Skip to main content

bamboo_plugin/
manifest.rs

1//! `plugin.json` manifest schema.
2//!
3//! A plugin bundle is a directory (installed at `~/.bamboo/plugins/<id>/`) with a
4//! `plugin.json` at its root describing what it *provides*: MCP servers, skills,
5//! prompt presets, workflows, supervised services, and ToolEvent sinks. This
6//! module defines that schema and a
7//! handful of pure, side-effect-free helpers (validation + `${...}` token
8//! substitution) that the installer (a later agent) builds on.
9//!
10//! # Directory layout convention
11//!
12//! ```text
13//! ~/.bamboo/plugins/<id>/
14//!   plugin.json          <- this manifest
15//!   skills/<skill-dir>/SKILL.md   (one or more, referenced by `provides.skills`)
16//!   prompts/              (optional; unused by the inline prompt design, see below)
17//!   workflows/<name>.md   (referenced by `provides.workflows`)
18//!   bin/<platform>/<id>[.exe]     (optional per-platform binary, see substitution contract)
19//! ```
20//!
21//! # Design decision: inline prompts, not file references
22//!
23//! `provides.prompts` is a `Vec<PluginPromptPreset>` with the preset content
24//! inlined directly in `plugin.json` (mirroring bamboo-server's
25//! `StoredPromptPreset { id, name, description?, content }`), rather than a list
26//! of filenames under `prompts/`. Rationale: prompt presets are small, and
27//! inlining keeps `plugin.json` a single self-contained source of truth the
28//! installer can validate and append into `prompt-presets.json` without a second
29//! file-read pass or an extra path-traversal surface. A future manifest version
30//! could add a file-reference variant if presets grow large enough to want
31//! external editing.
32//!
33//! # Substitution contract for `mcp_servers[].transport.stdio.{command,args,cwd,env}`
34//!
35//! Stdio MCP server commands may reference two tokens, resolved by the
36//! installer at install/registration time (see [`substitute_tokens`]):
37//!
38//! - `${plugin_dir}` — the absolute path to the installed plugin's root
39//!   directory (i.e. the directory containing `plugin.json`).
40//! - `${platform_bin}` — the absolute path to this plugin's per-platform
41//!   binary, resolved as `<plugin_dir>/bin/<platform>/<plugin id>[.exe on windows]`
42//!   where `<platform>` is one of `macos` | `windows` | `linux` (matching
43//!   [`Platform::as_str`]). This is a fixed naming convention (binary filename
44//!   == manifest `id`, `.exe` suffix only on Windows) so a single manifest
45//!   works across platforms without per-OS conditionals in `plugin.json` — if a
46//!   plugin needs a different binary name, it can still express that by joining
47//!   directly, e.g. `"${plugin_dir}/bin/${platform}/nova"` — but `${platform}`
48//!   alone is intentionally NOT provided as a token (see [`substitute_tokens`]
49//!   doc) to keep the contract to exactly two tokens.
50//!
51//! Tokens are substituted in `command`, each element of `args`, `cwd`, and each
52//! value in `env` (not env *keys*, and not in `url` for streamable_http —
53//! remote endpoints have no plugin-local path to inject).
54
55use std::collections::{BTreeMap, HashMap};
56use std::path::{Path, PathBuf};
57
58use serde::{Deserialize, Serialize};
59
60use bamboo_plugin_protocol::{
61    ToolEventSubscriptionId, FILE_CHANGED_SUBSCRIPTION_ID_V1, MAX_TOOL_EVENT_JSON_BYTES,
62    MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES, MAX_TOOL_EVENT_TOOL_NAME_BYTES, TOOL_EVENT_PROTOCOL_NAME,
63    TOOL_EVENT_V1_SCHEMA_VERSION,
64};
65
66use crate::error::{PluginError, PluginResult};
67
68/// Target OS gate / per-platform artifact key.
69///
70/// Kept as a 3-way enum (rather than a free-form string) so `platforms` /
71/// `${platform_bin}` resolution / artifact selection all agree on the exact
72/// same three spellings.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum Platform {
76    Macos,
77    Windows,
78    Linux,
79}
80
81impl Platform {
82    /// The platform this process is currently running on, if it is one of the
83    /// three Bamboo supports. `None` for anything else (e.g. `freebsd`) — a
84    /// platform gate should treat that as "not supported" rather than guess.
85    pub fn current() -> Option<Platform> {
86        Self::parse(std::env::consts::OS)
87    }
88
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Platform::Macos => "macos",
92            Platform::Windows => "windows",
93            Platform::Linux => "linux",
94        }
95    }
96
97    /// Parse the lowercase spelling used both in `plugin.json` and in
98    /// `std::env::consts::OS` (which already yields "macos"/"windows"/"linux").
99    pub fn parse(value: &str) -> Option<Platform> {
100        match value {
101            "macos" => Some(Platform::Macos),
102            "windows" => Some(Platform::Windows),
103            "linux" => Some(Platform::Linux),
104            _ => None,
105        }
106    }
107}
108
109impl std::fmt::Display for Platform {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.write_str(self.as_str())
112    }
113}
114
115/// A single MCP server this plugin wants to register, shaped like
116/// [`bamboo_domain::mcp_config::McpServerConfig`] but with `${plugin_dir}` /
117/// `${platform_bin}` tokens allowed in the stdio transport's path-shaped
118/// fields. See the module docs for the substitution contract.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpServerManifestEntry {
121    /// Server id — becomes the `mcpServers` map key once registered.
122    pub id: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub name: Option<String>,
125    #[serde(default = "default_true")]
126    pub enabled: bool,
127    pub transport: McpTransportManifest,
128    #[serde(default)]
129    pub allowed_tools: Vec<String>,
130    #[serde(default)]
131    pub denied_tools: Vec<String>,
132}
133
134fn default_true() -> bool {
135    true
136}
137
138/// Transport variants a manifest can declare. Mirrors
139/// [`bamboo_domain::mcp_config::TransportConfig`]'s two transports, minus
140/// the fields the installer fills in with sensible defaults at registration
141/// time (timeouts, reconnect policy) — a manifest author shouldn't need to
142/// know Bamboo's default timeout values.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum McpTransportManifest {
146    Stdio {
147        /// May contain `${plugin_dir}` / `${platform_bin}`.
148        command: String,
149        #[serde(default)]
150        args: Vec<String>,
151        /// May contain `${plugin_dir}` / `${platform_bin}`.
152        #[serde(default, skip_serializing_if = "Option::is_none")]
153        cwd: Option<String>,
154        /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
155        #[serde(default)]
156        env: HashMap<String, String>,
157    },
158    #[serde(rename = "streamable_http")]
159    StreamableHttp {
160        url: String,
161        #[serde(default)]
162        headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
163    },
164}
165
166impl McpServerManifestEntry {
167    /// Resolve this manifest entry into a real
168    /// [`bamboo_domain::mcp_config::McpServerConfig`], substituting
169    /// `${plugin_dir}` / `${platform_bin}` tokens and filling in Bamboo's
170    /// standard defaults for timeouts/reconnect. Pure — does not touch disk,
171    /// does not start anything. The caller (installer) is responsible for
172    /// merging the result into `config.json` and calling
173    /// `mcp_manager.start_server`.
174    pub fn resolve(
175        &self,
176        plugin_dir: &Path,
177        plugin_id: &str,
178        platform: Platform,
179    ) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
180        use bamboo_domain::mcp_config::{
181            default_connect_timeout, default_healthcheck_interval, default_request_timeout,
182            default_startup_timeout, McpServerConfig, ReconnectConfig, StdioConfig,
183            StreamableHttpConfig, TransportConfig,
184        };
185
186        let transport = match &self.transport {
187            McpTransportManifest::Stdio {
188                command,
189                args,
190                cwd,
191                env,
192            } => {
193                if command.trim().is_empty() {
194                    return Err(PluginError::InvalidManifest(format!(
195                        "mcp server '{}' has an empty stdio command",
196                        self.id
197                    )));
198                }
199                TransportConfig::Stdio(StdioConfig {
200                    command: substitute_tokens(command, plugin_dir, plugin_id, platform),
201                    args: args
202                        .iter()
203                        .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
204                        .collect(),
205                    cwd: cwd
206                        .as_deref()
207                        .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
208                    env: env
209                        .iter()
210                        .map(|(key, value)| {
211                            (
212                                key.clone(),
213                                substitute_tokens(value, plugin_dir, plugin_id, platform),
214                            )
215                        })
216                        .collect(),
217                    env_encrypted: HashMap::new(),
218                    env_credential_refs: std::collections::HashMap::new(),
219                    startup_timeout_ms: default_startup_timeout(),
220                })
221            }
222            McpTransportManifest::StreamableHttp { url, headers } => {
223                TransportConfig::StreamableHttp(StreamableHttpConfig {
224                    url: url.clone(),
225                    headers: headers.clone(),
226                    connect_timeout_ms: default_connect_timeout(),
227                })
228            }
229        };
230
231        Ok(McpServerConfig {
232            id: self.id.clone(),
233            name: self.name.clone(),
234            enabled: self.enabled,
235            transport,
236            request_timeout_ms: default_request_timeout(),
237            healthcheck_interval_ms: default_healthcheck_interval(),
238            reconnect: ReconnectConfig::default(),
239            allowed_tools: self.allowed_tools.clone(),
240            denied_tools: self.denied_tools.clone(),
241        })
242    }
243}
244
245/// Substitute `${plugin_dir}` and `${platform_bin}` in `template`. Unknown
246/// `${...}` tokens are left untouched (forward-compatible: a newer manifest
247/// using a token an older Bamboo doesn't know about degrades to a literal
248/// string rather than failing).
249pub fn substitute_tokens(
250    template: &str,
251    plugin_dir: &Path,
252    plugin_id: &str,
253    platform: Platform,
254) -> String {
255    let plugin_dir_str = plugin_dir.to_string_lossy();
256    let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
257        .to_string_lossy()
258        .into_owned();
259    template
260        .replace("${plugin_dir}", plugin_dir_str.as_ref())
261        .replace("${platform_bin}", &platform_bin_str)
262}
263
264/// Resolve the fixed-convention per-platform binary path:
265/// `<plugin_dir>/bin/<platform>/<plugin_id>[.exe]`.
266pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
267    let filename = if matches!(platform, Platform::Windows) {
268        format!("{plugin_id}.exe")
269    } else {
270        plugin_id.to_string()
271    };
272    plugin_dir
273        .join("bin")
274        .join(platform.as_str())
275        .join(filename)
276}
277
278/// Literal token a [`ServiceManifestEntry::command`] must equal EXACTLY (no
279/// PATH resolution, no ambient binaries — see [`ServiceManifestEntry`]'s
280/// docs and `PluginManifest::validate`). Also used by
281/// [`PluginManifest::uses_platform_bin_token`].
282pub const PLATFORM_BIN_TOKEN: &str = "${platform_bin}";
283
284/// How [`ServiceManager`](../../bamboo_server/service_manager/index.html)
285/// (bamboo-server) should poll a running service for liveness. `ProcessAlive`
286/// is the v1 default (no `target`); `Tcp`/`Http` additionally require a
287/// `target` (validated in [`PluginManifest::validate`]).
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum HealthCheckKind {
291    ProcessAlive,
292    Tcp,
293    Http,
294}
295
296fn default_health_interval_ms() -> u64 {
297    15_000
298}
299
300fn default_health_timeout_ms() -> u64 {
301    5_000
302}
303
304/// Health-check policy for a [`ServiceManifestEntry`].
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct HealthCheckSpec {
307    pub kind: HealthCheckKind,
308    /// Required (non-empty) for `Tcp` (`host:port`) / `Http` (a URL);
309    /// unused for `ProcessAlive`. Validated in [`PluginManifest::validate`].
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub target: Option<String>,
312    #[serde(default = "default_health_interval_ms")]
313    pub interval_ms: u64,
314    #[serde(default = "default_health_timeout_ms")]
315    pub timeout_ms: u64,
316}
317
318impl Default for HealthCheckSpec {
319    fn default() -> Self {
320        Self {
321            kind: HealthCheckKind::ProcessAlive,
322            target: None,
323            interval_ms: default_health_interval_ms(),
324            timeout_ms: default_health_timeout_ms(),
325        }
326    }
327}
328
329/// Signal a service's graceful shutdown sends before escalating to a hard
330/// kill. `Term` (SIGTERM on unix; a best-effort equivalent request on
331/// Windows before `TerminateProcess`) is the default; `None` skips the
332/// graceful signal entirely and kills immediately.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum ShutdownSignal {
336    #[default]
337    Term,
338    None,
339}
340
341fn default_shutdown_timeout_ms() -> u64 {
342    5_000
343}
344
345/// Graceful-shutdown policy for a [`ServiceManifestEntry`].
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct GracefulShutdown {
348    #[serde(default)]
349    pub signal: ShutdownSignal,
350    /// How long to wait after `signal` before escalating to SIGKILL /
351    /// `TerminateProcess`.
352    #[serde(default = "default_shutdown_timeout_ms")]
353    pub timeout_ms: u64,
354}
355
356impl Default for GracefulShutdown {
357    fn default() -> Self {
358        Self {
359            signal: ShutdownSignal::default(),
360            timeout_ms: default_shutdown_timeout_ms(),
361        }
362    }
363}
364
365/// Optional stdin wire contract for a supervised plugin service.
366///
367/// `None` preserves the original service behavior: stdin is connected to the
368/// null device and Bamboo never creates an input writer. `NdjsonV1` opts the
369/// verified service binary into one JSON value per line over a pipe owned by
370/// the exact supervised process generation.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum ServiceInputProtocol {
374    #[default]
375    None,
376    NdjsonV1,
377}
378
379impl ServiceInputProtocol {
380    fn is_none(&self) -> bool {
381        matches!(self, Self::None)
382    }
383}
384
385/// A long-running service this plugin wants supervised (issue #479, prereq
386/// for epic #477 — standalone connectors distributed as plugins). The
387/// highest-trust artifact kind a plugin can declare: unlike an MCP stdio
388/// server (whose `command` is free-form — see [`McpServerManifestEntry`]), a
389/// service's `command` MUST be exactly [`PLATFORM_BIN_TOKEN`] — no PATH
390/// resolution, no ambient binaries. It may only ever execute the plugin's
391/// own verified, sha256-pinned per-platform binary (see
392/// [`PluginArtifact`]'s archive contract) resolved via
393/// [`platform_bin_path`].
394///
395/// `args`/`cwd`/`env` (values only) accept the same `${plugin_dir}`/
396/// `${platform_bin}` substitution as MCP stdio entries — see
397/// [`substitute_tokens`].
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ServiceManifestEntry {
400    /// Service id — becomes the key bamboo-server's `ServiceManager` and
401    /// provenance (`RegisteredCapabilities::service_ids`) key off.
402    pub id: String,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub name: Option<String>,
405    #[serde(default = "default_true")]
406    pub enabled: bool,
407    /// MUST validate as exactly [`PLATFORM_BIN_TOKEN`] — see the type docs.
408    pub command: String,
409    #[serde(default)]
410    pub args: Vec<String>,
411    /// May contain `${plugin_dir}` / `${platform_bin}`.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub cwd: Option<String>,
414    /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
415    #[serde(default)]
416    pub env: HashMap<String, String>,
417    #[serde(default)]
418    pub health_check: HealthCheckSpec,
419    /// Reuses [`bamboo_domain::mcp_config::ReconnectConfig`]'s shape
420    /// (enabled/initial_backoff_ms/max_backoff_ms/max_attempts) per the
421    /// issue's design.
422    #[serde(default)]
423    pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
424    #[serde(default)]
425    pub graceful_shutdown: GracefulShutdown,
426    /// Explicit opt-in to Bamboo-owned service stdin. Omitted/legacy entries
427    /// remain `none` and retain the pre-input null-stdin behavior.
428    #[serde(default, skip_serializing_if = "ServiceInputProtocol::is_none")]
429    pub input_protocol: ServiceInputProtocol,
430}
431
432/// Protocol family and wire version requested by an event sink.
433///
434/// The family stays an open string at parse time. Validation accepts Bamboo's
435/// `tool_event` family; future non-zero versions remain installable and are
436/// explicitly reconciled as inactive until supported by the host.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct EventSinkProtocolManifest {
439    pub name: String,
440    pub version: u16,
441    /// Opaque fields reserved for future protocol versions. ToolEventV1 is a
442    /// closed schema and rejects these during validation; a future version is
443    /// kept parseable (and inactive) without silently losing its extensions.
444    #[serde(default, flatten)]
445    pub extensions: BTreeMap<String, serde_json::Value>,
446}
447
448/// Declarative queue/event bounds. The router introduced by #905 owns their
449/// allocation and enforcement; #903 validates the supported v1 envelope.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct EventSinkDeliveryLimits {
452    #[serde(default = "default_event_sink_queue_capacity")]
453    pub queue_capacity: u32,
454    #[serde(default = "default_event_sink_max_event_bytes")]
455    pub max_event_bytes: u32,
456    #[serde(default, flatten)]
457    pub extensions: BTreeMap<String, serde_json::Value>,
458}
459
460pub const DEFAULT_EVENT_SINK_QUEUE_CAPACITY: u32 = 64;
461pub const MAX_EVENT_SINK_QUEUE_CAPACITY: u32 = 1024;
462pub const MAX_EVENT_SINK_EVENT_BYTES: u32 = 1024 * 1024;
463pub const MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES: u64 = 64 * 1024 * 1024;
464pub const MAX_EVENT_SINKS_PER_PLUGIN: usize = 64;
465pub const MAX_EVENT_SINK_ID_BYTES: usize = 128;
466pub const MAX_EVENT_SINK_SERVICE_ID_BYTES: usize = 128;
467pub const MAX_EVENT_SINK_SUBSCRIPTIONS: usize = 32;
468pub const MAX_EVENT_SINK_TOOL_NAMES: usize = 64;
469pub const MAX_EVENT_SINK_PERMISSIONS: usize = 32;
470pub const MAX_EVENT_SINK_PERMISSION_ID_BYTES: usize = 64;
471pub const MAX_EVENT_SINK_EXTENSION_FIELDS: usize = 16;
472pub const MAX_EVENT_SINK_EXTENSION_KEY_BYTES: usize = 64;
473pub const MAX_EVENT_SINK_EXTENSION_VALUE_BYTES: usize = 4096;
474
475fn default_event_sink_queue_capacity() -> u32 {
476    DEFAULT_EVENT_SINK_QUEUE_CAPACITY
477}
478
479fn default_event_sink_max_event_bytes() -> u32 {
480    MAX_TOOL_EVENT_JSON_BYTES as u32
481}
482
483impl Default for EventSinkDeliveryLimits {
484    fn default() -> Self {
485        Self {
486            queue_capacity: default_event_sink_queue_capacity(),
487            max_event_bytes: default_event_sink_max_event_bytes(),
488            extensions: BTreeMap::new(),
489        }
490    }
491}
492
493fn validate_event_sink_extensions(
494    sink_id: &str,
495    scope: &str,
496    extensions: &BTreeMap<String, serde_json::Value>,
497    strict_v1: bool,
498) -> PluginResult<()> {
499    if strict_v1 && !extensions.is_empty() {
500        return Err(PluginError::InvalidManifest(format!(
501            "event sink '{sink_id}' ToolEventV1 {scope} contains unknown field(s): {}",
502            extensions.keys().cloned().collect::<Vec<_>>().join(", ")
503        )));
504    }
505    if extensions.len() > MAX_EVENT_SINK_EXTENSION_FIELDS {
506        return Err(PluginError::InvalidManifest(format!(
507            "event sink '{sink_id}' {scope} exceeds the extension-field limit of {MAX_EVENT_SINK_EXTENSION_FIELDS}"
508        )));
509    }
510    for (key, value) in extensions {
511        if key.trim().is_empty() || key.len() > MAX_EVENT_SINK_EXTENSION_KEY_BYTES {
512            return Err(PluginError::InvalidManifest(format!(
513                "event sink '{sink_id}' {scope} contains an invalid extension key"
514            )));
515        }
516        let value_len = serde_json::to_vec(value)
517            .map_err(|error| {
518                PluginError::InvalidManifest(format!(
519                    "event sink '{sink_id}' {scope} extension '{key}' cannot be serialized: {error}"
520                ))
521            })?
522            .len();
523        if value_len > MAX_EVENT_SINK_EXTENSION_VALUE_BYTES {
524            return Err(PluginError::InvalidManifest(format!(
525                "event sink '{sink_id}' {scope} extension '{key}' exceeds the value-size limit of {MAX_EVENT_SINK_EXTENSION_VALUE_BYTES} bytes"
526            )));
527        }
528    }
529    Ok(())
530}
531
532/// Stable v1 observation-permission spellings. These are requests, not grants;
533/// #907 applies host policy before serialization.
534pub const OBSERVE_METADATA_PERMISSION: &str = "metadata";
535pub const OBSERVE_TOOL_NAME_PERMISSION: &str = "tool_name";
536pub const OBSERVE_PATHS_PERMISSION: &str = "paths";
537pub const OBSERVE_DIFF_PERMISSION: &str = "diff";
538pub const OBSERVE_CONTENT_PERMISSION: &str = "content";
539
540/// Forward-compatible observation permission requested by an event sink.
541///
542/// The host interprets the known ToolEventV1 spellings during validation while
543/// retaining unknown values from future protocol versions as opaque ids.
544#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
545#[serde(transparent)]
546pub struct ObservationPermissionId(String);
547
548impl ObservationPermissionId {
549    pub fn new(value: impl Into<String>) -> Self {
550        Self(value.into())
551    }
552
553    pub fn as_str(&self) -> &str {
554        &self.0
555    }
556}
557
558fn default_event_sink_permissions() -> Vec<ObservationPermissionId> {
559    vec![ObservationPermissionId::new(OBSERVE_METADATA_PERMISSION)]
560}
561
562/// One open subscription id plus an optional canonical-tool-name filter. Both
563/// stay as bounded strings so a future protocol version can be installed and
564/// represented as degraded without older serde code rejecting it.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct EventSinkSubscriptionManifest {
567    pub id: ToolEventSubscriptionId,
568    #[serde(default, skip_serializing_if = "Vec::is_empty")]
569    pub tool_names: Vec<String>,
570    #[serde(default, flatten)]
571    pub extensions: BTreeMap<String, serde_json::Value>,
572}
573
574/// A ToolEvent consumer backed by this plugin's own verified service.
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
576pub struct EventSinkManifestEntry {
577    pub id: String,
578    pub service_id: String,
579    pub protocol: EventSinkProtocolManifest,
580    #[serde(default)]
581    pub subscriptions: Vec<EventSinkSubscriptionManifest>,
582    #[serde(default)]
583    pub delivery: EventSinkDeliveryLimits,
584    #[serde(default = "default_event_sink_permissions")]
585    pub requested_permissions: Vec<ObservationPermissionId>,
586    /// Optional sink-specific platform gate. It may only narrow the plugin's
587    /// own gate. A sink is inactive when the current host is not listed.
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub platforms: Option<Vec<Platform>>,
590    #[serde(default, flatten)]
591    pub extensions: BTreeMap<String, serde_json::Value>,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(tag = "reason", rename_all = "snake_case")]
596pub enum EventSinkInactiveReason {
597    UnsupportedProtocolVersion {
598        requested: u16,
599        supported: u16,
600    },
601    InstallIncomplete,
602    PlatformIneligible,
603    ServiceDisabled,
604    /// The manifest narrows delivery using a field that the host has not
605    /// granted this sink permission to observe. Keeping the sink inactive
606    /// avoids turning delivery versus non-delivery into a side channel.
607    ObservationPermissionNotGranted {
608        permission: ObservationPermissionId,
609    },
610}
611
612/// Eligibility emitted by pure reconciliation. `Eligible` intentionally does
613/// not claim that #903 has started delivery; the later router owns activation.
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
615#[serde(tag = "status", rename_all = "snake_case")]
616pub enum EventSinkCapabilityState {
617    Eligible,
618    Inactive { detail: EventSinkInactiveReason },
619}
620
621impl EventSinkManifestEntry {
622    pub fn capability_state(
623        &self,
624        service: &ServiceManifestEntry,
625        platform: Option<Platform>,
626    ) -> EventSinkCapabilityState {
627        if self.protocol.version > TOOL_EVENT_V1_SCHEMA_VERSION {
628            return EventSinkCapabilityState::Inactive {
629                detail: EventSinkInactiveReason::UnsupportedProtocolVersion {
630                    requested: self.protocol.version,
631                    supported: TOOL_EVENT_V1_SCHEMA_VERSION,
632                },
633            };
634        }
635        if platform.is_none()
636            || self.platforms.as_ref().is_some_and(|platforms| {
637                platform.is_some_and(|platform| !platforms.contains(&platform))
638            })
639        {
640            return EventSinkCapabilityState::Inactive {
641                detail: EventSinkInactiveReason::PlatformIneligible,
642            };
643        }
644        if !service.enabled {
645            return EventSinkCapabilityState::Inactive {
646                detail: EventSinkInactiveReason::ServiceDisabled,
647            };
648        }
649        EventSinkCapabilityState::Eligible
650    }
651}
652
653/// A [`ServiceManifestEntry`] with all `${...}` tokens substituted and
654/// `command` resolved to the concrete per-platform binary path — pure, ready
655/// for bamboo-server's `ServiceManager` to spawn. Analogous to
656/// [`McpServerManifestEntry::resolve`]'s `McpServerConfig` output.
657#[derive(Debug, Clone)]
658pub struct ResolvedServiceEntry {
659    pub id: String,
660    pub name: Option<String>,
661    pub enabled: bool,
662    pub command: PathBuf,
663    pub args: Vec<String>,
664    pub cwd: Option<PathBuf>,
665    pub env: HashMap<String, String>,
666    pub health_check: HealthCheckSpec,
667    pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
668    pub graceful_shutdown: GracefulShutdown,
669    pub input_protocol: ServiceInputProtocol,
670}
671
672impl ServiceManifestEntry {
673    /// Resolve this manifest entry against a concrete `plugin_dir`/platform.
674    /// Pure — does not touch disk, does not spawn anything. `command` is
675    /// always [`platform_bin_path`] (never `substitute_tokens`'d from
676    /// `self.command`) — validation already pins `self.command` to exactly
677    /// [`PLATFORM_BIN_TOKEN`], and `platform_bin_path` IS that token's
678    /// resolution.
679    pub fn resolve(
680        &self,
681        plugin_dir: &Path,
682        plugin_id: &str,
683        platform: Platform,
684    ) -> ResolvedServiceEntry {
685        ResolvedServiceEntry {
686            id: self.id.clone(),
687            name: self.name.clone(),
688            enabled: self.enabled,
689            command: platform_bin_path(plugin_dir, plugin_id, platform),
690            args: self
691                .args
692                .iter()
693                .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
694                .collect(),
695            cwd: self.cwd.as_deref().map(|value| {
696                PathBuf::from(substitute_tokens(value, plugin_dir, plugin_id, platform))
697            }),
698            env: self
699                .env
700                .iter()
701                .map(|(key, value)| {
702                    (
703                        key.clone(),
704                        substitute_tokens(value, plugin_dir, plugin_id, platform),
705                    )
706                })
707                .collect(),
708            health_check: self.health_check.clone(),
709            restart_policy: self.restart_policy.clone(),
710            graceful_shutdown: self.graceful_shutdown.clone(),
711            input_protocol: self.input_protocol,
712        }
713    }
714}
715
716/// Inline prompt preset, mirroring bamboo-server's
717/// `StoredPromptPreset { id, name, description?, content }` (see
718/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`).
719/// `id` must satisfy the same rule bamboo-server enforces:
720/// `[a-z0-9_]`, length <= 80 (see [`is_valid_preset_id`]).
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct PluginPromptPreset {
723    pub id: String,
724    pub name: String,
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub description: Option<String>,
727    pub content: String,
728}
729
730/// Per-platform downloadable artifact for the URL-install source (fetch logic
731/// is a later agent's job — this is schema-only).
732///
733/// # Archive contract (pinned for Wave-2 fetch code + plugin authors)
734///
735/// `url` points at an **archive**, never a raw executable: a `.zip` **or**
736/// `.tar.gz`/`.tgz`. The installer:
737/// 1. downloads it, verifies [`Self::sha256`] (lowercase hex, over the raw
738///    archive bytes) BEFORE unpacking anything,
739/// 2. unpacks it, and expects **exactly one executable at the archive root**
740///    named `<plugin id>` (unix) or `<plugin id>.exe` (windows),
741/// 3. places that executable at `<plugin_dir>/bin/<platform>/<plugin id>[.exe]`
742///    — the exact path [`platform_bin_path`] resolves, so `${platform_bin}`
743///    then points at it.
744///
745/// This matches how real release assets ship (e.g. nova's are
746/// `nova-v<ver>-<triple>.zip` with `nova.exe` at the zip root, and a
747/// `.tar.gz` with `nova` at the tar root) — a plugin does NOT have to
748/// re-layout its release binaries, it just declares the archive URL + hash.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct PluginArtifact {
751    /// Archive URL (`.zip` / `.tar.gz` / `.tgz`) — see the type-level docs.
752    pub url: String,
753    /// Lowercase hex-encoded sha256 of the raw archive bytes, verified by the
754    /// installer after download and BEFORE unpacking.
755    pub sha256: String,
756}
757
758/// What a plugin provides: any subset of MCP servers, skills, prompt presets,
759/// and (future) workflows.
760#[derive(Debug, Clone, Default, Serialize, Deserialize)]
761pub struct PluginProvides {
762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
763    pub hooks: Vec<crate::hooks::HookDeclaration>,
764    #[serde(default, skip_serializing_if = "Vec::is_empty")]
765    pub mcp_servers: Vec<McpServerManifestEntry>,
766    /// Directory names under `<plugin_dir>/skills/`. Each must contain a
767    /// `SKILL.md`. These are discovered *in place* (no copy, no symlink) once
768    /// the skill-discovery extension picks up the plugin dir — see
769    /// `bamboo-skills`' `SkillDirectorySource::Plugin`. Declaring them here is
770    /// for provenance/validation, not for making discovery work.
771    #[serde(default, skip_serializing_if = "Vec::is_empty")]
772    pub skills: Vec<String>,
773    #[serde(default, skip_serializing_if = "Vec::is_empty")]
774    pub prompts: Vec<PluginPromptPreset>,
775    /// `.md` filenames under `<plugin_dir>/workflows/`. They remain in place
776    /// and are discovered as read-only legacy Skill adapters; installation
777    /// never copies them into a user's global workflow directory.
778    #[serde(default, skip_serializing_if = "Vec::is_empty")]
779    pub workflows: Vec<String>,
780    /// Long-running services this plugin wants supervised — see
781    /// [`ServiceManifestEntry`]. Issue #479 (prereq for epic #477).
782    #[serde(default, skip_serializing_if = "Vec::is_empty")]
783    pub services: Vec<ServiceManifestEntry>,
784    /// ToolEvent consumers backed by this plugin's own verified services.
785    #[serde(default, skip_serializing_if = "Vec::is_empty")]
786    pub event_sinks: Vec<EventSinkManifestEntry>,
787}
788
789impl PluginProvides {
790    pub fn is_empty(&self) -> bool {
791        self.hooks.is_empty()
792            && self.mcp_servers.is_empty()
793            && self.skills.is_empty()
794            && self.prompts.is_empty()
795            && self.workflows.is_empty()
796            && self.services.is_empty()
797            && self.event_sinks.is_empty()
798    }
799}
800
801/// The `plugin.json` manifest.
802#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct PluginManifest {
804    /// Stable identifier, `[a-z0-9_-]`, used as the install directory name
805    /// (`~/.bamboo/plugins/<id>/`) and default binary name.
806    pub id: String,
807    pub name: String,
808    /// Semver-shaped string (`major.minor.patch[-pre][+build]`). Validated
809    /// structurally by [`PluginManifest::validate`]; actual semver comparison
810    /// for upgrade decisions is the installer's job (not depended on here to
811    /// avoid pulling in a semver crate for a foundation crate).
812    pub version: String,
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub description: Option<String>,
815    /// Minimum Bamboo version required, same shape as `version`.
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub bamboo_min_version: Option<String>,
818    /// Platform gate. `None` means "no restriction" (all platforms). `Some([])`
819    /// is rejected by [`PluginManifest::validate`] (an explicit empty gate
820    /// would mean "installable nowhere", which is never intended).
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub platforms: Option<Vec<Platform>>,
823    #[serde(default)]
824    pub provides: PluginProvides,
825    /// Per-platform downloadable bundle for the URL-install source. Keys are
826    /// the same lowercase strings as [`Platform::as_str`] (kept as `String`
827    /// rather than `Platform` here so an unknown/typo'd key surfaces as a
828    /// clear validation error instead of a silent serde failure on the whole
829    /// map — see [`PluginManifest::validate`]).
830    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
831    pub artifacts: HashMap<String, PluginArtifact>,
832}
833
834const MAX_PLUGIN_ID_LEN: usize = 64;
835const MAX_PRESET_ID_LEN: usize = 80;
836
837/// Preset ids the plugin system must NOT let a plugin claim, because
838/// bamboo-server reserves them. `"general_assistant"` is its
839/// `DEFAULT_PRESET_ID` (see
840/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`);
841/// `sanitize_store` there silently STRIPS any stored preset with that id, so a
842/// plugin declaring it would pass a naive `[a-z0-9_]` check but then vanish at
843/// runtime with no error. Reject it up front at manifest validation instead.
844const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
845
846/// `[a-z0-9-_]`, non-empty, no leading/trailing separator, no `--`/`__` runs
847/// are NOT specifically forbidden (unlike skill ids) since plugin ids may
848/// legitimately contain underscores (e.g. ported from an npm-style package
849/// name) — only characters and length are constrained.
850pub fn is_valid_plugin_id(id: &str) -> bool {
851    !id.is_empty()
852        && id.len() <= MAX_PLUGIN_ID_LEN
853        && id
854            .chars()
855            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
856}
857
858/// Same rule bamboo-server's prompt-preset store enforces
859/// (`validate_preset_id` in `handlers/agent/prompt_presets/storage.rs`):
860/// `[a-z0-9_]`, length <= 80 — plus a rejection of ids bamboo-server reserves
861/// (see [`RESERVED_PRESET_IDS`]) so a plugin can't declare one that would be
862/// silently dropped later.
863pub fn is_valid_preset_id(id: &str) -> bool {
864    !id.is_empty()
865        && id.len() <= MAX_PRESET_ID_LEN
866        && !RESERVED_PRESET_IDS.contains(&id)
867        && id
868            .chars()
869            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
870}
871
872/// A conservative, dependency-free semver *shape* check: `N.N.N` with
873/// optional `-pre` / `+build` suffixes. Doesn't validate pre-release/build
874/// identifier grammar precisely — good enough to reject obviously-wrong
875/// strings (`"latest"`, `""`, `"1.0"`) without adding a `semver` dependency to
876/// a foundation crate. The installer can layer stricter comparison later.
877pub fn is_plausible_semver(value: &str) -> bool {
878    let core = value.split(['-', '+']).next().unwrap_or_default();
879    let parts: Vec<&str> = core.split('.').collect();
880    parts.len() == 3
881        && parts
882            .iter()
883            .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
884}
885
886/// Rejects path separators, `..` traversal, empty strings, and control
887/// characters — used for both `provides.skills` dir names and
888/// `provides.workflows` filenames (which additionally must end in `.md`).
889fn is_safe_relative_name(name: &str) -> bool {
890    !name.is_empty()
891        && !name.contains('/')
892        && !name.contains('\\')
893        && !name.contains("..")
894        && !name.chars().any(|ch| ch.is_control())
895}
896
897impl PluginManifest {
898    /// Parse a manifest from a `plugin.json` file's contents. Does not
899    /// validate — call [`Self::validate`] separately (parse vs. validate are
900    /// kept distinct so a caller can inspect an invalid-but-parseable
901    /// manifest, e.g. to report a precise validation error).
902    pub fn parse_str(content: &str) -> PluginResult<Self> {
903        serde_json::from_str(content).map_err(PluginError::from)
904    }
905
906    /// Structural validation beyond what serde already enforces. Does not
907    /// touch disk (skill-dir / workflow-file *existence* checks happen at
908    /// install time against a concrete `plugin_dir`, not here).
909    pub fn validate(&self) -> PluginResult<()> {
910        if !is_valid_plugin_id(&self.id) {
911            return Err(PluginError::InvalidManifest(format!(
912                "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
913                self.id, MAX_PLUGIN_ID_LEN
914            )));
915        }
916        if self.name.trim().is_empty() {
917            return Err(PluginError::InvalidManifest(
918                "plugin name must not be empty".to_string(),
919            ));
920        }
921        if !is_plausible_semver(&self.version) {
922            return Err(PluginError::InvalidManifest(format!(
923                "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
924                self.version
925            )));
926        }
927        if let Some(min_version) = &self.bamboo_min_version {
928            if !is_plausible_semver(min_version) {
929                return Err(PluginError::InvalidManifest(format!(
930                    "invalid bamboo_min_version '{min_version}'"
931                )));
932            }
933        }
934        if let Some(platforms) = &self.platforms {
935            if platforms.is_empty() {
936                return Err(PluginError::InvalidManifest(
937                    "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
938                        .to_string(),
939                ));
940            }
941        }
942
943        let mut seen_mcp_ids = std::collections::HashSet::new();
944        for entry in &self.provides.mcp_servers {
945            if entry.id.trim().is_empty() {
946                return Err(PluginError::InvalidManifest(
947                    "mcp server entries must have a non-empty id".to_string(),
948                ));
949            }
950            if !seen_mcp_ids.insert(entry.id.clone()) {
951                return Err(PluginError::InvalidManifest(format!(
952                    "duplicate mcp server id '{}' in provides.mcp_servers",
953                    entry.id
954                )));
955            }
956            if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
957                if command.trim().is_empty() {
958                    return Err(PluginError::InvalidManifest(format!(
959                        "mcp server '{}' has an empty stdio command",
960                        entry.id
961                    )));
962                }
963            }
964        }
965
966        let mut seen_service_ids = std::collections::HashSet::new();
967        for entry in &self.provides.services {
968            if entry.id.trim().is_empty() {
969                return Err(PluginError::InvalidManifest(
970                    "service entries must have a non-empty id".to_string(),
971                ));
972            }
973            if !seen_service_ids.insert(entry.id.clone()) {
974                return Err(PluginError::InvalidManifest(format!(
975                    "duplicate service id '{}' in provides.services",
976                    entry.id
977                )));
978            }
979            if entry.command.trim().is_empty() {
980                return Err(PluginError::InvalidManifest(format!(
981                    "service '{}' has an empty command",
982                    entry.id
983                )));
984            }
985            // Services are the highest-trust artifact kind: no PATH
986            // resolution, no ambient binaries. `command` must be EXACTLY the
987            // substitution token, never a literal path or shell command —
988            // stricter than MCP's free-form stdio `command`.
989            if entry.command != PLATFORM_BIN_TOKEN {
990                return Err(PluginError::InvalidManifest(format!(
991                    "service '{}' command must be exactly '{PLATFORM_BIN_TOKEN}' — services may \
992                     only execute the plugin's own verified per-platform binary, never an \
993                     arbitrary command",
994                    entry.id
995                )));
996            }
997            match entry.health_check.kind {
998                HealthCheckKind::Tcp | HealthCheckKind::Http => {
999                    let target_ok = entry
1000                        .health_check
1001                        .target
1002                        .as_deref()
1003                        .map(|value| !value.trim().is_empty())
1004                        .unwrap_or(false);
1005                    if !target_ok {
1006                        return Err(PluginError::InvalidManifest(format!(
1007                            "service '{}' health_check.kind={:?} requires a non-empty target",
1008                            entry.id, entry.health_check.kind
1009                        )));
1010                    }
1011                }
1012                HealthCheckKind::ProcessAlive => {}
1013            }
1014        }
1015
1016        let plugin_platforms = self.effective_platforms();
1017        if self.provides.event_sinks.len() > MAX_EVENT_SINKS_PER_PLUGIN {
1018            return Err(PluginError::InvalidManifest(format!(
1019                "provides.event_sinks exceeds the per-plugin limit of {MAX_EVENT_SINKS_PER_PLUGIN}"
1020            )));
1021        }
1022        let mut seen_sink_ids = std::collections::HashSet::new();
1023        let mut declared_buffer_bytes = 0_u64;
1024        for sink in &self.provides.event_sinks {
1025            if sink.id.trim().is_empty() || sink.id.len() > MAX_EVENT_SINK_ID_BYTES {
1026                return Err(PluginError::InvalidManifest(format!(
1027                    "event sink id '{}' must be non-empty and no more than {} UTF-8 bytes",
1028                    sink.id, MAX_EVENT_SINK_ID_BYTES
1029                )));
1030            }
1031            if !seen_sink_ids.insert(sink.id.as_str()) {
1032                return Err(PluginError::InvalidManifest(format!(
1033                    "duplicate event sink id '{}' in provides.event_sinks",
1034                    sink.id
1035                )));
1036            }
1037            if sink.service_id.trim().is_empty()
1038                || sink.service_id.len() > MAX_EVENT_SINK_SERVICE_ID_BYTES
1039            {
1040                return Err(PluginError::InvalidManifest(format!(
1041                    "event sink '{}' must reference a non-empty service id no longer than {} UTF-8 bytes",
1042                    sink.id, MAX_EVENT_SINK_SERVICE_ID_BYTES
1043                )));
1044            }
1045            let Some(service) = self
1046                .provides
1047                .services
1048                .iter()
1049                .find(|service| service.id == sink.service_id)
1050            else {
1051                return Err(PluginError::InvalidManifest(format!(
1052                    "event sink '{}' references service '{}' which is not declared by the same plugin",
1053                    sink.id, sink.service_id
1054                )));
1055            };
1056            if sink.protocol.name != TOOL_EVENT_PROTOCOL_NAME {
1057                return Err(PluginError::InvalidManifest(format!(
1058                    "event sink '{}' uses unknown protocol family '{}' (expected '{}')",
1059                    sink.id, sink.protocol.name, TOOL_EVENT_PROTOCOL_NAME
1060                )));
1061            }
1062            if sink.protocol.version == 0 {
1063                return Err(PluginError::InvalidManifest(format!(
1064                    "event sink '{}' protocol version must be non-zero",
1065                    sink.id
1066                )));
1067            }
1068            let strict_v1 = sink.protocol.version == TOOL_EVENT_V1_SCHEMA_VERSION;
1069            if strict_v1 && service.input_protocol != ServiceInputProtocol::NdjsonV1 {
1070                return Err(PluginError::InvalidManifest(format!(
1071                    "ToolEventV1 event sink '{}' requires service '{}' to declare input_protocol 'ndjson_v1'",
1072                    sink.id, sink.service_id
1073                )));
1074            }
1075            validate_event_sink_extensions(&sink.id, "declaration", &sink.extensions, strict_v1)?;
1076            validate_event_sink_extensions(
1077                &sink.id,
1078                "protocol",
1079                &sink.protocol.extensions,
1080                strict_v1,
1081            )?;
1082            validate_event_sink_extensions(
1083                &sink.id,
1084                "delivery",
1085                &sink.delivery.extensions,
1086                strict_v1,
1087            )?;
1088            if sink.subscriptions.is_empty()
1089                || sink.subscriptions.len() > MAX_EVENT_SINK_SUBSCRIPTIONS
1090            {
1091                return Err(PluginError::InvalidManifest(format!(
1092                    "event sink '{}' must request 1..={MAX_EVENT_SINK_SUBSCRIPTIONS} subscriptions",
1093                    sink.id,
1094                )));
1095            }
1096            let mut seen_subscriptions = std::collections::HashSet::new();
1097            for subscription in &sink.subscriptions {
1098                validate_event_sink_extensions(
1099                    &sink.id,
1100                    "subscription",
1101                    &subscription.extensions,
1102                    strict_v1,
1103                )?;
1104                let subscription_id = subscription.id.as_str();
1105                if subscription_id.trim().is_empty()
1106                    || subscription_id.len() > MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES
1107                {
1108                    return Err(PluginError::InvalidManifest(format!(
1109                        "event sink '{}' has an invalid subscription id",
1110                        sink.id
1111                    )));
1112                }
1113                if !seen_subscriptions.insert(subscription_id) {
1114                    return Err(PluginError::InvalidManifest(format!(
1115                        "event sink '{}' repeats subscription '{}'",
1116                        sink.id, subscription_id
1117                    )));
1118                }
1119                if subscription.tool_names.len() > MAX_EVENT_SINK_TOOL_NAMES {
1120                    return Err(PluginError::InvalidManifest(format!(
1121                        "event sink '{}' subscription '{}' exceeds the tool-name limit of {MAX_EVENT_SINK_TOOL_NAMES}",
1122                        sink.id, subscription_id
1123                    )));
1124                }
1125                let mut seen_tool_names = std::collections::HashSet::new();
1126                for tool_name in &subscription.tool_names {
1127                    if tool_name.trim().is_empty()
1128                        || tool_name.len() > MAX_TOOL_EVENT_TOOL_NAME_BYTES
1129                    {
1130                        return Err(PluginError::InvalidManifest(format!(
1131                            "event sink '{}' subscription '{}' has an invalid tool name",
1132                            sink.id, subscription_id
1133                        )));
1134                    }
1135                    if !seen_tool_names.insert(tool_name.as_str()) {
1136                        return Err(PluginError::InvalidManifest(format!(
1137                            "event sink '{}' subscription '{}' repeats tool name '{}'",
1138                            sink.id, subscription_id, tool_name
1139                        )));
1140                    }
1141                }
1142            }
1143            if sink.requested_permissions.is_empty()
1144                || sink.requested_permissions.len() > MAX_EVENT_SINK_PERMISSIONS
1145            {
1146                return Err(PluginError::InvalidManifest(format!(
1147                    "event sink '{}' must request 1..={MAX_EVENT_SINK_PERMISSIONS} observation permissions",
1148                    sink.id,
1149                )));
1150            }
1151            let mut seen_permissions = std::collections::HashSet::new();
1152            for permission in &sink.requested_permissions {
1153                let permission_id = permission.as_str();
1154                if permission_id.trim().is_empty()
1155                    || permission_id.len() > MAX_EVENT_SINK_PERMISSION_ID_BYTES
1156                {
1157                    return Err(PluginError::InvalidManifest(format!(
1158                        "event sink '{}' has an invalid observation permission",
1159                        sink.id
1160                    )));
1161                }
1162                if !seen_permissions.insert(permission_id) {
1163                    return Err(PluginError::InvalidManifest(format!(
1164                        "event sink '{}' repeats observation permission '{}'",
1165                        sink.id, permission_id
1166                    )));
1167                }
1168            }
1169            if sink.delivery.queue_capacity == 0
1170                || sink.delivery.queue_capacity > MAX_EVENT_SINK_QUEUE_CAPACITY
1171                || sink.delivery.max_event_bytes == 0
1172                || sink.delivery.max_event_bytes > MAX_EVENT_SINK_EVENT_BYTES
1173            {
1174                return Err(PluginError::InvalidManifest(format!(
1175                    "event sink '{}' delivery limits exceed absolute host bounds",
1176                    sink.id
1177                )));
1178            }
1179            let sink_buffer_bytes = u64::from(sink.delivery.queue_capacity)
1180                .checked_mul(u64::from(sink.delivery.max_event_bytes))
1181                .ok_or_else(|| {
1182                    PluginError::InvalidManifest(format!(
1183                        "event sink '{}' delivery buffer size overflows",
1184                        sink.id
1185                    ))
1186                })?;
1187            declared_buffer_bytes = declared_buffer_bytes
1188                .checked_add(sink_buffer_bytes)
1189                .ok_or_else(|| {
1190                    PluginError::InvalidManifest(
1191                        "event sink aggregate delivery buffer size overflows".to_string(),
1192                    )
1193                })?;
1194            if declared_buffer_bytes > MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES {
1195                return Err(PluginError::InvalidManifest(format!(
1196                    "provides.event_sinks requests more than {MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES} bytes of aggregate delivery buffering"
1197                )));
1198            }
1199            if let Some(platforms) = &sink.platforms {
1200                if platforms.is_empty() {
1201                    return Err(PluginError::InvalidManifest(format!(
1202                        "event sink '{}' platforms, if present, must not be empty",
1203                        sink.id
1204                    )));
1205                }
1206                let mut seen_platforms = Vec::new();
1207                for platform in platforms {
1208                    if seen_platforms.contains(platform) {
1209                        return Err(PluginError::InvalidManifest(format!(
1210                            "event sink '{}' repeats platform '{}'",
1211                            sink.id, platform
1212                        )));
1213                    }
1214                    seen_platforms.push(*platform);
1215                    if !plugin_platforms.contains(platform) {
1216                        return Err(PluginError::InvalidManifest(format!(
1217                            "event sink '{}' platform gate must be a subset of the plugin platform gate",
1218                            sink.id
1219                        )));
1220                    }
1221                }
1222            }
1223
1224            // Future versions preserve bounded opaque subscription/permission
1225            // strings and reconcile inactive. Only implemented v1 values are
1226            // interpreted here.
1227            if sink.protocol.version == TOOL_EVENT_V1_SCHEMA_VERSION {
1228                if sink
1229                    .subscriptions
1230                    .iter()
1231                    .any(|subscription| subscription.id.as_str() != FILE_CHANGED_SUBSCRIPTION_ID_V1)
1232                {
1233                    return Err(PluginError::InvalidManifest(format!(
1234                        "event sink '{}' requests an unsupported ToolEventV1 subscription",
1235                        sink.id
1236                    )));
1237                }
1238                const V1_PERMISSIONS: &[&str] = &[
1239                    OBSERVE_METADATA_PERMISSION,
1240                    OBSERVE_TOOL_NAME_PERMISSION,
1241                    OBSERVE_PATHS_PERMISSION,
1242                    OBSERVE_DIFF_PERMISSION,
1243                    OBSERVE_CONTENT_PERMISSION,
1244                ];
1245                if sink
1246                    .requested_permissions
1247                    .iter()
1248                    .any(|permission| !V1_PERMISSIONS.contains(&permission.as_str()))
1249                {
1250                    return Err(PluginError::InvalidManifest(format!(
1251                        "event sink '{}' requests an unsupported ToolEventV1 observation permission",
1252                        sink.id
1253                    )));
1254                }
1255                if !seen_permissions.contains(OBSERVE_METADATA_PERMISSION) {
1256                    return Err(PluginError::InvalidManifest(format!(
1257                        "event sink '{}' ToolEventV1 permissions must include '{}'",
1258                        sink.id, OBSERVE_METADATA_PERMISSION
1259                    )));
1260                }
1261                let requests_payload = seen_permissions.contains(OBSERVE_DIFF_PERMISSION)
1262                    || seen_permissions.contains(OBSERVE_CONTENT_PERMISSION);
1263                if requests_payload && !seen_permissions.contains(OBSERVE_PATHS_PERMISSION) {
1264                    return Err(PluginError::InvalidManifest(format!(
1265                        "event sink '{}' requests diff/content without the required paths permission",
1266                        sink.id
1267                    )));
1268                }
1269                if sink.delivery.max_event_bytes > MAX_TOOL_EVENT_JSON_BYTES as u32 {
1270                    return Err(PluginError::InvalidManifest(format!(
1271                        "event sink '{}' ToolEventV1 delivery limits exceed host bounds",
1272                        sink.id
1273                    )));
1274                }
1275            }
1276        }
1277
1278        for skill_dir in &self.provides.skills {
1279            if !is_safe_relative_name(skill_dir) {
1280                return Err(PluginError::InvalidManifest(format!(
1281                    "invalid skill directory name '{skill_dir}' in provides.skills"
1282                )));
1283            }
1284        }
1285
1286        let mut seen_preset_ids = std::collections::HashSet::new();
1287        for preset in &self.provides.prompts {
1288            if !is_valid_preset_id(&preset.id) {
1289                return Err(PluginError::InvalidManifest(format!(
1290                    "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
1291                    preset.id, MAX_PRESET_ID_LEN
1292                )));
1293            }
1294            if !seen_preset_ids.insert(preset.id.clone()) {
1295                return Err(PluginError::InvalidManifest(format!(
1296                    "duplicate prompt preset id '{}' in provides.prompts",
1297                    preset.id
1298                )));
1299            }
1300            if preset.name.trim().is_empty() {
1301                return Err(PluginError::InvalidManifest(format!(
1302                    "prompt preset '{}' has an empty name",
1303                    preset.id
1304                )));
1305            }
1306            if preset.content.trim().is_empty() {
1307                return Err(PluginError::InvalidManifest(format!(
1308                    "prompt preset '{}' has empty content",
1309                    preset.id
1310                )));
1311            }
1312        }
1313
1314        for workflow_file in &self.provides.workflows {
1315            if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
1316                return Err(PluginError::InvalidManifest(format!(
1317                    "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
1318                )));
1319            }
1320        }
1321
1322        for (platform_key, artifact) in &self.artifacts {
1323            let Some(artifact_platform) = Platform::parse(platform_key) else {
1324                return Err(PluginError::InvalidManifest(format!(
1325                    "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
1326                )));
1327            };
1328            // An artifact for a platform this plugin does not claim to support
1329            // is dead weight at best and a sign of a mistake at worst — reject
1330            // it so the manifest can't drift out of sync with its own gate.
1331            if let Some(gate) = &self.platforms {
1332                if !gate.contains(&artifact_platform) {
1333                    return Err(PluginError::InvalidManifest(format!(
1334                        "artifacts contains platform '{platform_key}' which is not in the \
1335                         `platforms` gate {:?}",
1336                        gate.iter()
1337                            .map(|platform| platform.as_str())
1338                            .collect::<Vec<_>>()
1339                    )));
1340                }
1341            }
1342            if artifact.url.trim().is_empty() {
1343                return Err(PluginError::InvalidManifest(format!(
1344                    "artifact for platform '{platform_key}' has an empty url"
1345                )));
1346            }
1347            let sha_is_hex64 = artifact.sha256.len() == 64
1348                && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
1349            if !sha_is_hex64 {
1350                return Err(PluginError::InvalidManifest(format!(
1351                    "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
1352                )));
1353            }
1354        }
1355
1356        // Binary-backed URL install: if a per-platform binary is needed
1357        // (`${platform_bin}` is used) AND this manifest ships downloadable
1358        // `artifacts` (the URL-install path), then every platform the plugin
1359        // claims to support MUST have an artifact — otherwise the install
1360        // would fail opaquely on that OS at runtime (no binary to place under
1361        // `bin/`) instead of here, at manifest validation. Local-dir/archive
1362        // installs ship `bin/` directly and declare no `artifacts`, so this is
1363        // (correctly) skipped for them.
1364        if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
1365            for platform in self.effective_platforms() {
1366                if !self.artifacts.contains_key(platform.as_str()) {
1367                    return Err(PluginError::InvalidManifest(format!(
1368                        "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
1369                         artifact for supported platform '{}' (every supported platform needs a \
1370                         downloadable binary bundle)",
1371                        platform.as_str()
1372                    )));
1373                }
1374            }
1375        }
1376
1377        Ok(())
1378    }
1379
1380    /// Whether this manifest is installable on the given platform (`true` if
1381    /// `platforms` is unset — no restriction).
1382    pub fn supports_platform(&self, platform: Platform) -> bool {
1383        match &self.platforms {
1384            None => true,
1385            Some(platforms) => platforms.contains(&platform),
1386        }
1387    }
1388
1389    /// The effective set of platforms this plugin claims to support: the
1390    /// `platforms` gate if present, otherwise all three (an unset gate means
1391    /// "all platforms").
1392    pub fn effective_platforms(&self) -> Vec<Platform> {
1393        self.platforms
1394            .clone()
1395            .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
1396    }
1397
1398    /// Whether any declared MCP stdio server references the `${platform_bin}`
1399    /// substitution token (in command, args, cwd, or an env value) — i.e.
1400    /// whether this plugin needs a per-platform binary to run. Drives the
1401    /// artifacts/platform cross-check in [`Self::validate`].
1402    pub fn uses_platform_bin_token(&self) -> bool {
1403        const TOKEN: &str = PLATFORM_BIN_TOKEN;
1404        let mcp_uses = self.provides.mcp_servers.iter().any(|entry| {
1405            let McpTransportManifest::Stdio {
1406                command,
1407                args,
1408                cwd,
1409                env,
1410            } = &entry.transport
1411            else {
1412                return false;
1413            };
1414            command.contains(TOKEN)
1415                || args.iter().any(|value| value.contains(TOKEN))
1416                || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
1417                || env.values().any(|value| value.contains(TOKEN))
1418        });
1419        // Services validate to command == PLATFORM_BIN_TOKEN exactly, but
1420        // this helper must stay correct even against a not-yet-validated
1421        // manifest (it feeds the artifacts/platform cross-check inside
1422        // `validate()` itself), so check `.contains` the same way MCP does
1423        // rather than assuming validity.
1424        let service_uses = self.provides.services.iter().any(|entry| {
1425            entry.command.contains(TOKEN)
1426                || entry.args.iter().any(|value| value.contains(TOKEN))
1427                || entry
1428                    .cwd
1429                    .as_deref()
1430                    .is_some_and(|value| value.contains(TOKEN))
1431                || entry.env.values().any(|value| value.contains(TOKEN))
1432        });
1433        mcp_uses || service_uses
1434    }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439    #[test]
1440    fn retired_sse_manifest_is_rejected() {
1441        assert!(serde_json::from_value::<super::McpTransportManifest>(
1442            serde_json::json!({"type": "sse", "url": "https://example.test/sse"})
1443        )
1444        .is_err());
1445    }
1446
1447    use super::*;
1448
1449    fn minimal_manifest_json() -> &'static str {
1450        r#"{
1451            "id": "hello-plugin",
1452            "name": "Hello Plugin",
1453            "version": "0.1.0",
1454            "provides": {
1455                "skills": ["hello-world"],
1456                "prompts": [
1457                    {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
1458                ]
1459            }
1460        }"#
1461    }
1462
1463    #[test]
1464    fn parses_minimal_manifest() {
1465        let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
1466        assert_eq!(manifest.id, "hello-plugin");
1467        assert_eq!(manifest.version, "0.1.0");
1468        assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
1469        assert_eq!(manifest.provides.prompts.len(), 1);
1470        assert!(manifest.provides.mcp_servers.is_empty());
1471        assert!(manifest.artifacts.is_empty());
1472        manifest.validate().expect("minimal manifest is valid");
1473    }
1474
1475    #[test]
1476    fn parses_full_manifest_with_mcp_and_artifacts() {
1477        let json = r#"{
1478            "id": "nova_plugin",
1479            "name": "Nova",
1480            "version": "1.2.3-beta+build.7",
1481            "description": "Desktop control MCP server",
1482            "bamboo_min_version": "2026.7.0",
1483            "platforms": ["macos", "windows", "linux"],
1484            "provides": {
1485                "mcp_servers": [
1486                    {
1487                        "id": "nova",
1488                        "enabled": true,
1489                        "transport": {
1490                            "type": "stdio",
1491                            "command": "${platform_bin}",
1492                            "args": ["--serve"],
1493                            "cwd": "${plugin_dir}",
1494                            "env": {"NOVA_HOME": "${plugin_dir}/data"}
1495                        }
1496                    }
1497                ],
1498                "workflows": ["daily-report.md"]
1499            },
1500            "artifacts": {
1501                "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1502                "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
1503                "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1504            }
1505        }"#;
1506
1507        let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
1508        manifest.validate().expect("full manifest is valid");
1509        assert!(manifest.supports_platform(Platform::Macos));
1510        assert!(manifest.supports_platform(Platform::Windows));
1511        assert!(manifest.supports_platform(Platform::Linux));
1512
1513        let entry = &manifest.provides.mcp_servers[0];
1514        let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
1515        let resolved = entry
1516            .resolve(plugin_dir, &manifest.id, Platform::Macos)
1517            .expect("resolve mcp entry");
1518        match resolved.transport {
1519            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1520                assert_eq!(
1521                    stdio.command,
1522                    "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
1523                );
1524                assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
1525                assert_eq!(
1526                    stdio.env.get("NOVA_HOME").map(String::as_str),
1527                    Some("/home/user/.bamboo/plugins/nova_plugin/data")
1528                );
1529            }
1530            _ => panic!("expected stdio transport"),
1531        }
1532    }
1533
1534    #[test]
1535    fn platform_bin_path_appends_exe_on_windows_only() {
1536        let dir = Path::new("/plugins/demo");
1537        assert_eq!(
1538            platform_bin_path(dir, "demo", Platform::Macos),
1539            PathBuf::from("/plugins/demo/bin/macos/demo")
1540        );
1541        assert_eq!(
1542            platform_bin_path(dir, "demo", Platform::Windows),
1543            PathBuf::from("/plugins/demo/bin/windows/demo.exe")
1544        );
1545        assert_eq!(
1546            platform_bin_path(dir, "demo", Platform::Linux),
1547            PathBuf::from("/plugins/demo/bin/linux/demo")
1548        );
1549    }
1550
1551    #[test]
1552    fn rejects_invalid_id() {
1553        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1554        manifest.id = "Bad Id!".to_string();
1555        let error = manifest.validate().expect_err("bad id should fail");
1556        assert!(error.to_string().contains("invalid plugin id"));
1557    }
1558
1559    #[test]
1560    fn rejects_bad_semver() {
1561        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1562        manifest.version = "latest".to_string();
1563        let error = manifest.validate().expect_err("bad version should fail");
1564        assert!(error.to_string().contains("invalid plugin version"));
1565    }
1566
1567    #[test]
1568    fn rejects_empty_platforms_list() {
1569        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1570        manifest.platforms = Some(vec![]);
1571        let error = manifest
1572            .validate()
1573            .expect_err("empty platforms should fail");
1574        assert!(error.to_string().contains("platforms"));
1575    }
1576
1577    #[test]
1578    fn rejects_duplicate_mcp_server_ids() {
1579        let json = r#"{
1580            "id": "dup",
1581            "name": "Dup",
1582            "version": "1.0.0",
1583            "provides": {
1584                "mcp_servers": [
1585                    {"id": "a", "transport": {"type": "stdio", "command": "x"}},
1586                    {"id": "a", "transport": {"type": "stdio", "command": "y"}}
1587                ]
1588            }
1589        }"#;
1590        let manifest = PluginManifest::parse_str(json).unwrap();
1591        let error = manifest
1592            .validate()
1593            .expect_err("duplicate mcp id should fail");
1594        assert!(error.to_string().contains("duplicate mcp server id"));
1595    }
1596
1597    #[test]
1598    fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
1599        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1600        manifest.provides.skills = vec!["../escape".to_string()];
1601        assert!(manifest.validate().is_err());
1602
1603        let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1604        manifest2.provides.skills = vec![];
1605        manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
1606        assert!(manifest2.validate().is_err());
1607    }
1608
1609    #[test]
1610    fn rejects_invalid_artifact_sha256() {
1611        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1612        manifest.artifacts.insert(
1613            "macos".to_string(),
1614            PluginArtifact {
1615                url: "https://example.com/x.tar.gz".to_string(),
1616                sha256: "not-hex".to_string(),
1617            },
1618        );
1619        let error = manifest.validate().expect_err("bad sha256 should fail");
1620        assert!(error.to_string().contains("sha256"));
1621    }
1622
1623    #[test]
1624    fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
1625        // Uses ${platform_bin}, supports all three platforms (no gate), ships
1626        // URL artifacts — but only for macos + windows. Missing linux artifact
1627        // must be caught at validation, not at install time on a linux host.
1628        let json = r#"{
1629            "id": "binbacked",
1630            "name": "Bin Backed",
1631            "version": "1.0.0",
1632            "provides": {
1633                "mcp_servers": [
1634                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1635                ]
1636            },
1637            "artifacts": {
1638                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1639                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1640            }
1641        }"#;
1642        let manifest = PluginManifest::parse_str(json).unwrap();
1643        let error = manifest
1644            .validate()
1645            .expect_err("missing linux artifact should fail");
1646        assert!(error.to_string().contains("linux"));
1647    }
1648
1649    #[test]
1650    fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
1651        // Same plugin, but a `platforms` gate narrows support to exactly the
1652        // platforms that DO have artifacts → valid.
1653        let json = r#"{
1654            "id": "binbacked",
1655            "name": "Bin Backed",
1656            "version": "1.0.0",
1657            "platforms": ["macos", "windows"],
1658            "provides": {
1659                "mcp_servers": [
1660                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1661                ]
1662            },
1663            "artifacts": {
1664                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1665                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1666            }
1667        }"#;
1668        let manifest = PluginManifest::parse_str(json).unwrap();
1669        manifest
1670            .validate()
1671            .expect("gate-narrowed binary plugin is valid");
1672        assert!(manifest.uses_platform_bin_token());
1673    }
1674
1675    #[test]
1676    fn rejects_artifact_for_platform_outside_the_gate() {
1677        let json = r#"{
1678            "id": "gated",
1679            "name": "Gated",
1680            "version": "1.0.0",
1681            "platforms": ["macos"],
1682            "artifacts": {
1683                "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1684            }
1685        }"#;
1686        let manifest = PluginManifest::parse_str(json).unwrap();
1687        let error = manifest
1688            .validate()
1689            .expect_err("artifact outside gate should fail");
1690        assert!(error.to_string().contains("not in the `platforms` gate"));
1691    }
1692
1693    #[test]
1694    fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
1695        // Local-dir/archive install: uses ${platform_bin} but ships bin/
1696        // directly (no `artifacts`). The cross-check must NOT fire.
1697        let json = r#"{
1698            "id": "localbin",
1699            "name": "Local Bin",
1700            "version": "1.0.0",
1701            "provides": {
1702                "mcp_servers": [
1703                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1704                ]
1705            }
1706        }"#;
1707        let manifest = PluginManifest::parse_str(json).unwrap();
1708        manifest
1709            .validate()
1710            .expect("local binary plugin without artifacts is valid");
1711    }
1712
1713    #[test]
1714    fn rejects_reserved_preset_id() {
1715        let json = r#"{
1716            "id": "reserver",
1717            "name": "Reserver",
1718            "version": "1.0.0",
1719            "provides": {
1720                "prompts": [
1721                    {"id": "general_assistant", "name": "Nope", "content": "x"}
1722                ]
1723            }
1724        }"#;
1725        let manifest = PluginManifest::parse_str(json).unwrap();
1726        let error = manifest
1727            .validate()
1728            .expect_err("reserved preset id should fail");
1729        assert!(error.to_string().contains("prompt preset id"));
1730        assert!(!is_valid_preset_id("general_assistant"));
1731    }
1732
1733    #[test]
1734    fn rejects_unknown_artifact_platform_key() {
1735        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1736        manifest.artifacts.insert(
1737            "solaris".to_string(),
1738            PluginArtifact {
1739                url: "https://example.com/x.tar.gz".to_string(),
1740                sha256: "a".repeat(64),
1741            },
1742        );
1743        let error = manifest
1744            .validate()
1745            .expect_err("unknown platform key should fail");
1746        assert!(error.to_string().contains("unknown platform key"));
1747    }
1748
1749    #[test]
1750    fn semver_shape_check() {
1751        assert!(is_plausible_semver("1.2.3"));
1752        assert!(is_plausible_semver("1.2.3-beta.1"));
1753        assert!(is_plausible_semver("1.2.3+build.7"));
1754        assert!(is_plausible_semver("1.2.3-beta+build"));
1755        assert!(!is_plausible_semver("1.2"));
1756        assert!(!is_plausible_semver("latest"));
1757        assert!(!is_plausible_semver(""));
1758        assert!(!is_plausible_semver("v1.2.3"));
1759    }
1760
1761    fn service_manifest_json(id: &str, command: &str) -> String {
1762        serde_json::json!({
1763            "id": "svc-plugin",
1764            "name": "Svc Plugin",
1765            "version": "1.0.0",
1766            "provides": {
1767                "services": [
1768                    {"id": id, "command": command}
1769                ]
1770            }
1771        })
1772        .to_string()
1773    }
1774
1775    fn event_sink_manifest_value(
1776        protocol_version: u16,
1777        service_enabled: bool,
1778    ) -> serde_json::Value {
1779        serde_json::json!({
1780            "id": "event-plugin",
1781            "name": "Event Plugin",
1782            "version": "1.0.0",
1783            "provides": {
1784                "services": [{
1785                    "id": "audit-service",
1786                    "enabled": service_enabled,
1787                    "command": PLATFORM_BIN_TOKEN,
1788                    "input_protocol": "ndjson_v1"
1789                }],
1790                "event_sinks": [{
1791                    "id": "audit-events",
1792                    "service_id": "audit-service",
1793                    "protocol": {
1794                        "name": TOOL_EVENT_PROTOCOL_NAME,
1795                        "version": protocol_version
1796                    },
1797                    "subscriptions": [{
1798                        "id": FILE_CHANGED_SUBSCRIPTION_ID_V1,
1799                        "tool_names": ["Write", "Edit"]
1800                    }],
1801                    "delivery": {
1802                        "queue_capacity": DEFAULT_EVENT_SINK_QUEUE_CAPACITY,
1803                        "max_event_bytes": MAX_TOOL_EVENT_JSON_BYTES
1804                    },
1805                    "requested_permissions": [OBSERVE_METADATA_PERMISSION]
1806                }]
1807            }
1808        })
1809    }
1810
1811    fn parse_event_sink_manifest(value: &serde_json::Value) -> PluginManifest {
1812        PluginManifest::parse_str(&value.to_string()).expect("parse event sink manifest")
1813    }
1814
1815    #[test]
1816    fn legacy_manifest_round_trip_omits_event_sinks() {
1817        let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse legacy");
1818        assert!(manifest.provides.event_sinks.is_empty());
1819
1820        let serialized = serde_json::to_value(&manifest).expect("serialize legacy manifest");
1821        assert!(serialized["provides"].get("event_sinks").is_none());
1822        assert!(!serde_json::to_string(&manifest)
1823            .expect("serialize legacy manifest bytes")
1824            .contains("event_sinks"));
1825    }
1826
1827    #[test]
1828    fn validates_v1_sink_with_safe_defaults_and_tool_filters() {
1829        let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1830        let sink = value["provides"]["event_sinks"][0]
1831            .as_object_mut()
1832            .expect("sink object");
1833        sink.remove("delivery");
1834        sink.remove("requested_permissions");
1835
1836        let manifest = parse_event_sink_manifest(&value);
1837        manifest.validate().expect("valid v1 event sink");
1838        let sink = &manifest.provides.event_sinks[0];
1839        assert_eq!(
1840            sink.delivery,
1841            EventSinkDeliveryLimits {
1842                queue_capacity: DEFAULT_EVENT_SINK_QUEUE_CAPACITY,
1843                max_event_bytes: MAX_TOOL_EVENT_JSON_BYTES as u32,
1844                extensions: BTreeMap::new(),
1845            }
1846        );
1847        assert_eq!(sink.requested_permissions.len(), 1);
1848        assert_eq!(
1849            sink.requested_permissions[0].as_str(),
1850            OBSERVE_METADATA_PERMISSION
1851        );
1852        assert_eq!(
1853            sink.subscriptions[0].tool_names,
1854            vec!["Write".to_string(), "Edit".to_string()]
1855        );
1856        assert_eq!(
1857            sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1858            EventSinkCapabilityState::Eligible
1859        );
1860    }
1861
1862    #[test]
1863    fn future_tool_event_version_preserves_opaque_values_and_is_inactive() {
1864        let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
1865        value["provides"]["services"][0]
1866            .as_object_mut()
1867            .expect("service object")
1868            .remove("input_protocol");
1869        value["provides"]["event_sinks"][0]["future_sink_option"] =
1870            serde_json::json!({ "mode": "v2" });
1871        value["provides"]["event_sinks"][0]["protocol"]["negotiation"] =
1872            serde_json::json!("optional");
1873        value["provides"]["event_sinks"][0]["subscriptions"] = serde_json::json!([{
1874            "id": "tool.symbol_changed.v2",
1875            "tool_names": ["FutureTool"],
1876            "projection": "symbol"
1877        }]);
1878        value["provides"]["event_sinks"][0]["requested_permissions"] =
1879            serde_json::json!(["symbol_metadata_v2"]);
1880        value["provides"]["event_sinks"][0]["delivery"] = serde_json::json!({
1881            "queue_capacity": 64,
1882            "max_event_bytes": MAX_EVENT_SINK_EVENT_BYTES,
1883            "batch_size": 8
1884        });
1885
1886        let manifest = parse_event_sink_manifest(&value);
1887        manifest
1888            .validate()
1889            .expect("future version must degrade instead of failing validation");
1890        let sink = &manifest.provides.event_sinks[0];
1891        assert_eq!(
1892            manifest.provides.services[0].input_protocol,
1893            ServiceInputProtocol::None,
1894            "future protocols must remain installable and inactive when the current host input protocol is absent"
1895        );
1896        assert_eq!(sink.subscriptions[0].id.as_str(), "tool.symbol_changed.v2");
1897        assert_eq!(sink.requested_permissions.len(), 1);
1898        assert_eq!(sink.requested_permissions[0].as_str(), "symbol_metadata_v2");
1899        let serialized = serde_json::to_value(&manifest).expect("serialize future extensions");
1900        assert_eq!(
1901            serialized["provides"]["event_sinks"][0]["future_sink_option"]["mode"],
1902            "v2"
1903        );
1904        assert_eq!(
1905            serialized["provides"]["event_sinks"][0]["protocol"]["negotiation"],
1906            "optional"
1907        );
1908        assert_eq!(
1909            serialized["provides"]["event_sinks"][0]["delivery"]["batch_size"],
1910            8
1911        );
1912        assert_eq!(
1913            serialized["provides"]["event_sinks"][0]["subscriptions"][0]["projection"],
1914            "symbol"
1915        );
1916        assert_eq!(
1917            sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1918            EventSinkCapabilityState::Inactive {
1919                detail: EventSinkInactiveReason::UnsupportedProtocolVersion {
1920                    requested: TOOL_EVENT_V1_SCHEMA_VERSION + 1,
1921                    supported: TOOL_EVENT_V1_SCHEMA_VERSION,
1922                },
1923            }
1924        );
1925    }
1926
1927    #[test]
1928    fn tool_event_v1_rejects_unknown_fields_in_every_nested_scope() {
1929        for path in ["sink", "protocol", "delivery", "subscription"] {
1930            let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1931            match path {
1932                "sink" => {
1933                    value["provides"]["event_sinks"][0]["platform"] = serde_json::json!(["macos"])
1934                }
1935                "protocol" => {
1936                    value["provides"]["event_sinks"][0]["protocol"]["negotiation"] =
1937                        serde_json::json!("required")
1938                }
1939                "delivery" => {
1940                    value["provides"]["event_sinks"][0]["delivery"]["queue_capcity"] =
1941                        serde_json::json!(4)
1942                }
1943                "subscription" => {
1944                    value["provides"]["event_sinks"][0]["subscriptions"][0]["projection"] =
1945                        serde_json::json!("full")
1946                }
1947                _ => unreachable!(),
1948            }
1949            let error = parse_event_sink_manifest(&value)
1950                .validate()
1951                .expect_err("ToolEventV1 typo/extension must fail closed");
1952            assert!(
1953                error.to_string().contains("unknown field"),
1954                "scope={path}, error={error}"
1955            );
1956        }
1957    }
1958
1959    #[test]
1960    fn disabled_service_and_platform_mismatch_are_explicitly_inactive() {
1961        let value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, false);
1962        let manifest = parse_event_sink_manifest(&value);
1963        manifest
1964            .validate()
1965            .expect("disabled service is declarative");
1966        assert_eq!(
1967            manifest.provides.event_sinks[0]
1968                .capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1969            EventSinkCapabilityState::Inactive {
1970                detail: EventSinkInactiveReason::ServiceDisabled,
1971            }
1972        );
1973
1974        let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1975        value["provides"]["event_sinks"][0]["platforms"] = serde_json::json!(["macos"]);
1976        let manifest = parse_event_sink_manifest(&value);
1977        manifest.validate().expect("narrow platform gate is valid");
1978        let sink = &manifest.provides.event_sinks[0];
1979        assert_eq!(
1980            sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1981            EventSinkCapabilityState::Inactive {
1982                detail: EventSinkInactiveReason::PlatformIneligible,
1983            }
1984        );
1985        assert_eq!(
1986            sink.capability_state(&manifest.provides.services[0], None),
1987            EventSinkCapabilityState::Inactive {
1988                detail: EventSinkInactiveReason::PlatformIneligible,
1989            }
1990        );
1991    }
1992
1993    #[test]
1994    fn rejects_missing_or_duplicate_sink_ownership() {
1995        let mut missing_service = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1996        missing_service["provides"]["event_sinks"][0]["service_id"] =
1997            serde_json::json!("foreign-service");
1998        let error = parse_event_sink_manifest(&missing_service)
1999            .validate()
2000            .expect_err("cross-plugin/missing service reference must fail");
2001        assert!(error.to_string().contains("same plugin"));
2002
2003        let mut duplicate = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2004        let clone = duplicate["provides"]["event_sinks"][0].clone();
2005        duplicate["provides"]["event_sinks"]
2006            .as_array_mut()
2007            .expect("sinks array")
2008            .push(clone);
2009        let error = parse_event_sink_manifest(&duplicate)
2010            .validate()
2011            .expect_err("duplicate sink id must fail");
2012        assert!(error.to_string().contains("duplicate event sink id"));
2013    }
2014
2015    #[test]
2016    fn tool_event_v1_requires_ndjson_service_input() {
2017        let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2018        value["provides"]["services"][0]
2019            .as_object_mut()
2020            .expect("service object")
2021            .remove("input_protocol");
2022
2023        let error = parse_event_sink_manifest(&value)
2024            .validate()
2025            .expect_err("ToolEventV1 cannot route into a null-stdin service");
2026        assert!(error.to_string().contains("input_protocol 'ndjson_v1'"));
2027    }
2028
2029    #[test]
2030    fn v1_rejects_unknown_or_incompatible_observation_requests() {
2031        let mut unknown_subscription =
2032            event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2033        unknown_subscription["provides"]["event_sinks"][0]["subscriptions"][0]["id"] =
2034            serde_json::json!("tool.unknown.v1");
2035        assert!(parse_event_sink_manifest(&unknown_subscription)
2036            .validate()
2037            .expect_err("unknown v1 subscription")
2038            .to_string()
2039            .contains("unsupported ToolEventV1 subscription"));
2040
2041        let mut unknown_permission = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2042        unknown_permission["provides"]["event_sinks"][0]["requested_permissions"] =
2043            serde_json::json!([OBSERVE_METADATA_PERMISSION, "everything"]);
2044        assert!(parse_event_sink_manifest(&unknown_permission)
2045            .validate()
2046            .expect_err("unknown v1 permission")
2047            .to_string()
2048            .contains("unsupported ToolEventV1 observation permission"));
2049
2050        let mut payload_without_path =
2051            event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2052        payload_without_path["provides"]["event_sinks"][0]["requested_permissions"] =
2053            serde_json::json!([OBSERVE_METADATA_PERMISSION, OBSERVE_CONTENT_PERMISSION]);
2054        assert!(parse_event_sink_manifest(&payload_without_path)
2055            .validate()
2056            .expect_err("content without path permission")
2057            .to_string()
2058            .contains("required paths permission"));
2059    }
2060
2061    #[test]
2062    fn rejects_malformed_protocol_and_duplicate_open_values() {
2063        let mut version_zero = event_sink_manifest_value(0, true);
2064        assert!(parse_event_sink_manifest(&version_zero)
2065            .validate()
2066            .expect_err("protocol version zero")
2067            .to_string()
2068            .contains("non-zero"));
2069
2070        version_zero["provides"]["event_sinks"][0]["protocol"] =
2071            serde_json::json!({"name": "tool_evnet", "version": 2});
2072        assert!(parse_event_sink_manifest(&version_zero)
2073            .validate()
2074            .expect_err("unknown protocol family")
2075            .to_string()
2076            .contains("unknown protocol family"));
2077
2078        let mut duplicate_subscription =
2079            event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2080        let subscription =
2081            duplicate_subscription["provides"]["event_sinks"][0]["subscriptions"][0].clone();
2082        duplicate_subscription["provides"]["event_sinks"][0]["subscriptions"]
2083            .as_array_mut()
2084            .expect("subscriptions")
2085            .push(subscription);
2086        assert!(parse_event_sink_manifest(&duplicate_subscription)
2087            .validate()
2088            .expect_err("duplicate subscription")
2089            .to_string()
2090            .contains("repeats subscription"));
2091
2092        let mut duplicate_permission =
2093            event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2094        duplicate_permission["provides"]["event_sinks"][0]["requested_permissions"] =
2095            serde_json::json!([OBSERVE_METADATA_PERMISSION, OBSERVE_METADATA_PERMISSION]);
2096        assert!(parse_event_sink_manifest(&duplicate_permission)
2097            .validate()
2098            .expect_err("duplicate permission")
2099            .to_string()
2100            .contains("repeats observation permission"));
2101    }
2102
2103    #[test]
2104    fn rejects_duplicate_tool_filters_and_excessive_declared_buffering() {
2105        let mut duplicate_tool = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2106        duplicate_tool["provides"]["event_sinks"][0]["subscriptions"][0]["tool_names"] =
2107            serde_json::json!(["Write", "Write"]);
2108        assert!(parse_event_sink_manifest(&duplicate_tool)
2109            .validate()
2110            .expect_err("duplicate tool filter")
2111            .to_string()
2112            .contains("repeats tool name"));
2113
2114        let mut excessive = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
2115        excessive["provides"]["event_sinks"][0]["delivery"] = serde_json::json!({
2116            "queue_capacity": 65,
2117            "max_event_bytes": MAX_EVENT_SINK_EVENT_BYTES
2118        });
2119        assert!(parse_event_sink_manifest(&excessive)
2120            .validate()
2121            .expect_err("aggregate buffer budget must be bounded")
2122            .to_string()
2123            .contains("aggregate delivery buffering"));
2124
2125        let mut v1_oversize = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2126        v1_oversize["provides"]["event_sinks"][0]["delivery"]["max_event_bytes"] =
2127            serde_json::json!(MAX_TOOL_EVENT_JSON_BYTES as u32 + 1);
2128        assert!(parse_event_sink_manifest(&v1_oversize)
2129            .validate()
2130            .expect_err("ToolEventV1 wire maximum")
2131            .to_string()
2132            .contains("ToolEventV1 delivery limits"));
2133
2134        let mut queue_oversize = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
2135        queue_oversize["provides"]["event_sinks"][0]["delivery"]["queue_capacity"] =
2136            serde_json::json!(MAX_EVENT_SINK_QUEUE_CAPACITY + 1);
2137        assert!(parse_event_sink_manifest(&queue_oversize)
2138            .validate()
2139            .expect_err("absolute queue bound")
2140            .to_string()
2141            .contains("absolute host bounds"));
2142    }
2143
2144    #[test]
2145    fn parses_and_validates_minimal_service_entry() {
2146        let json = service_manifest_json("svc", PLATFORM_BIN_TOKEN);
2147        let manifest = PluginManifest::parse_str(&json).unwrap();
2148        manifest.validate().expect("minimal service entry is valid");
2149        let entry = &manifest.provides.services[0];
2150        assert!(entry.enabled);
2151        assert_eq!(entry.health_check.kind, HealthCheckKind::ProcessAlive);
2152        assert_eq!(entry.graceful_shutdown.signal, ShutdownSignal::Term);
2153        assert_eq!(entry.input_protocol, ServiceInputProtocol::None);
2154        assert!(
2155            serde_json::to_value(entry)
2156                .expect("serialize legacy service")
2157                .get("input_protocol")
2158                .is_none(),
2159            "the default must remain absent from reserialized legacy manifests"
2160        );
2161        assert!(manifest.uses_platform_bin_token());
2162    }
2163
2164    #[test]
2165    fn parses_and_resolves_explicit_ndjson_v1_service_input() {
2166        let mut value: serde_json::Value =
2167            serde_json::from_str(&service_manifest_json("svc", PLATFORM_BIN_TOKEN)).unwrap();
2168        value["provides"]["services"][0]["input_protocol"] = serde_json::json!("ndjson_v1");
2169        let manifest = PluginManifest::parse_str(&value.to_string()).expect("parse ndjson input");
2170        manifest.validate().expect("ndjson service is valid");
2171        let entry = &manifest.provides.services[0];
2172        assert_eq!(entry.input_protocol, ServiceInputProtocol::NdjsonV1);
2173        assert_eq!(
2174            entry
2175                .resolve(
2176                    Path::new("/plugins/svc-plugin"),
2177                    &manifest.id,
2178                    Platform::Linux
2179                )
2180                .input_protocol,
2181            ServiceInputProtocol::NdjsonV1
2182        );
2183    }
2184
2185    #[test]
2186    fn rejects_service_command_that_is_not_exactly_the_platform_bin_token() {
2187        for bad_command in ["/usr/bin/env", "nova", "${platform_bin} --serve", ""] {
2188            let json = service_manifest_json("svc", bad_command);
2189            let manifest = PluginManifest::parse_str(&json).unwrap();
2190            let error = manifest
2191                .validate()
2192                .expect_err("non-token service command must be rejected");
2193            assert!(matches!(error, PluginError::InvalidManifest(_)));
2194        }
2195    }
2196
2197    #[test]
2198    fn rejects_duplicate_service_ids() {
2199        let json = serde_json::json!({
2200            "id": "svc-plugin",
2201            "name": "Svc",
2202            "version": "1.0.0",
2203            "provides": {
2204                "services": [
2205                    {"id": "a", "command": PLATFORM_BIN_TOKEN},
2206                    {"id": "a", "command": PLATFORM_BIN_TOKEN}
2207                ]
2208            }
2209        })
2210        .to_string();
2211        let manifest = PluginManifest::parse_str(&json).unwrap();
2212        let error = manifest
2213            .validate()
2214            .expect_err("duplicate service id should fail");
2215        assert!(error.to_string().contains("duplicate service id"));
2216    }
2217
2218    #[test]
2219    fn rejects_tcp_and_http_health_check_missing_target() {
2220        for kind in ["tcp", "http"] {
2221            let json = serde_json::json!({
2222                "id": "svc-plugin",
2223                "name": "Svc",
2224                "version": "1.0.0",
2225                "provides": {
2226                    "services": [
2227                        {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": kind}}
2228                    ]
2229                }
2230            })
2231            .to_string();
2232            let manifest = PluginManifest::parse_str(&json).unwrap();
2233            let error = manifest
2234                .validate()
2235                .expect_err("tcp/http health_check without a target should fail");
2236            assert!(error.to_string().contains("target"));
2237        }
2238    }
2239
2240    #[test]
2241    fn accepts_tcp_health_check_with_target() {
2242        let json = serde_json::json!({
2243            "id": "svc-plugin",
2244            "name": "Svc",
2245            "version": "1.0.0",
2246            "provides": {
2247                "services": [
2248                    {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": "tcp", "target": "127.0.0.1:9000"}}
2249                ]
2250            }
2251        })
2252        .to_string();
2253        let manifest = PluginManifest::parse_str(&json).unwrap();
2254        manifest
2255            .validate()
2256            .expect("tcp health_check with target is valid");
2257    }
2258
2259    #[test]
2260    fn services_missing_artifact_for_a_supported_platform_is_rejected() {
2261        // Mirrors `rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform`
2262        // but via `provides.services` instead of `provides.mcp_servers` — the
2263        // artifacts/platform cross-check must cover services too (issue #479).
2264        let json = serde_json::json!({
2265            "id": "svc-plugin",
2266            "name": "Svc",
2267            "version": "1.0.0",
2268            "provides": {
2269                "services": [
2270                    {"id": "a", "command": PLATFORM_BIN_TOKEN}
2271                ]
2272            },
2273            "artifacts": {
2274                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2275                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
2276            }
2277        })
2278        .to_string();
2279        let manifest = PluginManifest::parse_str(&json).unwrap();
2280        let error = manifest
2281            .validate()
2282            .expect_err("missing linux artifact for a service-only plugin should fail");
2283        assert!(error.to_string().contains("linux"));
2284    }
2285
2286    #[test]
2287    fn resolve_service_entry_substitutes_tokens_and_pins_command_to_platform_bin() {
2288        let json = serde_json::json!({
2289            "id": "svc-plugin",
2290            "name": "Svc",
2291            "version": "1.0.0",
2292            "provides": {
2293                "services": [
2294                    {
2295                        "id": "a",
2296                        "command": PLATFORM_BIN_TOKEN,
2297                        "args": ["--config", "${plugin_dir}/data"],
2298                        "cwd": "${plugin_dir}",
2299                        "env": {"HOME_DIR": "${plugin_dir}/home"}
2300                    }
2301                ]
2302            }
2303        })
2304        .to_string();
2305        let manifest = PluginManifest::parse_str(&json).unwrap();
2306        manifest.validate().expect("valid");
2307        let entry = &manifest.provides.services[0];
2308        let plugin_dir = Path::new("/home/user/.bamboo/plugins/svc-plugin");
2309        let resolved = entry.resolve(plugin_dir, &manifest.id, Platform::Linux);
2310        assert_eq!(
2311            resolved.command,
2312            PathBuf::from("/home/user/.bamboo/plugins/svc-plugin/bin/linux/svc-plugin")
2313        );
2314        assert_eq!(
2315            resolved.args,
2316            vec![
2317                "--config".to_string(),
2318                "/home/user/.bamboo/plugins/svc-plugin/data".to_string()
2319            ]
2320        );
2321        assert_eq!(resolved.cwd, Some(plugin_dir.to_path_buf()));
2322        assert_eq!(
2323            resolved.env.get("HOME_DIR").map(String::as_str),
2324            Some("/home/user/.bamboo/plugins/svc-plugin/home")
2325        );
2326    }
2327
2328    #[test]
2329    fn plugin_id_rules() {
2330        assert!(is_valid_plugin_id("hello-plugin"));
2331        assert!(is_valid_plugin_id("nova_plugin_2"));
2332        assert!(!is_valid_plugin_id(""));
2333        assert!(!is_valid_plugin_id("Hello"));
2334        assert!(!is_valid_plugin_id("hello plugin"));
2335        assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
2336    }
2337}