objectiveai_cli/filesystem/plugins/manifest.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::filesystem::tools::{CliZip, Exec};
5
6/// Declarative metadata a plugin ships with — the on-disk
7/// `objectiveai.json` at
8/// `<base_dir>/plugins/<owner>/<name>/<version>/objectiveai.json`. The
9/// CLI reads and writes this shape verbatim (install writes the fetched
10/// manifest exactly as authored); the `plugins get` wire response is a
11/// lean projection of it.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
13#[schemars(rename = "filesystem.plugins.Manifest")]
14pub struct Manifest {
15 /// GitHub `<owner>` segment of the source repo, written verbatim
16 /// from the fetched manifest (forks land on disk with whatever
17 /// owner they authored, not rewritten to the install coordinate).
18 pub owner: String,
19
20 /// The plugin's identifier (matches the `<name>` directory
21 /// segment).
22 pub name: String,
23
24 /// Version string. Semver convention is recommended but not
25 /// enforced — the host just displays whatever's here.
26 pub version: String,
27
28 /// One-line description of what the plugin does. Surfaced in
29 /// listings and the plugin's `--help`-equivalent UI.
30 pub description: String,
31
32 /// Per-OS exec command for the plugin's cli side — the same shape
33 /// tools use. The current platform's vector is the program plus
34 /// its leading arguments; the run's args are appended, and the
35 /// whole thing runs with CWD = `<plugin dir>/cli/`. Relative
36 /// program paths resolve against that folder; bare names keep
37 /// PATH-lookup semantics. An empty per-OS vector means viewer-only
38 /// for that platform.
39 pub exec: Exec,
40
41 /// Per-OS cli bundle zip filenames. At install the current
42 /// platform's entry (when present) is downloaded and extracted
43 /// into `<plugin dir>/cli/` (the [`Self::exec`] working
44 /// directory), the same way [`Self::viewer_zip`] extracts to
45 /// `viewer/`. Required field; each per-OS entry is optional —
46 /// absent means the exec needs nothing on disk for that platform
47 /// (e.g. it invokes a PATH-resolved program, or the plugin is
48 /// viewer-only).
49 pub cli_zip: CliZip,
50
51 /// GitHub-release asset filename for the plugin's viewer UI
52 /// bundle (a `.zip` whose root contains `index.html` plus
53 /// assets). When absent, the plugin has no viewer tab from this
54 /// source. Mutually exclusive with [`Self::viewer_url`] —
55 /// validated by [`Self::validate`].
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 #[schemars(extend("omitempty" = true))]
58 pub viewer_zip: Option<String>,
59
60 /// Remote URL the viewer's iframe loads directly, instead of an
61 /// on-disk bundle from [`Self::viewer_zip`]. The full URL is used
62 /// as the iframe `src=` verbatim — query string, path, port,
63 /// fragment all pass through. Must use `https://`, or `http://`
64 /// targeting `localhost` / `127.0.0.1` (development only).
65 ///
66 /// Mutually exclusive with [`Self::viewer_zip`]. [`Self::viewer_routes`]
67 /// apply to remote-URL viewers the same way they apply to
68 /// zip-bundled viewers — the embedded axum server still hosts the
69 /// declared routes; the iframe still receives the same postMessage
70 /// protocol regardless of where its HTML/JS loaded from.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 #[schemars(extend("omitempty" = true))]
73 pub viewer_url: Option<String>,
74
75 /// HTTP routes the viewer exposes on behalf of this plugin.
76 /// Each entry registers a handler at
77 /// `/plugin/<repository>/<path>` on the viewer's embedded axum
78 /// server; a hit emits a `PluginRequest { type, value }` event
79 /// to the React frontend, which dispatches to the plugin's
80 /// iframe via the postMessage bridge.
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub viewer_routes: Vec<ViewerRoute>,
83
84 /// MCP servers the plugin wants the host to expose. Each entry
85 /// has a `name` (the identifier agents reference via
86 /// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`])
87 /// plus an `authorization` flag. Auth-requiring entries flag
88 /// `authorization = true`; credentials are resolved by the host
89 /// (env vars / config), not the manifest.
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 #[schemars(extend("omitempty" = true))]
92 pub mcp_servers: Vec<McpServer>,
93}
94
95impl Manifest {
96 /// Whether this plugin presents a viewer tab in the host. True
97 /// iff either viewer source field — [`Self::viewer_zip`] or
98 /// [`Self::viewer_url`] — is set.
99 pub fn has_viewer(&self) -> bool {
100 self.viewer_zip.is_some() || self.viewer_url.is_some()
101 }
102
103 /// Validate fields that can't be enforced by serde alone:
104 /// `viewer_zip` and `viewer_url` are mutually exclusive, and
105 /// `viewer_url` (when present) must be `https://` or `http://`
106 /// targeting `localhost` / `127.0.0.1`. Called at every parse
107 /// boundary (remote-fetched install, on-disk read) so a broken
108 /// manifest can't sneak through.
109 pub fn validate(&self) -> Result<(), &'static str> {
110 if self.viewer_zip.is_some() && self.viewer_url.is_some() {
111 return Err("viewer_zip and viewer_url are mutually exclusive");
112 }
113 if let Some(url) = self.viewer_url.as_deref() {
114 validate_viewer_url(url)?;
115 }
116 // Each MCP-server entry needs a non-empty name; the list as a
117 // whole must have no `name` duplicates (since agents reference
118 // by name).
119 for entry in &self.mcp_servers {
120 if entry.name.is_empty() {
121 return Err("mcp_servers[i].name cannot be empty");
122 }
123 }
124 for (i, a) in self.mcp_servers.iter().enumerate() {
125 for b in &self.mcp_servers[i + 1..] {
126 if a.name == b.name {
127 return Err("mcp_servers contains duplicate name");
128 }
129 }
130 }
131 Ok(())
132 }
133}
134
135/// Allow `https://*`. Allow `http://` only when the host is
136/// `localhost` or `127.0.0.1` (development). Reject everything else
137/// — raw http on a public hostname inside a Tauri WebView is a
138/// footgun (plaintext, MITM-able, mixed-content-blocked by the
139/// browser engine in most cases).
140///
141/// Dependency-free: a couple of `starts_with` / split checks beat
142/// pulling the full `url` crate for one validation. Doesn't handle
143/// IPv6 brackets or punycode — neither matters for the localhost
144/// allow-list.
145fn validate_viewer_url(url: &str) -> Result<(), &'static str> {
146 let url = url.trim();
147 if url.is_empty() {
148 return Err("viewer_url cannot be empty");
149 }
150 if url.starts_with("https://") {
151 return Ok(());
152 }
153 if let Some(rest) = url.strip_prefix("http://") {
154 // Host ends at the first '/', ':', '?', '#', or EOF.
155 let host_end = rest
156 .find(|c: char| matches!(c, '/' | ':' | '?' | '#'))
157 .unwrap_or(rest.len());
158 let host = &rest[..host_end];
159 if host == "localhost" || host == "127.0.0.1" {
160 return Ok(());
161 }
162 return Err(
163 "viewer_url with http:// scheme is only allowed for localhost or 127.0.0.1",
164 );
165 }
166 Err("viewer_url must use https:// or http://localhost / http://127.0.0.1")
167}
168
169/// MCP server entry inside [`Manifest::mcp_servers`]. A host-exposed
170/// upstream identified by a `name` that agent declarations reference
171/// (via
172/// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`]),
173/// plus an `authorization` flag.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
175#[schemars(rename = "filesystem.plugins.McpServer")]
176pub struct McpServer {
177 /// Author-chosen identifier. Unique per plugin manifest. Agents
178 /// declare which subset of the plugin's MCP servers they want
179 /// exposed by listing names here.
180 pub name: String,
181 /// Whether the host should attach an `Authorization` header
182 /// when dialing this upstream. Credentials are resolved by the
183 /// host (env vars / config), not the manifest.
184 #[serde(default)]
185 pub authorization: bool,
186}
187
188/// One HTTP route a plugin's viewer registers on the host viewer's
189/// embedded axum server. The full path served is
190/// `/plugin/<repository>/<self.path>`; on a hit, the body is
191/// JSON-decoded and forwarded as a `PluginRequest { type: self.type,
192/// value: body }` event to the frontend.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
194#[schemars(rename = "filesystem.plugins.ViewerRoute")]
195pub struct ViewerRoute {
196 /// Path relative to the plugin's namespace. Must start with `/`;
197 /// the host prepends `/plugin/<repository>` before registering.
198 pub path: String,
199
200 /// HTTP method this route handles. Methods other than the listed
201 /// five aren't supported (and don't appear in plugin practice).
202 pub method: HttpMethod,
203
204 /// String tag forwarded to the plugin's iframe as the `type`
205 /// field of the resulting `PluginRequest`. Plugin authors pick
206 /// any value they want; the host doesn't interpret it.
207 #[serde(rename = "type")]
208 pub r#type: String,
209}
210
211/// HTTP methods supported by [`ViewerRoute`]. Serializes as upper-case
212/// (`"GET"`, `"POST"`, …) on the wire.
213#[derive(
214 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
215)]
216#[schemars(rename = "filesystem.plugins.HttpMethod")]
217#[serde(rename_all = "UPPERCASE")]
218pub enum HttpMethod {
219 Get,
220 Post,
221 Put,
222 Patch,
223 Delete,
224}
225
226// Typed conversion to the SDK's lean `plugins get` wire shape — drops
227// the on-disk-only fields (`cli_zip`, `viewer_zip`). Lets the
228// `command::plugins::{get, list}` leaves yield SDK `ResponseManifest`
229// items without round-tripping through `serde_json::Value`. The inner
230// type parallels (`ViewerRoute`/`ResponseViewerRoute`,
231// `McpServer`/`ResponseMcpServer`, `HttpMethod`/`ResponseHttpMethod`)
232// are field-identical — collapsing them into shared types is a
233// separate cleanup pass. `exec` is the SDK type already.
234impl From<Manifest> for objectiveai_sdk::cli::command::plugins::get::ResponseManifest {
235 fn from(m: Manifest) -> Self {
236 use objectiveai_sdk::cli::command::plugins::get::{
237 ResponseHttpMethod, ResponseManifest, ResponseMcpServer,
238 ResponseViewerRoute,
239 };
240 ResponseManifest {
241 owner: m.owner,
242 name: m.name,
243 version: m.version,
244 description: m.description,
245 exec: m.exec,
246 viewer_url: m.viewer_url,
247 viewer_routes: m
248 .viewer_routes
249 .into_iter()
250 .map(|r| ResponseViewerRoute {
251 path: r.path,
252 method: match r.method {
253 HttpMethod::Get => ResponseHttpMethod::Get,
254 HttpMethod::Post => ResponseHttpMethod::Post,
255 HttpMethod::Put => ResponseHttpMethod::Put,
256 HttpMethod::Patch => ResponseHttpMethod::Patch,
257 HttpMethod::Delete => ResponseHttpMethod::Delete,
258 },
259 r#type: r.r#type,
260 })
261 .collect(),
262 mcp_servers: m
263 .mcp_servers
264 .into_iter()
265 .map(|s| ResponseMcpServer {
266 name: s.name,
267 authorization: s.authorization,
268 })
269 .collect(),
270 }
271 }
272}