Skip to main content

systemprompt_models/bridge/
manifest.rs

1//! Signed manifest wire format.
2//!
3//! `GET /v1/bridge/manifest` returns a [`SignedManifestEnvelope`]: the
4//! JCS-canonical serialization of a [`SignedManifest`] carried verbatim as
5//! `payload`, plus a detached ed25519 signature over those exact bytes. The
6//! bridge verifies the signature against the raw `payload` string *before*
7//! deserialising it, so fields added to [`SignedManifest`] in newer gateways
8//! never invalidate the signature on older bridges — unknown fields are
9//! simply ignored at parse time. Semantic breaks that an older bridge cannot
10//! safely ignore are declared by raising `min_schema_version` above
11//! [`MANIFEST_SCHEMA_VERSION`] of the consuming bridge, which then refuses
12//! with an upgrade message instead of a signature error.
13//!
14//! Signing, signature verification, and manifest construction live in
15//! the bridge crate (`bin/bridge/src/gateway/manifest.rs`) alongside
16//! the gateway client. Those layers pull in `ed25519-dalek` and
17//! `serde_jcs` which are not appropriate dependencies for this
18//! foundation crate.
19//!
20//! Copyright (c) systemprompt.io — Business Source License 1.1.
21//! See <https://systemprompt.io> for licensing details.
22
23use std::collections::BTreeMap;
24
25use serde::{Deserialize, Serialize};
26
27pub use crate::bridge::ids::ManifestSignature;
28use crate::bridge::ids::{
29    LibraryArtifactId, ManagedMcpServerName, PluginId, Sha256Digest, SkillId, SkillName, ToolName,
30    ToolPolicy,
31};
32use crate::bridge::manifest_version::ManifestVersion;
33use crate::services::hooks::{HookCategory, HookEvent};
34use crate::services::plugin::{PluginComponentRef, PluginHooksRef};
35use systemprompt_identifiers::{
36    AgentId, AgentName, HookId, MarketplaceId, McpServerId, TenantId, UserId, ValidatedUrl,
37};
38
39pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
40
41pub const MIN_BRIDGE_VERSION: &str = "0.28.0";
42
43#[must_use]
44pub fn bridge_version_is_supported(reported: &str, floor: &str) -> bool {
45    match (
46        semver::Version::parse(reported),
47        semver::Version::parse(floor),
48    ) {
49        (Ok(reported), Ok(floor)) => reported >= floor,
50        // Why: a version that cannot be parsed cannot be shown to meet the
51        // floor. Answering "supported" here let a mis-built bridge through the
52        // gate and hid the defect until the admin Devices page disagreed.
53        _ => false,
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SignedManifestEnvelope {
59    pub payload: String,
60    pub signature: ManifestSignature,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SignedManifest {
65    #[serde(default)]
66    pub min_schema_version: u32,
67    #[serde(default)]
68    pub min_bridge_version: Option<String>,
69    pub manifest_version: ManifestVersion,
70    pub issued_at: String,
71    pub not_before: String,
72    pub user_id: UserId,
73    pub tenant_id: Option<TenantId>,
74    #[serde(default)]
75    pub user: Option<UserInfo>,
76    pub plugins: Vec<PluginEntry>,
77    #[serde(default)]
78    pub skills: Vec<SkillEntry>,
79    #[serde(default)]
80    pub agents: Vec<AgentEntry>,
81    #[serde(default)]
82    pub hooks: Vec<HookEntry>,
83    pub managed_mcp_servers: Vec<ManagedMcpServer>,
84    pub revocations: Vec<String>,
85    #[serde(default)]
86    pub enabled_hosts: Vec<String>,
87    #[serde(default)]
88    pub host_model_protocols: BTreeMap<String, Vec<String>>,
89    #[serde(default)]
90    pub artifacts: Vec<ArtifactEntry>,
91    #[serde(default)]
92    pub allow_claude_ai_connectors: bool,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub diagnostics: Vec<String>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub marketplaces: Vec<ManifestMarketplace>,
97}
98
99/// An enabled marketplace and the manifest plugins it carries, after the
100/// per-user filter: `plugin_ids` never names a plugin absent from `plugins`.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct ManifestMarketplace {
103    pub id: MarketplaceId,
104    pub name: String,
105    pub plugin_ids: Vec<PluginId>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct UserInfo {
110    pub id: UserId,
111    pub name: String,
112    pub email: String,
113    #[serde(default)]
114    pub display_name: Option<String>,
115    #[serde(default)]
116    pub roles: Vec<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct PluginEntry {
121    pub id: PluginId,
122    pub version: String,
123    pub sha256: Sha256Digest,
124    pub files: Vec<PluginFile>,
125    #[serde(default)]
126    pub hooks: PluginHooksRef,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PluginFile {
131    pub path: String,
132    pub sha256: Sha256Digest,
133    pub size: u64,
134}
135
136/// A Cowork-native library document (raw HTML in the desktop app's Artifacts
137/// library) — not one of the in-chat MCP artifacts in [`crate::artifacts`].
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ArtifactEntry {
140    pub id: LibraryArtifactId,
141    pub name: String,
142    pub description: String,
143    pub version: String,
144    pub mcp_tools: Vec<String>,
145    pub content: String,
146    pub starred: bool,
147    pub sha256: Sha256Digest,
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub plugins: Vec<PluginId>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SkillEntry {
154    pub id: SkillId,
155    pub name: SkillName,
156    pub description: String,
157    pub file_path: String,
158    #[serde(default)]
159    pub tags: Vec<String>,
160    pub sha256: Sha256Digest,
161    pub instructions: String,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub hosts: Vec<String>,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub plugins: Vec<PluginId>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AgentEntry {
170    pub id: AgentId,
171    pub name: AgentName,
172    pub display_name: String,
173    pub description: String,
174    pub version: String,
175    pub endpoint: String,
176    pub enabled: bool,
177    pub is_default: bool,
178    pub is_primary: bool,
179    #[serde(default)]
180    pub provider: Option<String>,
181    #[serde(default)]
182    pub model: Option<String>,
183    #[serde(default)]
184    pub mcp_servers: PluginComponentRef,
185    #[serde(default)]
186    pub skills: PluginComponentRef,
187    #[serde(default)]
188    pub tags: Vec<String>,
189    #[serde(default)]
190    pub system_prompt: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HookEntry {
195    pub id: HookId,
196    pub name: String,
197    pub description: String,
198    pub version: String,
199    pub event: HookEvent,
200    pub matcher: String,
201    pub command: String,
202    #[serde(default)]
203    pub is_async: bool,
204    pub category: HookCategory,
205    #[serde(default)]
206    pub tags: Vec<String>,
207    pub sha256: Sha256Digest,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(from = "ManagedMcpServerWire")]
212pub struct ManagedMcpServer {
213    pub id: McpServerId,
214    pub name: ManagedMcpServerName,
215    pub url: ValidatedUrl,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub transport: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub headers: Option<BTreeMap<String, String>>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub oauth: Option<bool>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub tool_policy: Option<BTreeMap<ToolName, ToolPolicy>>,
224}
225
226#[derive(Deserialize)]
227struct ManagedMcpServerWire {
228    #[serde(default)]
229    id: Option<McpServerId>,
230    name: ManagedMcpServerName,
231    url: ValidatedUrl,
232    #[serde(default)]
233    transport: Option<String>,
234    #[serde(default)]
235    headers: Option<BTreeMap<String, String>>,
236    #[serde(default)]
237    oauth: Option<bool>,
238    #[serde(default)]
239    tool_policy: Option<BTreeMap<ToolName, ToolPolicy>>,
240}
241
242impl From<ManagedMcpServerWire> for ManagedMcpServer {
243    fn from(wire: ManagedMcpServerWire) -> Self {
244        let id = wire
245            .id
246            .unwrap_or_else(|| McpServerId::new(wire.name.as_str()));
247        Self {
248            id,
249            name: wire.name,
250            url: wire.url,
251            transport: wire.transport,
252            headers: wire.headers,
253            oauth: wire.oauth,
254            tool_policy: wire.tool_policy,
255        }
256    }
257}