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, McpServerId, TenantId, UserId, ValidatedUrl,
37};
38
39pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
40
41// Why: not tied to the release version, and never swept by a version-bump
42// script. Raising it strands every client below it until they update, so it
43// moves only when the gateway makes a change an older bridge cannot handle.
44pub const MIN_BRIDGE_VERSION: &str = "0.28.0";
45
46#[must_use]
47pub fn bridge_version_is_supported(reported: &str, floor: &str) -> bool {
48    match (
49        semver::Version::parse(reported),
50        semver::Version::parse(floor),
51    ) {
52        (Ok(reported), Ok(floor)) => reported >= floor,
53        // Why: an unparseable version is almost always a local dev build;
54        // refusing those would make the gateway untestable against a work tree.
55        _ => true,
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SignedManifestEnvelope {
61    pub payload: String,
62    pub signature: ManifestSignature,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SignedManifest {
67    #[serde(default)]
68    pub min_schema_version: u32,
69    #[serde(default)]
70    pub min_bridge_version: Option<String>,
71    pub manifest_version: ManifestVersion,
72    pub issued_at: String,
73    pub not_before: String,
74    pub user_id: UserId,
75    pub tenant_id: Option<TenantId>,
76    #[serde(default)]
77    pub user: Option<UserInfo>,
78    pub plugins: Vec<PluginEntry>,
79    #[serde(default)]
80    pub skills: Vec<SkillEntry>,
81    #[serde(default)]
82    pub agents: Vec<AgentEntry>,
83    #[serde(default)]
84    pub hooks: Vec<HookEntry>,
85    pub managed_mcp_servers: Vec<ManagedMcpServer>,
86    pub revocations: Vec<String>,
87    #[serde(default)]
88    pub enabled_hosts: Vec<String>,
89    #[serde(default)]
90    pub host_model_protocols: BTreeMap<String, Vec<String>>,
91    #[serde(default)]
92    pub artifacts: Vec<ArtifactEntry>,
93    #[serde(default)]
94    pub allow_claude_ai_connectors: bool,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub diagnostics: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct UserInfo {
101    pub id: UserId,
102    pub name: String,
103    pub email: String,
104    #[serde(default)]
105    pub display_name: Option<String>,
106    #[serde(default)]
107    pub roles: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct PluginEntry {
112    pub id: PluginId,
113    pub version: String,
114    pub sha256: Sha256Digest,
115    pub files: Vec<PluginFile>,
116    #[serde(default)]
117    pub hooks: PluginHooksRef,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct PluginFile {
122    pub path: String,
123    pub sha256: Sha256Digest,
124    pub size: u64,
125}
126
127/// A Cowork-native library document (raw HTML in the desktop app's Artifacts
128/// library) — not one of the in-chat MCP artifacts in [`crate::artifacts`].
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ArtifactEntry {
131    pub id: LibraryArtifactId,
132    pub name: String,
133    pub description: String,
134    pub version: String,
135    pub mcp_tools: Vec<String>,
136    pub content: String,
137    pub starred: bool,
138    pub sha256: Sha256Digest,
139}
140
141// Why: field names and casing must track Cowork's native `create_artifact`
142// input, so a consumer can read a bundle's `artifacts/<id>.json` and the
143// bridge's staged library records with one parser.
144#[derive(Debug, Serialize)]
145pub struct CoworkLibraryArtifactRecord<'a> {
146    pub id: &'a str,
147    pub name: &'a str,
148    pub description: &'a str,
149    pub version: &'a str,
150    pub content: &'a str,
151    #[serde(rename = "isStarred")]
152    pub is_starred: bool,
153    #[serde(rename = "mcpTools")]
154    pub mcp_tools: &'a [String],
155}
156
157impl<'a> From<&'a ArtifactEntry> for CoworkLibraryArtifactRecord<'a> {
158    fn from(a: &'a ArtifactEntry) -> Self {
159        Self {
160            id: a.id.as_str(),
161            name: &a.name,
162            description: &a.description,
163            version: &a.version,
164            content: &a.content,
165            is_starred: a.starred,
166            mcp_tools: &a.mcp_tools,
167        }
168    }
169}
170
171// Why: the install manifest a plugin bundle ships at `artifacts/manifest.json`
172// — every record minus its HTML, which sits beside it as `artifacts/<id>.html`
173// so a seed skill can copy a page without parsing JSON.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct CoworkArtifactBundleManifest {
176    pub artifacts: Vec<CoworkArtifactBundleRecord>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CoworkArtifactBundleRecord {
181    pub id: LibraryArtifactId,
182    pub name: String,
183    pub description: String,
184    pub version: String,
185    #[serde(rename = "isStarred")]
186    pub is_starred: bool,
187    #[serde(rename = "mcpTools")]
188    pub mcp_tools: Vec<String>,
189}
190
191impl From<&ArtifactEntry> for CoworkArtifactBundleRecord {
192    fn from(a: &ArtifactEntry) -> Self {
193        Self {
194            id: a.id.clone(),
195            name: a.name.clone(),
196            description: a.description.clone(),
197            version: a.version.clone(),
198            is_starred: a.starred,
199            mcp_tools: a.mcp_tools.clone(),
200        }
201    }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct SkillEntry {
206    pub id: SkillId,
207    pub name: SkillName,
208    pub description: String,
209    pub file_path: String,
210    #[serde(default)]
211    pub tags: Vec<String>,
212    pub sha256: Sha256Digest,
213    pub instructions: String,
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub hosts: Vec<String>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct AgentEntry {
220    pub id: AgentId,
221    pub name: AgentName,
222    pub display_name: String,
223    pub description: String,
224    pub version: String,
225    pub endpoint: String,
226    pub enabled: bool,
227    pub is_default: bool,
228    pub is_primary: bool,
229    #[serde(default)]
230    pub provider: Option<String>,
231    #[serde(default)]
232    pub model: Option<String>,
233    #[serde(default)]
234    pub mcp_servers: PluginComponentRef,
235    #[serde(default)]
236    pub skills: PluginComponentRef,
237    #[serde(default)]
238    pub tags: Vec<String>,
239    #[serde(default)]
240    pub system_prompt: Option<String>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct HookEntry {
245    pub id: HookId,
246    pub name: String,
247    pub description: String,
248    pub version: String,
249    pub event: HookEvent,
250    pub matcher: String,
251    pub command: String,
252    #[serde(default)]
253    pub is_async: bool,
254    pub category: HookCategory,
255    #[serde(default)]
256    pub tags: Vec<String>,
257    pub sha256: Sha256Digest,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(from = "ManagedMcpServerWire")]
262pub struct ManagedMcpServer {
263    pub id: McpServerId,
264    pub name: ManagedMcpServerName,
265    pub url: ValidatedUrl,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub transport: Option<String>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub headers: Option<BTreeMap<String, String>>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub oauth: Option<bool>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub tool_policy: Option<BTreeMap<ToolName, ToolPolicy>>,
274}
275
276// Why: manifests signed before the `id` field existed carry only `name`, so
277// deserialization derives an absent id from it.
278#[derive(Deserialize)]
279struct ManagedMcpServerWire {
280    #[serde(default)]
281    id: Option<McpServerId>,
282    name: ManagedMcpServerName,
283    url: ValidatedUrl,
284    #[serde(default)]
285    transport: Option<String>,
286    #[serde(default)]
287    headers: Option<BTreeMap<String, String>>,
288    #[serde(default)]
289    oauth: Option<bool>,
290    #[serde(default)]
291    tool_policy: Option<BTreeMap<ToolName, ToolPolicy>>,
292}
293
294impl From<ManagedMcpServerWire> for ManagedMcpServer {
295    fn from(wire: ManagedMcpServerWire) -> Self {
296        let id = wire
297            .id
298            .unwrap_or_else(|| McpServerId::new(wire.name.as_str()));
299        Self {
300            id,
301            name: wire.name,
302            url: wire.url,
303            transport: wire.transport,
304            headers: wire.headers,
305            oauth: wire.oauth,
306            tool_policy: wire.tool_policy,
307        }
308    }
309}