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}