Skip to main content

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    /// When `true`, the plugin participates in the per-state plugin
95    /// daemon (`daemon spawn`): the daemon launches it as
96    /// `<exec> daemon begin` (via the shared plugin executor) and keeps
97    /// it resident. Host-internal — not surfaced on the `plugins get`
98    /// wire projection. Skipped on serialization when `false` so
99    /// non-daemon manifests stay lean.
100    #[serde(default, skip_serializing_if = "is_false")]
101    #[schemars(extend("omitempty" = true))]
102    pub daemon: bool,
103}
104
105fn is_false(value: &bool) -> bool {
106    !*value
107}
108
109impl Manifest {
110    /// Whether this plugin presents a viewer tab in the host. True
111    /// iff either viewer source field — [`Self::viewer_zip`] or
112    /// [`Self::viewer_url`] — is set.
113    pub fn has_viewer(&self) -> bool {
114        self.viewer_zip.is_some() || self.viewer_url.is_some()
115    }
116
117    /// Validate fields that can't be enforced by serde alone:
118    /// `viewer_zip` and `viewer_url` are mutually exclusive, and
119    /// `viewer_url` (when present) must be `https://` or `http://`
120    /// targeting `localhost` / `127.0.0.1`. Called at every parse
121    /// boundary (remote-fetched install, on-disk read) so a broken
122    /// manifest can't sneak through.
123    pub fn validate(&self) -> Result<(), &'static str> {
124        if self.viewer_zip.is_some() && self.viewer_url.is_some() {
125            return Err("viewer_zip and viewer_url are mutually exclusive");
126        }
127        if let Some(url) = self.viewer_url.as_deref() {
128            validate_viewer_url(url)?;
129        }
130        // Each MCP-server entry needs a non-empty name; the list as a
131        // whole must have no `name` duplicates (since agents reference
132        // by name).
133        for entry in &self.mcp_servers {
134            if entry.name.is_empty() {
135                return Err("mcp_servers[i].name cannot be empty");
136            }
137        }
138        for (i, a) in self.mcp_servers.iter().enumerate() {
139            for b in &self.mcp_servers[i + 1..] {
140                if a.name == b.name {
141                    return Err("mcp_servers contains duplicate name");
142                }
143            }
144        }
145        Ok(())
146    }
147}
148
149/// Allow `https://*`. Allow `http://` only when the host is
150/// `localhost` or `127.0.0.1` (development). Reject everything else
151/// — raw http on a public hostname inside a Tauri WebView is a
152/// footgun (plaintext, MITM-able, mixed-content-blocked by the
153/// browser engine in most cases).
154///
155/// Dependency-free: a couple of `starts_with` / split checks beat
156/// pulling the full `url` crate for one validation. Doesn't handle
157/// IPv6 brackets or punycode — neither matters for the localhost
158/// allow-list.
159fn validate_viewer_url(url: &str) -> Result<(), &'static str> {
160    let url = url.trim();
161    if url.is_empty() {
162        return Err("viewer_url cannot be empty");
163    }
164    if url.starts_with("https://") {
165        return Ok(());
166    }
167    if let Some(rest) = url.strip_prefix("http://") {
168        // Host ends at the first '/', ':', '?', '#', or EOF.
169        let host_end = rest
170            .find(|c: char| matches!(c, '/' | ':' | '?' | '#'))
171            .unwrap_or(rest.len());
172        let host = &rest[..host_end];
173        if host == "localhost" || host == "127.0.0.1" {
174            return Ok(());
175        }
176        return Err(
177            "viewer_url with http:// scheme is only allowed for localhost or 127.0.0.1",
178        );
179    }
180    Err("viewer_url must use https:// or http://localhost / http://127.0.0.1")
181}
182
183/// MCP server entry inside [`Manifest::mcp_servers`]. A host-exposed
184/// upstream identified by a `name` that agent declarations reference
185/// (via
186/// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`]),
187/// plus an `authorization` flag.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
189#[schemars(rename = "filesystem.plugins.McpServer")]
190pub struct McpServer {
191    /// Author-chosen identifier. Unique per plugin manifest. Agents
192    /// declare which subset of the plugin's MCP servers they want
193    /// exposed by listing names here.
194    pub name: String,
195    /// Whether the host should attach an `Authorization` header
196    /// when dialing this upstream. Credentials are resolved by the
197    /// host (env vars / config), not the manifest.
198    #[serde(default)]
199    pub authorization: bool,
200}
201
202/// One HTTP route a plugin's viewer registers on the host viewer's
203/// embedded axum server. The full path served is
204/// `/plugin/<repository>/<self.path>`; on a hit, the body is
205/// JSON-decoded and forwarded as a `PluginRequest { type: self.type,
206/// value: body }` event to the frontend.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
208#[schemars(rename = "filesystem.plugins.ViewerRoute")]
209pub struct ViewerRoute {
210    /// Path relative to the plugin's namespace. Must start with `/`;
211    /// the host prepends `/plugin/<repository>` before registering.
212    pub path: String,
213
214    /// HTTP method this route handles. Methods other than the listed
215    /// five aren't supported (and don't appear in plugin practice).
216    pub method: HttpMethod,
217
218    /// String tag forwarded to the plugin's iframe as the `type`
219    /// field of the resulting `PluginRequest`. Plugin authors pick
220    /// any value they want; the host doesn't interpret it.
221    #[serde(rename = "type")]
222    pub r#type: String,
223}
224
225/// HTTP methods supported by [`ViewerRoute`]. Serializes as upper-case
226/// (`"GET"`, `"POST"`, …) on the wire.
227#[derive(
228    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
229)]
230#[schemars(rename = "filesystem.plugins.HttpMethod")]
231#[serde(rename_all = "UPPERCASE")]
232pub enum HttpMethod {
233    Get,
234    Post,
235    Put,
236    Patch,
237    Delete,
238}
239
240// Typed conversion to the SDK's lean `plugins get` wire shape — drops
241// the on-disk-only fields (`cli_zip`, `viewer_zip`). Lets the
242// `command::plugins::{get, list}` leaves yield SDK `ResponseManifest`
243// items without round-tripping through `serde_json::Value`. The inner
244// type parallels (`ViewerRoute`/`ResponseViewerRoute`,
245// `McpServer`/`ResponseMcpServer`, `HttpMethod`/`ResponseHttpMethod`)
246// are field-identical — collapsing them into shared types is a
247// separate cleanup pass. `exec` is the SDK type already.
248impl From<Manifest> for objectiveai_sdk::cli::command::plugins::get::ResponseManifest {
249    fn from(m: Manifest) -> Self {
250        use objectiveai_sdk::cli::command::plugins::get::{
251            ResponseHttpMethod, ResponseManifest, ResponseMcpServer,
252            ResponseViewerRoute,
253        };
254        ResponseManifest {
255            owner: m.owner,
256            name: m.name,
257            version: m.version,
258            description: m.description,
259            exec: m.exec,
260            viewer_url: m.viewer_url,
261            viewer_routes: m
262                .viewer_routes
263                .into_iter()
264                .map(|r| ResponseViewerRoute {
265                    path: r.path,
266                    method: match r.method {
267                        HttpMethod::Get => ResponseHttpMethod::Get,
268                        HttpMethod::Post => ResponseHttpMethod::Post,
269                        HttpMethod::Put => ResponseHttpMethod::Put,
270                        HttpMethod::Patch => ResponseHttpMethod::Patch,
271                        HttpMethod::Delete => ResponseHttpMethod::Delete,
272                    },
273                    r#type: r.r#type,
274                })
275                .collect(),
276            mcp_servers: m
277                .mcp_servers
278                .into_iter()
279                .map(|s| ResponseMcpServer {
280                    name: s.name,
281                    authorization: s.authorization,
282                })
283                .collect(),
284        }
285    }
286}