Skip to main content

harmont_cli/plugin/
host.rs

1//! Thin wrapper around `extism::Plugin` instances loaded into a
2//! per-plugin pool. Concurrent invocations from chain tasks acquire
3//! a pool slot rather than blocking on a single plugin instance.
4
5// Pedantic-bucket nags that don't add safety on this module:
6// - `missing_errors_doc`: every public fn here returns `anyhow::Result`
7//   with a context message; an `# Errors` section would just restate it.
8// - `significant_drop_tightening` on `call_capability`: the `PoolGuard`
9//   intentionally lives until after `serde_json::from_slice` returns,
10//   because the `&[u8]` we just borrowed from the plugin's memory
11//   only stays valid while the plugin instance is in scope.
12#![allow(clippy::missing_errors_doc, clippy::significant_drop_tightening)]
13
14use std::path::PathBuf;
15
16use anyhow::{Context, Result};
17use hm_plugin_protocol::PluginManifest;
18
19use super::pool::PluginPool;
20use crate::error::HmError;
21
22#[derive(Debug)]
23pub struct LoadedPlugin {
24    pub manifest: PluginManifest,
25    /// Path the plugin was loaded from. `None` if loaded from embedded
26    /// bytes (`include_bytes!`).
27    pub source: Option<PathBuf>,
28    pool: PluginPool,
29}
30
31impl LoadedPlugin {
32    /// Build a plugin from an on-disk `.wasm` file. The Extism manifest
33    /// disables WASI filesystem access entirely (host-mediated reads
34    /// only).
35    ///
36    /// Two-phase load: instantiate with no allowed hosts, read the
37    /// plugin's [`PluginManifest`], then rebuild the pool with the
38    /// allowlist the plugin declared. The throwaway pool is dropped
39    /// before the real one is built.
40    pub fn from_file(path: PathBuf, max_instances: usize) -> Result<Self> {
41        let probe = PluginPool::from_file(path.clone(), max_instances)
42            .with_context(|| format!("load plugin from {}", path.display()))?;
43        let manifest = read_manifest(&probe)?;
44        drop(probe);
45        let pool = PluginPool::from_file_with_hosts(
46            path.clone(),
47            max_instances,
48            manifest.allowed_hosts.clone(),
49        )
50        .with_context(|| format!("reload plugin from {} with allowed_hosts", path.display()))?;
51        Ok(Self {
52            manifest,
53            source: Some(path),
54            pool,
55        })
56    }
57
58    /// Build a plugin from embedded bytes (used for in-tree builtins).
59    ///
60    /// Two-phase load: see [`LoadedPlugin::from_file`].
61    pub fn from_bytes(bytes: &'static [u8], max_instances: usize) -> Result<Self> {
62        let probe = PluginPool::from_bytes(bytes, max_instances).context("load embedded plugin")?;
63        let manifest = read_manifest(&probe)?;
64        drop(probe);
65        let pool =
66            PluginPool::from_bytes_with_hosts(bytes, max_instances, manifest.allowed_hosts.clone())
67                .context("reload embedded plugin with allowed_hosts")?;
68        Ok(Self {
69            manifest,
70            source: None,
71            pool,
72        })
73    }
74
75    /// Call a capability export. Acquires a pool slot for the duration
76    /// of the call, then returns it. Generic over the input/output
77    /// types.
78    ///
79    /// The `Send + Sync` bound on `I` is required so the returned
80    /// future is `Send` — chain tasks await this future across a
81    /// `tokio::spawn` boundary.
82    pub async fn call_capability<I, O>(&self, export: &str, input: &I) -> Result<O>
83    where
84        I: serde::Serialize + Sync,
85        O: serde::de::DeserializeOwned,
86    {
87        let in_bytes = serde_json::to_vec(input).context("serialise capability input")?;
88        let mut guard = self
89            .pool
90            .acquire()
91            .await
92            .context("acquire plugin instance")?;
93        // Set the per-plugin thread-local so `hm_kv_*` host fns can
94        // resolve `KvScope::Plugin` to the right on-disk file.
95        crate::plugin::host_fns::set_current_plugin_name(self.manifest.name.clone());
96        let call_result = guard.plugin().call::<Vec<u8>, &[u8]>(export, in_bytes);
97        crate::plugin::host_fns::clear_current_plugin_name();
98        let out_bytes = call_result.map_err(|e| HmError::PluginPanic {
99            name: self.manifest.name.clone(),
100            capability: export.to_string(),
101            message: e.to_string(),
102        })?;
103        serde_json::from_slice(out_bytes).context("decode capability output")
104    }
105}
106
107/// Test helper: synthesises a `SubcommandInput` shaped JSON value for
108/// the `host_fn_probe` fixture and any other integration test that
109/// needs a minimal valid input to `hm_subcommand_run`.
110///
111/// `#[doc(hidden)]` because this is not part of the production public
112/// API; it exists so `tests/*.rs` integration tests (which see only
113/// the public surface) can call into it without a separate feature
114/// flag.
115#[doc(hidden)]
116#[must_use]
117pub fn dummy_subcommand_input() -> serde_json::Value {
118    serde_json::json!({
119        "verb_path": ["fixture-probe"],
120        "args": {},
121        "env": {}
122    })
123}
124
125/// Read the manifest from a freshly-instantiated plugin. Runs the
126/// `hm_manifest` export and decodes the JSON.
127///
128/// Loading happens synchronously from startup paths (`hm version`,
129/// `hm plugin list`) as well as from inside an existing tokio runtime
130/// (`orchestrator::scheduler::run`). Use the current handle if
131/// present; otherwise spin up a small single-threaded runtime.
132fn read_manifest(pool: &PluginPool) -> Result<PluginManifest> {
133    let task = async {
134        let mut guard = pool.acquire().await?;
135        let bytes = guard
136            .plugin()
137            .call::<&str, &[u8]>("hm_manifest", "")
138            .context("call hm_manifest")?
139            .to_vec();
140        let manifest: PluginManifest =
141            serde_json::from_slice(&bytes).context("decode hm_manifest output")?;
142        Ok::<PluginManifest, anyhow::Error>(manifest)
143    };
144    if let Ok(handle) = tokio::runtime::Handle::try_current() {
145        tokio::task::block_in_place(|| handle.block_on(task))
146    } else {
147        // No runtime; spin up a tiny one. Happens only when
148        // `LoadedPlugin::from_*` is called from a truly synchronous
149        // entry point (none in production today — kept for robustness
150        // and unit tests that drive `LoadedPlugin` directly).
151        let rt = tokio::runtime::Builder::new_current_thread()
152            .enable_all()
153            .build()
154            .context("build adhoc tokio runtime for manifest read")?;
155        rt.block_on(task)
156    }
157}