Skip to main content

ferridriver_script/bindings/
plugins.rs

1//! Native plugin surface.
2//!
3//! A plugin/extension file is rolldown-bundled to `QuickJS` bytecode
4//! once at startup. Loading + evaluating that bytecode in a session runs
5//! its top-level `defineTool(...)` (and any `Given/When/Then`) calls,
6//! registering directly into the shared Rust `ExtensionRegistry`.
7//! `defineTool` is the only tool-registration surface — no
8//! `globalThis.exports`, no legacy shapes.
9//!
10//! There is **no synthesized JS and no `globalThis.__*`**: the
11//! `plugins.<name>` callable is a native Rust closure that restores the
12//! handler from the registry, builds `{ args, page, context, request,
13//! commands }` with the Object API, applies the handler and returns its
14//! promise — the exact mechanism BDD steps use (`invoke_step`). The
15//! `commands` binding and the `allow.net` host guard are native Rust
16//! (`PluginCommandsJs`, `HttpClientJs::with_net`); the allow-list
17//! is checked in Rust before any shell/network I/O.
18
19use std::collections::BTreeMap;
20use std::future::Future;
21use std::sync::Arc;
22use std::time::Duration;
23
24use rquickjs::function::{Func, Opt};
25use rquickjs::promise::{MaybePromise, Promised};
26use rquickjs::{Ctx, IntoJs, JsLifetime, Module, Object, Value, class::Class, class::Trace};
27
28use super::bdd::{tool_dispatch, tool_names};
29use super::http_client::HttpClientJs;
30use crate::bindings::convert::{json_to_js, serde_from_js};
31use crate::command_spec::CommandSpec;
32use crate::engine::SessionProcsUd;
33use crate::error::ScriptError;
34use crate::session_procs::{self, SessionProcs};
35
36/// One plugin file handed to the engine at `install_plugins` time:
37/// just its precompiled bytecode. Tool names + capabilities are read
38/// from the manifest the module registers, not carried here.
39#[derive(Debug, Clone)]
40pub struct PluginBinding {
41  /// Precompiled `QuickJS` bytecode of the rolldown-bundled module,
42  /// produced once at startup by
43  /// [`crate::bundle::compile_and_extract_plugins`]. `Module::load`ed
44  /// per session — no per-session parse, no source retained.
45  pub bytecode: Arc<[u8]>,
46}
47
48/// The `commands` object a plugin handler receives. Holds this tool's
49/// declared command set (default-deny — a handler cannot reach a name
50/// its manifest did not declare, nor another tool's) plus the session's
51/// durable persistent-process registry.
52///
53/// - `run(name, vars?)` — one-shot: resolve `${vars}` strictly, execute
54///   (argv or `sh -c` per the spec), bounded by timeout + output cap,
55///   shaped per the declared output mode.
56/// - `start(name, vars?)` / `status(name)` / `stop(name)` — persistent:
57///   manage a long-running process whose lifetime is the session's.
58#[derive(JsLifetime, Trace)]
59#[rquickjs::class(rename = "PluginCommands")]
60pub struct PluginCommandsJs {
61  #[qjs(skip_trace)]
62  allowed: Arc<BTreeMap<String, CommandSpec>>,
63  #[qjs(skip_trace)]
64  procs: Option<Arc<SessionProcs>>,
65}
66
67impl PluginCommandsJs {
68  fn cmd_err(verb: &'static str, msg: impl std::fmt::Display) -> rquickjs::Error {
69    rquickjs::Error::new_from_js_message(verb, "Error", msg.to_string())
70  }
71
72  fn spec(&self, verb: &'static str, name: &str) -> rquickjs::Result<CommandSpec> {
73    self.allowed.get(name).cloned().ok_or_else(|| {
74      Self::cmd_err(
75        verb,
76        format!("\"{name}\" is not in the commands allow-list for this tool"),
77      )
78    })
79  }
80
81  fn vars_of<'js>(ctx: &Ctx<'js>, vars: Opt<Value<'js>>) -> rquickjs::Result<BTreeMap<String, serde_json::Value>> {
82    match vars.0 {
83      Some(v) if !v.is_undefined() && !v.is_null() => serde_from_js(ctx, v),
84      _ => Ok(BTreeMap::new()),
85    }
86  }
87
88  fn registry(&self, verb: &'static str) -> rquickjs::Result<&Arc<SessionProcs>> {
89    self
90      .procs
91      .as_ref()
92      .ok_or_else(|| Self::cmd_err(verb, "persistent commands are unavailable in this context"))
93  }
94}
95
96#[rquickjs::methods]
97impl PluginCommandsJs {
98  /// One-shot: run to completion and return shaped stdout.
99  #[qjs(rename = "run")]
100  pub async fn run<'js>(&self, ctx: Ctx<'js>, name: String, vars: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
101    let spec = self.spec("commands.run", &name)?;
102    let vars_map = Self::vars_of(&ctx, vars)?;
103    let resolved = spec
104      .resolve(&vars_map)
105      .map_err(|m| Self::cmd_err("commands.run", format!("{name}: {m}")))?;
106    let value = Box::pin(session_procs::run_oneshot(&resolved))
107      .await
108      .map_err(|m| Self::cmd_err("commands.run", format!("{name}: {m}")))?;
109    json_to_js(&ctx, &value)
110  }
111
112  /// Persistent: start (idempotent if already running). Returns
113  /// `{ name, pid }`.
114  #[qjs(rename = "start")]
115  pub fn start<'js>(&self, ctx: Ctx<'js>, name: String, vars: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
116    let spec = self.spec("commands.start", &name)?;
117    let vars_map = Self::vars_of(&ctx, vars)?;
118    let resolved = spec
119      .resolve(&vars_map)
120      .map_err(|m| Self::cmd_err("commands.start", format!("{name}: {m}")))?;
121    let pid = self
122      .registry("commands.start")?
123      .start(&name, &resolved)
124      .map_err(|m| Self::cmd_err("commands.start", format!("{name}: {m}")))?;
125    json_to_js(&ctx, &serde_json::json!({ "name": name, "pid": pid }))
126  }
127
128  /// Persistent: running?/exit code + the buffered stdout/stderr tail.
129  #[qjs(rename = "status")]
130  pub fn status<'js>(&self, ctx: Ctx<'js>, name: String) -> rquickjs::Result<Value<'js>> {
131    let value = self
132      .registry("commands.status")?
133      .status(&name)
134      .map_err(|m| Self::cmd_err("commands.status", m))?;
135    json_to_js(&ctx, &value)
136  }
137
138  /// Persistent: kill the process group.
139  #[qjs(rename = "stop")]
140  pub fn stop(&self, name: String) -> rquickjs::Result<()> {
141    self
142      .registry("commands.stop")?
143      .stop(&name)
144      .map_err(|m| Self::cmd_err("commands.stop", m))
145  }
146}
147
148fn rq(e: &ScriptError) -> rquickjs::Error {
149  rquickjs::Error::new_from_js_message("plugins", "Error", e.message.clone())
150}
151
152/// Install loaded plugins: load+evaluate each file's bytecode (which
153/// registers its tools into the shared registry, native or legacy
154/// shape), then expose every registered tool as a native
155/// `plugins.<name>` callable.
156pub fn install_plugins(ctx: &Ctx<'_>, files: &[PluginBinding]) -> rquickjs::Result<()> {
157  for file in files {
158    // SAFETY: `file.bytecode` was produced by `Module::write` in THIS
159    // process and this exact rquickjs/QuickJS build with native
160    // endianness (see `compile_and_extract_plugins`) and is never
161    // persisted — the precondition `Module::load` documents.
162    #[allow(unsafe_code)]
163    let module = unsafe { Module::load(ctx.clone(), &file.bytecode) }?;
164    // Evaluating the module runs its top-level `defineTool(...)` /
165    // `Given(...)` calls, registering directly into the extension
166    // registry. No `globalThis.exports`, no post-eval ingest.
167    let (_evaluated, _promise) = module.eval()?;
168  }
169
170  let names = tool_names(ctx).map_err(|e| rq(&e))?;
171  let plugins_obj = Object::new(ctx.clone())?;
172  for (idx, name) in names.into_iter().enumerate() {
173    // The closure forwards into a generic fn so `Ctx`/`Value`/return
174    // share one `'js` (an inline closure with `<'_>` would give each its
175    // own lifetime and `Function::call`'s result could not be returned).
176    let f = Func::from(move |ctx, call_args| dispatch_tool(ctx, idx, call_args));
177    plugins_obj.set(name.as_str(), f)?;
178  }
179  ctx.globals().set("plugins", plugins_obj)?;
180  Ok(())
181}
182
183/// Native `plugins.<name>(args)` body: restore the tool's handler from
184/// the registry, build `{ args, page, context, request, commands }` via
185/// the Object API (per-tool `commands` allow-list + optional net-guarded
186/// `request`, both Rust-enforced), apply the handler and await its
187/// result. When the manifest declared `timeoutMs`, the handler is raced
188/// against that bound natively (same mechanism `invoke_step` uses) so
189/// every caller — promoted MCP tool, `invoke_plugin`, or another
190/// extension calling `plugins.<name>` — is covered, not just the MCP
191/// entry point. Returns a JS promise; the caller `await`s it. No
192/// synthesized JS.
193fn dispatch_tool<'js>(
194  ctx: Ctx<'js>,
195  idx: usize,
196  call_args: Opt<Value<'js>>,
197) -> Promised<impl std::future::Future<Output = rquickjs::Result<Value<'js>>> + 'js> {
198  Promised::from(async move {
199    let d = tool_dispatch(&ctx, idx).map_err(|e| rq(&e))?;
200
201    let arg = Object::new(ctx.clone())?;
202    let undef = Value::new_undefined(ctx.clone());
203    arg.set("args", call_args.0.unwrap_or_else(|| undef.clone()))?;
204
205    let g = ctx.globals();
206    arg.set("page", g.get::<_, Value<'js>>("page").unwrap_or_else(|_| undef.clone()))?;
207    arg.set(
208      "context",
209      g.get::<_, Value<'js>>("context").unwrap_or_else(|_| undef.clone()),
210    )?;
211
212    // The tool's declared `allow.net` (empty ⇒ unrestricted). Used for
213    // BOTH the net-guarded `request` wrapper AND the `fetch` policy
214    // bracket below — one allow-list, both HTTP entry points.
215    let net_policy: Option<Arc<[String]>> = if d.allowed_net.is_empty() {
216      None
217    } else {
218      Some(Arc::from(d.allowed_net.as_slice()))
219    };
220
221    // `request`: pass through unless the tool declared `allow.net`, in
222    // which case hand it a net-restricted wrapper over the SAME underlying
223    // context (host check enforced natively in `HttpClientJs`).
224    let req_val: Value<'js> = g.get("request").unwrap_or_else(|_| undef.clone());
225    let request_out: Value<'js> = match net_policy.clone() {
226      Some(net) => match Class::<HttpClientJs>::from_value(&req_val) {
227        Ok(cls) => {
228          let inner = cls.borrow().inner_arc();
229          let guarded = Class::instance(ctx.clone(), HttpClientJs::with_net(inner, net))?;
230          guarded.into_js(&ctx)?
231        },
232        Err(_) => req_val,
233      },
234      None => req_val,
235    };
236    arg.set("request", request_out)?;
237
238    let procs = ctx.userdata::<SessionProcsUd>().map(|u| u.0.clone());
239    let commands = Class::instance(
240      ctx.clone(),
241      PluginCommandsJs {
242        allowed: Arc::new(d.allowed_commands),
243        procs,
244      },
245    )?;
246    arg.set("commands", commands)?;
247
248    // The same `allow.net` must also bind the global `fetch` (a facade
249    // over the same core). `fetch` reads the active policy from VM
250    // userdata; bracket every poll of THIS handler's future so the cell
251    // holds this tool's list whenever its continuation runs and is
252    // restored to the caller's value otherwise — correct under nesting
253    // (a tool calling `plugins.other`) and concurrent interleaving
254    // (`Promise.all([plugins.a(), plugins.b()])`) because the swap and
255    // the synchronous `fetch` guard both run within a single poll on the
256    // single QuickJS thread.
257    let policy_cell = ctx
258      .userdata::<crate::bindings::fetch::NetPolicyUd>()
259      .map(|u| u.0.clone());
260
261    let handler = d.handler;
262    let timeout_ms = d.timeout_ms;
263    let inner = async move {
264      let mp: MaybePromise<'js> = handler.call((arg,))?;
265      let fut = mp.into_future::<Value<'js>>();
266      match timeout_ms {
267        Some(t) => match tokio::time::timeout(Duration::from_millis(t), fut).await {
268          Ok(r) => r,
269          Err(_) => Err(rquickjs::Error::new_from_js_message(
270            "plugins",
271            "Error",
272            format!("tool timed out after {t}ms"),
273          )),
274        },
275        None => fut.await,
276      }
277    };
278
279    match policy_cell {
280      None => inner.await,
281      Some(cell) => {
282        let mut inner = std::pin::pin!(inner);
283        std::future::poll_fn(move |cx2| {
284          let prev = cell.swap(net_policy.clone());
285          let r = inner.as_mut().poll(cx2);
286          cell.swap(prev);
287          r
288        })
289        .await
290      },
291    }
292  })
293}