Skip to main content

objectiveai_sdk/filesystem/plugins/
client.rs

1//! Plugin discovery on the local filesystem.
2//!
3//! Installed plugins live at `<base_dir>/plugins/<name>/`, with the
4//! binary at `<base_dir>/plugins/<name>/plugin` (or `plugin.exe` on
5//! Windows) and the optional viewer bundle at
6//! `<base_dir>/plugins/<name>/viewer/`. The manifest is persisted
7//! alongside at `<base_dir>/plugins/<name>.json`. The cli's
8//! external-subcommand dispatch uses [`Client::resolve_plugin`] to
9//! turn a user-supplied plugin name into an executable path.
10//!
11//! [`Client::install_plugin`] always writes the canonical
12//! `plugin[.exe]` filename, but [`Client::resolve_plugin`] tolerates a
13//! tiered fallback — see its docstring for the exact lookup order.
14
15use std::path::{Path, PathBuf};
16
17use super::super::Client;
18use super::{Manifest, ManifestWithNameAndSource};
19
20/// Two-step parse: try `ManifestWithNameAndSource` first (installed
21/// plugins persist `name` + `source`), then fall back to a bare
22/// `Manifest` with `name = file_stem` and `source = absolute_path`
23/// (hand-edited / pre-existing manifests). Returns `None` on missing
24/// / unreadable / malformed files.
25async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
26    let bytes = tokio::fs::read(path).await.ok()?;
27    if let Ok(full) = serde_json::from_slice::<ManifestWithNameAndSource>(&bytes) {
28        // Same validate gate as the install path: malformed
29        // viewer-source combos shouldn't surface as installed plugins.
30        full.manifest.validate().ok()?;
31        return Some(full);
32    }
33    let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
34    manifest.validate().ok()?;
35    let name = path.file_stem()?.to_str()?.to_string();
36    let source = path.to_string_lossy().into_owned();
37    Some(ManifestWithNameAndSource { name, manifest, source })
38}
39
40impl Client {
41    /// The plugins directory: `<base_dir>/plugins`.
42    pub fn plugins_dir(&self) -> PathBuf {
43        self.base_dir().join("plugins")
44    }
45
46    /// The directory that holds a plugin's installed artifacts:
47    /// `<plugins_dir>/<name>/`. Contains the binary, an optional
48    /// `viewer/` bundle, and any runtime state the plugin writes.
49    pub fn plugin_dir(&self, name: &str) -> PathBuf {
50        self.plugins_dir().join(name)
51    }
52
53    /// Canonical path of a plugin's binary:
54    /// `<plugins_dir>/<name>/plugin` on Unix, `…/plugin.exe` on
55    /// Windows. Used by both `install_plugin` (write target) and
56    /// `resolve_plugin` (read target) so the two cannot drift.
57    pub fn plugin_binary_path(&self, name: &str) -> PathBuf {
58        self.plugin_dir(name)
59            .join(if cfg!(windows) { "plugin.exe" } else { "plugin" })
60    }
61
62    /// Resolve a plugin name to its executable path. Lookup order:
63    ///
64    /// 1. Platform-preferred canonical:
65    ///    `<plugins_dir>/<name>/plugin.exe` on Windows,
66    ///    `<plugins_dir>/<name>/plugin` elsewhere. Matches what
67    ///    [`Self::install_plugin`] writes.
68    /// 2. Cross-platform canonical: the same two filenames in opposite
69    ///    order, for hand-placed binaries that came from the "wrong"
70    ///    platform's release asset.
71    /// 3. First-found fallback: any file under `<plugin_dir>/` whose
72    ///    `file_stem` is exactly `"plugin"` and whose `extension` is
73    ///    something other than the two canonical cases — e.g.
74    ///    `plugin.bat`, `plugin.sh`, `plugin.cmd`. Tiebreak is
75    ///    `read_dir` order (filesystem-defined).
76    ///
77    /// Tier 3's stem match uses `Path::file_stem`, which strips only
78    /// the last extension component, so multi-segment names like
79    /// `plugin.tar.gz` (stem = `plugin.tar`) don't accidentally count.
80    ///
81    /// Returns `None` if none of the three tiers turn up a regular
82    /// file. Uses `tokio` filesystem APIs throughout — never blocks.
83    pub async fn resolve_plugin(&self, name: &str) -> Option<PathBuf> {
84        let dir = self.plugin_dir(name);
85
86        // Tiers 1 + 2.
87        #[cfg(windows)]
88        let priority: [&str; 2] = ["plugin.exe", "plugin"];
89        #[cfg(not(windows))]
90        let priority: [&str; 2] = ["plugin", "plugin.exe"];
91
92        for filename in priority {
93            let path = dir.join(filename);
94            if tokio::fs::metadata(&path)
95                .await
96                .map(|m| m.is_file())
97                .unwrap_or(false)
98            {
99                return Some(path);
100            }
101        }
102
103        // Tier 3: scan for any other `plugin.<ext>` file.
104        let mut read_dir = tokio::fs::read_dir(&dir).await.ok()?;
105        while let Ok(Some(entry)) = read_dir.next_entry().await {
106            let path = entry.path();
107            let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
108                continue;
109            };
110            if file_name == "plugin" || file_name == "plugin.exe" {
111                // Already tried by the priority loop.
112                continue;
113            }
114            if path.file_stem().and_then(|s| s.to_str()) != Some("plugin") {
115                continue;
116            }
117            if path.extension().is_none() {
118                // Defensive: file_stem == "plugin" + no extension would
119                // be the file named exactly "plugin", which the
120                // priority loop already covered.
121                continue;
122            }
123            if entry.metadata().await.map(|m| m.is_file()).unwrap_or(false) {
124                return Some(path);
125            }
126        }
127
128        None
129    }
130
131    /// Look up a single plugin manifest by name. Reads
132    /// `<base_dir>/plugins/<name>.json`. If the file persists `name`
133    /// and `source` (as installed plugins do), they're returned
134    /// verbatim; otherwise the wrapper is synthesized with
135    /// `name = <name>` and `source = absolute_path`. Returns `None`
136    /// if the file is missing, unreadable, or malformed.
137    pub async fn get_plugin(&self, name: &str) -> Option<ManifestWithNameAndSource> {
138        let path = self.plugins_dir().join(format!("{name}.json"));
139        parse_manifest_file(&path).await
140    }
141
142    /// Enumerate plugin manifests in the plugins directory. Reads each
143    /// `.json` file in `<base_dir>/plugins/`, deserializes it as a
144    /// [`Manifest`], and pairs it with the file's stem (`name`) and
145    /// absolute path (`source`). Every failure mode — missing dir,
146    /// unreadable file, malformed JSON, missing required field — is
147    /// silently skipped; the return type is plain `Vec` rather than
148    /// `Result` to reflect that.
149    ///
150    /// Results are sorted by manifest mtime descending (most recently
151    /// modified first), then `skip(offset).take(limit)` is applied —
152    /// matching the convention of the logs list endpoints. Pass
153    /// `(0, usize::MAX)` for an unbounded list.
154    ///
155    /// The directory scan is sequential (intrinsic to `read_dir`) but
156    /// per-file read+parse runs concurrently via
157    /// [`futures::future::join_all`].
158    pub async fn list_plugins(&self, offset: usize, limit: usize) -> Vec<ManifestWithNameAndSource> {
159        let dir = self.plugins_dir();
160        let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await else {
161            return Vec::new();
162        };
163        let mut paths: Vec<PathBuf> = Vec::new();
164        while let Ok(Some(entry)) = read_dir.next_entry().await {
165            let path = entry.path();
166            if path.extension().and_then(|e| e.to_str()) == Some("json") {
167                paths.push(path);
168            }
169        }
170        let futures = paths.into_iter().map(|p| async move {
171            let bundle = parse_manifest_file(&p).await?;
172            let modified = tokio::fs::metadata(&p)
173                .await
174                .ok()?
175                .modified()
176                .ok()?
177                .duration_since(std::time::SystemTime::UNIX_EPOCH)
178                .ok()?
179                .as_secs();
180            Some((modified, bundle))
181        });
182        let mut entries: Vec<(u64, ManifestWithNameAndSource)> = futures::future::join_all(futures)
183            .await
184            .into_iter()
185            .flatten()
186            .collect();
187        entries.sort_by(|a, b| b.0.cmp(&a.0));
188        let iter = entries.into_iter().map(|(_, m)| m);
189        if offset > 0 || limit < usize::MAX {
190            iter.skip(offset).take(limit).collect()
191        } else {
192            iter.collect()
193        }
194    }
195}
196
197#[cfg(feature = "http")]
198impl Client {
199    /// Install a plugin from a GitHub repository.
200    ///
201    /// 1. Fetches `objectiveai.json` from `raw.githubusercontent.com`
202    ///    at the supplied `commit_sha` (or the default branch via
203    ///    `HEAD` when none).
204    /// 2. Parses it as a [`Manifest`].
205    /// 3. Looks up the current platform in `manifest.binaries`. If
206    ///    absent (or this host's platform isn't recognized by
207    ///    [`super::Platform::current`]), returns `Ok(false)` — the
208    ///    plugin simply doesn't support this host.
209    /// 4. Downloads the matching release asset from
210    ///    `https://github.com/<owner>/<repository>/releases/download/v<version>/<asset>`.
211    /// 5. Writes it to `<base_dir>/plugins/<repository>/plugin`
212    ///    (`plugin.exe` on Windows). Sets mode `0o755` on Unix so the
213    ///    binary is executable.
214    ///
215    /// `headers` is an optional `IndexMap<String, String>` that gets
216    /// attached to both HTTP requests (e.g. `Authorization` for
217    /// private repos / higher rate limits). The cli always passes
218    /// `None`.
219    ///
220    /// Failures past step 3 are returned as
221    /// [`super::InstallError`] wrapped by
222    /// [`super::super::Error::Install`].
223    pub async fn install_plugin(
224        &self,
225        owner: &str,
226        repository: &str,
227        commit_sha: Option<&str>,
228        headers: Option<&indexmap::IndexMap<String, String>>,
229        upgrade: bool,
230    ) -> Result<bool, super::super::Error> {
231        check_repository_name(repository)?;
232        let manifest = self
233            .fetch_plugin_manifest(owner, repository, commit_sha, headers)
234            .await?;
235        let source = raw_manifest_url(owner, repository, commit_sha);
236        self.install_plugin_from_manifest(owner, repository, &manifest, &source, headers, upgrade)
237            .await
238    }
239
240    /// Step 1 of `install_plugin`: fetch `<owner>/<repo>/<ref>/objectiveai.json`
241    /// from `raw.githubusercontent.com` and parse it as a [`Manifest`].
242    /// Exposed publicly so callers can inspect the manifest before
243    /// committing to an install (e.g. for whitelist checks).
244    pub async fn fetch_plugin_manifest(
245        &self,
246        owner: &str,
247        repository: &str,
248        commit_sha: Option<&str>,
249        headers: Option<&indexmap::IndexMap<String, String>>,
250    ) -> Result<Manifest, super::super::Error> {
251        self.fetch_plugin_manifest_impl(
252            "https://raw.githubusercontent.com",
253            owner,
254            repository,
255            commit_sha,
256            headers,
257        )
258        .await
259    }
260
261    /// Step 2 of `install_plugin`: given an already-parsed manifest,
262    /// pick the binary for the current platform (`Ok(false)` if
263    /// absent), download it from the corresponding release asset,
264    /// and write it to `<plugins_dir>/<repository>/plugin[.exe]`.
265    pub async fn install_plugin_from_manifest(
266        &self,
267        owner: &str,
268        repository: &str,
269        manifest: &Manifest,
270        source: &str,
271        headers: Option<&indexmap::IndexMap<String, String>>,
272        upgrade: bool,
273    ) -> Result<bool, super::super::Error> {
274        check_repository_name(repository)?;
275        self.install_from_manifest_impl(
276            "https://github.com",
277            owner,
278            repository,
279            manifest,
280            source,
281            headers,
282            upgrade,
283        )
284        .await
285    }
286
287    /// Test-only entry point that exposes the raw / releases URL
288    /// bases so in-process mock servers can intercept the requests.
289    /// Threads both URLs through the same fetch + install_from path
290    /// used by production.
291    #[cfg(test)]
292    pub(super) async fn install_plugin_at(
293        &self,
294        raw_base: &str,
295        releases_base: &str,
296        owner: &str,
297        repository: &str,
298        commit_sha: Option<&str>,
299        headers: Option<&indexmap::IndexMap<String, String>>,
300        upgrade: bool,
301    ) -> Result<bool, super::super::Error> {
302        check_repository_name(repository)?;
303        let manifest = self
304            .fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
305            .await?;
306        let reference = commit_sha.unwrap_or("HEAD");
307        let source = format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
308        self.install_from_manifest_impl(
309            releases_base,
310            owner,
311            repository,
312            &manifest,
313            &source,
314            headers,
315            upgrade,
316        )
317        .await
318    }
319
320    /// Test-only fetch-only entry point, mirrors `install_plugin_at`.
321    #[cfg(test)]
322    pub(super) async fn fetch_plugin_manifest_at(
323        &self,
324        raw_base: &str,
325        owner: &str,
326        repository: &str,
327        commit_sha: Option<&str>,
328        headers: Option<&indexmap::IndexMap<String, String>>,
329    ) -> Result<Manifest, super::super::Error> {
330        self.fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
331            .await
332    }
333
334    async fn fetch_plugin_manifest_impl(
335        &self,
336        raw_base: &str,
337        owner: &str,
338        repository: &str,
339        commit_sha: Option<&str>,
340        headers: Option<&indexmap::IndexMap<String, String>>,
341    ) -> Result<Manifest, super::super::Error> {
342        let http = reqwest::Client::new();
343        let header_map = build_headers(headers)?;
344        let reference = commit_sha.unwrap_or("HEAD");
345        let manifest_url =
346            format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
347        let resp = http
348            .get(&manifest_url)
349            .headers(header_map)
350            .send()
351            .await
352            .map_err(super::InstallError::ManifestRequest)?;
353        let status = resp.status();
354        let bytes = resp
355            .bytes()
356            .await
357            .map_err(super::InstallError::ManifestResponse)?;
358        if !status.is_success() {
359            return Err(super::InstallError::ManifestBadStatus {
360                code: status,
361                url: manifest_url,
362                body: String::from_utf8_lossy(&bytes).into_owned(),
363            }
364            .into());
365        }
366        let mut de = serde_json::Deserializer::from_slice(&bytes);
367        let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
368            .map_err(super::InstallError::ManifestParse)?;
369        manifest
370            .validate()
371            .map_err(super::InstallError::ManifestInvalid)?;
372        Ok(manifest)
373    }
374
375    async fn install_from_manifest_impl(
376        &self,
377        releases_base: &str,
378        owner: &str,
379        repository: &str,
380        manifest: &Manifest,
381        source: &str,
382        headers: Option<&indexmap::IndexMap<String, String>>,
383        upgrade: bool,
384    ) -> Result<bool, super::super::Error> {
385        // 1. Platform match (no disk touches).
386        let Some(platform) = super::Platform::current() else {
387            return Ok(false);
388        };
389        let Some(binary_name) = manifest.binaries.get(platform) else {
390            return Ok(false);
391        };
392
393        let plugins_dir = self.plugins_dir();
394        let plugin_dir = self.plugin_dir(repository);
395        let binary_path = self.plugin_binary_path(repository);
396        let viewer_dir = plugin_dir.join("viewer");
397        let manifest_path = plugins_dir.join(format!("{repository}.json"));
398
399        // 2. Existing-install check: the manifest sibling file is the
400        //    source of truth for "this plugin is installed."
401        let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
402        if manifest_exists && !upgrade {
403            return Err(super::InstallError::AlreadyInstalled {
404                repository: repository.to_string(),
405            }
406            .into());
407        }
408
409        // 3. Clean prior install data when --upgrade. Best-effort: any
410        //    delete failure surfaces later as a write-phase error
411        //    (e.g. ManifestPersist) if the artifact is truly stuck.
412        //    Extra entries under <plugin_dir>/ are untouched.
413        if upgrade {
414            let _ = tokio::fs::remove_file(&manifest_path).await;
415            let _ = tokio::fs::remove_file(&binary_path).await;
416            let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
417        }
418
419        // 4. Network phase: fetch everything into memory before any
420        //    disk write. A network failure here leaves the disk in
421        //    whatever state step 3 left it in (empty if upgrade,
422        //    unchanged if fresh install — since step 2's check would
423        //    have refused).
424        let http = reqwest::Client::new();
425        let bin_bytes: Vec<u8> = {
426            let binary_url = format!(
427                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{binary_name}",
428                version = manifest.version,
429            );
430            let resp = http
431                .get(&binary_url)
432                .headers(build_headers(headers)?)
433                .send()
434                .await
435                .map_err(super::InstallError::BinaryRequest)?;
436            let status = resp.status();
437            if !status.is_success() {
438                return Err(super::InstallError::BinaryBadStatus {
439                    code: status,
440                    url: binary_url,
441                }
442                .into());
443            }
444            resp.bytes()
445                .await
446                .map_err(super::InstallError::BinaryResponse)?
447                .to_vec()
448        };
449
450        let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) = &manifest.viewer_zip {
451            let viewer_url = format!(
452                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
453                version = manifest.version,
454            );
455            let resp = http
456                .get(&viewer_url)
457                .headers(build_headers(headers)?)
458                .send()
459                .await
460                .map_err(super::InstallError::ViewerZipRequest)?;
461            let status = resp.status();
462            if !status.is_success() {
463                return Err(super::InstallError::ViewerZipBadStatus {
464                    code: status,
465                    url: viewer_url,
466                }
467                .into());
468            }
469            Some(
470                resp.bytes()
471                    .await
472                    .map_err(super::InstallError::ViewerZipResponse)?
473                    .to_vec(),
474            )
475        } else {
476            None
477        };
478
479        let manifest_bytes: Vec<u8> = {
480            let bundle = ManifestWithNameAndSource {
481                name: repository.to_string(),
482                manifest: manifest.clone(),
483                source: source.to_string(),
484            };
485            serde_json::to_vec_pretty(&bundle).map_err(super::InstallError::ManifestSerialize)?
486        };
487
488        // 5. Plugin dir setup. Idempotent — preserves any pre-existing
489        //    "extra data" the plugin's runtime created.
490        tokio::fs::create_dir_all(&plugin_dir)
491            .await
492            .map_err(|e| super::InstallError::PluginDirCreate(plugin_dir.clone(), e))?;
493
494        // 6. Concurrent write phase via try_join!. Three branches fan
495        //    out, short-circuit on first error.
496        tokio::try_join!(
497            write_binary_branch(binary_path, bin_bytes),
498            write_viewer_branch(viewer_dir, zip_bytes),
499            write_manifest_branch(manifest_path, manifest_bytes),
500        )?;
501
502        Ok(true)
503    }
504}
505
506#[cfg(feature = "http")]
507async fn write_binary_branch(
508    binary_path: PathBuf,
509    bytes: Vec<u8>,
510) -> Result<(), super::InstallError> {
511    tokio::fs::write(&binary_path, &bytes)
512        .await
513        .map_err(|e| super::InstallError::BinaryWrite(binary_path.clone(), e))?;
514    #[cfg(unix)]
515    {
516        use std::os::unix::fs::PermissionsExt;
517        let perms = std::fs::Permissions::from_mode(0o755);
518        tokio::fs::set_permissions(&binary_path, perms)
519            .await
520            .map_err(|e| super::InstallError::Chmod(binary_path.clone(), e))?;
521    }
522    Ok(())
523}
524
525#[cfg(feature = "http")]
526async fn write_viewer_branch(
527    viewer_dir: PathBuf,
528    zip_bytes: Option<Vec<u8>>,
529) -> Result<(), super::InstallError> {
530    let Some(bytes) = zip_bytes else {
531        return Ok(());
532    };
533    tokio::fs::create_dir_all(&viewer_dir)
534        .await
535        .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e.to_string()))?;
536    let viewer_dir_for_blocking = viewer_dir.clone();
537    tokio::task::spawn_blocking(move || {
538        let cursor = std::io::Cursor::new(bytes);
539        let mut archive = zip::ZipArchive::new(cursor)
540            .map_err(|e| format!("zip archive open: {e}"))?;
541        archive
542            .extract(&viewer_dir_for_blocking)
543            .map_err(|e| format!("extract: {e}"))
544    })
545    .await
546    .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), format!("join: {e}")))?
547    .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e))?;
548    Ok(())
549}
550
551#[cfg(feature = "http")]
552async fn write_manifest_branch(
553    manifest_path: PathBuf,
554    bytes: Vec<u8>,
555) -> Result<(), super::InstallError> {
556    tokio::fs::write(&manifest_path, &bytes)
557        .await
558        .map_err(|e| super::InstallError::ManifestPersist(manifest_path.clone(), e))
559}
560
561/// Reject reserved plugin repository names before any install
562/// side-effect. `objectiveai` (case-insensitive) is reserved because
563/// the viewer uses it as the Tauri channel name for built-in events;
564/// a plugin with that repository name would shadow them.
565#[cfg(feature = "http")]
566fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
567    if repository.eq_ignore_ascii_case("objectiveai") {
568        return Err(super::InstallError::ReservedRepositoryName {
569            repository: repository.to_string(),
570        });
571    }
572    Ok(())
573}
574
575/// Convention: the raw-GitHub URL we'd fetch `objectiveai.json` from
576/// for a given (owner, repository, optional commit sha). Defaults to
577/// `HEAD` when no commit is supplied. Lifted out so the cli and the
578/// SDK's own `install_plugin` wrapper share one source of truth.
579pub fn raw_manifest_url(owner: &str, repository: &str, commit_sha: Option<&str>) -> String {
580    let reference = commit_sha.unwrap_or("HEAD");
581    format!(
582        "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
583    )
584}
585
586#[cfg(feature = "http")]
587pub(super) fn build_headers(
588    headers: Option<&indexmap::IndexMap<String, String>>,
589) -> Result<reqwest::header::HeaderMap, super::InstallError> {
590    let mut out = reqwest::header::HeaderMap::new();
591    let Some(h) = headers else {
592        return Ok(out);
593    };
594    for (k, v) in h {
595        let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
596            super::InstallError::InvalidHeaderName {
597                name: k.clone(),
598                reason: e.to_string(),
599            }
600        })?;
601        let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
602            super::InstallError::InvalidHeaderValue {
603                name: k.clone(),
604                reason: e.to_string(),
605            }
606        })?;
607        out.insert(name, value);
608    }
609    Ok(out)
610}