Skip to main content

ferridriver_script/
engine.rs

1//! `ScriptEngine` + `Session`: a sandboxed `QuickJS` runtime/context.
2//!
3//! [`ScriptEngine::run`] is the one-shot path (fresh VM, library/test
4//! convenience). [`Session`] is the persistent path: one `QuickJS`
5//! runtime + context reused across many [`Session::execute`] calls so
6//! user `globalThis` state survives between executions REPL-style while
7//! framework bindings refresh each call. The production MCP server keeps
8//! a set of [`Session`]s with a retention policy in
9//! [`crate::session_table::SessionTable`].
10
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
13use std::time::{Duration, Instant};
14
15use rquickjs::function::{Async, Func};
16use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx, Module, Object, Value, async_with};
17
18use crate::console::{ConsoleCapture, strip_ansi};
19use crate::error::{ScriptError, ScriptErrorKind};
20use crate::fs::PathSandbox;
21use crate::result::{ConsoleLevel, ScriptResult};
22use crate::vars::VarsStore;
23
24/// Default console-capture limits.
25pub const DEFAULT_MAX_CONSOLE_ENTRIES: usize = 1_000;
26pub const DEFAULT_MAX_CONSOLE_BYTES: usize = 1_048_576;
27pub const DEFAULT_MAX_CONSOLE_ENTRY_BYTES: usize = 8_192;
28
29/// Default per-script wall-clock timeout (5 minutes).
30pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
31
32/// Default per-script memory quota (256 MiB).
33pub const DEFAULT_MEMORY_LIMIT: usize = 256 * 1024 * 1024;
34
35/// Default per-script JS stack size (1 MiB).
36pub const DEFAULT_STACK_SIZE: usize = 1024 * 1024;
37
38/// Default GC trigger threshold (64 MiB). QuickJS is reference-counted;
39/// the cycle GC otherwise fires adaptively at ~1.5x live size, so an
40/// object-churny automation script (big `evaluate` results, repeated
41/// `ariaSnapshot`/snapshot trees, locator chains) pays recurring
42/// mark-sweep stalls mid-run. Raising the floor lets a typical
43/// short-lived script finish with few/zero cycle-GC passes — the same
44/// lever Amazon LLRT exposes (`LLRT_GC_THRESHOLD_MB`, 20 MiB default).
45/// `default_memory_limit` (256 MiB) remains the hard backstop, and
46/// acyclic garbage is still freed immediately by refcounting, so this
47/// only defers *cycle* collection, not normal frees.
48pub const DEFAULT_GC_THRESHOLD: usize = 64 * 1024 * 1024;
49
50/// Default cap on concurrently-retained persistent session VMs. When a
51/// new session would exceed this, the least-recently-used idle VM is
52/// evicted (its `globalThis` state is discarded; a later call rebuilds).
53pub const DEFAULT_MAX_SESSION_VMS: usize = 64;
54
55/// Default idle TTL: a session VM untouched this long is reaped on the
56/// next `SessionTable::acquire`, independent of cap pressure, so a
57/// long-running server does not pin dead sessions' memory indefinitely.
58pub const DEFAULT_SESSION_IDLE_TTL: Duration = Duration::from_secs(30 * 60);
59
60/// Configuration for the script engine.
61#[derive(Debug, Clone)]
62pub struct ScriptEngineConfig {
63  pub default_timeout: Duration,
64  pub default_memory_limit: usize,
65  pub default_stack_size: usize,
66  /// Cycle-GC trigger threshold in bytes. See [`DEFAULT_GC_THRESHOLD`].
67  pub default_gc_threshold: usize,
68  pub max_console_entries: usize,
69  pub max_console_bytes: usize,
70  pub max_console_entry_bytes: usize,
71  /// Upper bound on persistent session VMs kept warm at once.
72  pub max_session_vms: usize,
73  /// Idle TTL for a session VM. `None` disables time-based reaping (only
74  /// the `max_session_vms` LRU cap applies).
75  pub session_idle_ttl: Option<Duration>,
76}
77
78impl Default for ScriptEngineConfig {
79  fn default() -> Self {
80    Self {
81      default_timeout: DEFAULT_TIMEOUT,
82      default_memory_limit: DEFAULT_MEMORY_LIMIT,
83      default_stack_size: DEFAULT_STACK_SIZE,
84      default_gc_threshold: DEFAULT_GC_THRESHOLD,
85      max_console_entries: DEFAULT_MAX_CONSOLE_ENTRIES,
86      max_console_bytes: DEFAULT_MAX_CONSOLE_BYTES,
87      max_console_entry_bytes: DEFAULT_MAX_CONSOLE_ENTRY_BYTES,
88      max_session_vms: DEFAULT_MAX_SESSION_VMS,
89      session_idle_ttl: Some(DEFAULT_SESSION_IDLE_TTL),
90    }
91  }
92}
93
94/// Per-call overrides for a single `run` invocation.
95#[derive(Debug, Clone, Default)]
96pub struct RunOptions {
97  pub timeout: Option<Duration>,
98  pub memory_limit: Option<usize>,
99  pub stack_size: Option<usize>,
100  pub gc_threshold: Option<usize>,
101}
102
103/// Which host is running the extension/registry. Exposed to JS as the
104/// native global `ferridriver.host` ("mcp" | "bdd" | "script") so one
105/// extension file can branch its contributions — e.g. only `defineTool`
106/// under MCP, only `Given/When/Then` under the test runner — without any
107/// runtime cost (a single string set once per session).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum ExtensionHost {
110  /// MCP server (`ferridriver mcp`) — consumes `defineTool` tools.
111  Mcp,
112  /// BDD test runner (`ferridriver bdd`) — consumes step/hook defs.
113  Bdd,
114  /// Ad-hoc script (`ferridriver run` / `run_script`).
115  #[default]
116  Script,
117}
118
119impl ExtensionHost {
120  #[must_use]
121  pub fn as_str(self) -> &'static str {
122    match self {
123      Self::Mcp => "mcp",
124      Self::Bdd => "bdd",
125      Self::Script => "script",
126    }
127  }
128}
129
130/// Per-call execution context holding session-level state the script reaches
131/// via globals (`vars`, `fs`, `artifacts`, and the optional browser bindings
132/// `page` / `context` / `request`). A `None` entry skips installation of
133/// the matching global so pure-compute scripts don't need the extra
134/// infrastructure.
135#[derive(Clone)]
136pub struct RunContext {
137  pub vars: Arc<dyn VarsStore>,
138  pub sandbox: Arc<PathSandbox>,
139  /// Optional dedicated output directory, exposed to scripts as `artifacts`.
140  /// Typically `.ferridriver/artifacts/` alongside `script_root`.
141  pub artifacts: Option<Arc<PathSandbox>>,
142  pub page: Option<Arc<ferridriver::Page>>,
143  pub browser_context: Option<Arc<ferridriver::context::ContextRef>>,
144  pub request: Option<Arc<ferridriver::http_client::HttpClient>>,
145  /// Optional root `Browser` handle exposed as the `browser` global.
146  /// Scripts use it for
147  /// `browser.newContext(BrowserContextOptions)` — the natural
148  /// Playwright entry point that §4.1's options bag attaches to.
149  pub browser: Option<Arc<ferridriver::Browser>>,
150  /// Plugin bindings to install on the `plugins` global. Empty means no
151  /// `plugins` global is exposed beyond the singleton commands runner.
152  pub plugins: Vec<crate::bindings::PluginBinding>,
153  /// When true, ES module imports use normal filesystem resolution
154  /// instead of the `PathSandbox`-rooted loader. Intended for trusted
155  /// first-party code (BDD step files run from the user's own CLI), so
156  /// step files can `import './helpers.js'` from anywhere on disk. The
157  /// MCP / `run_script` path leaves this `false` and stays sandboxed.
158  pub trusted_modules: bool,
159  /// Which host is driving this session — surfaced to JS as
160  /// `ferridriver.host`. Defaults to [`ExtensionHost::Script`].
161  pub host: ExtensionHost,
162  /// Opt-in sandbox relaxations resolved from config (the env
163  /// allow-list). Default = fully locked down.
164  pub caps: ScriptCaps,
165}
166
167/// Resolved, ready-to-install sandbox relaxations. Built by the host
168/// (MCP/CLI/BDD) from `ferridriver_config::ScriptingConfig`; the engine
169/// only consumes it. Default is the locked-down posture: no env.
170#[derive(Debug, Clone, Default)]
171pub struct ScriptCaps {
172  /// `process.env` contents — already filtered to the operator's
173  /// allow-list intersected with the real environment. Empty ⇒
174  /// `process.env` is an empty object.
175  pub env: std::collections::BTreeMap<String, String>,
176}
177
178impl ScriptCaps {
179  /// Resolve from an operator allow-list: only the named variables, and
180  /// only those actually present in the process environment, are
181  /// captured. A name not in the environment is silently absent (same
182  /// as Node) — it is never invented.
183  #[must_use]
184  pub fn resolve(allow_env: &[String]) -> Self {
185    let env = allow_env
186      .iter()
187      .filter_map(|k| std::env::var(k).ok().map(|v| (k.clone(), v)))
188      .collect();
189    Self { env }
190  }
191}
192
193/// The session's owning [`AsyncContext`], stashed as rquickjs userdata
194/// at [`Session::create`] so bindings that mint a `Page` from script
195/// (`browser.newContext().newPage()`, `locator.page()`, `frame.page()`)
196/// can thread it into `PageJs` — without it, `page.route` /
197/// `page.exposeFunction` cross-task dispatch has no context to re-enter.
198pub(crate) struct SessionAsyncCtx(pub(crate) AsyncContext);
199
200// SAFETY: holds only an owned `AsyncContext` (`'static`; no borrowed
201// JS values), so re-stating the unused `'js` lifetime is sound.
202#[allow(unsafe_code)]
203unsafe impl rquickjs::JsLifetime<'_> for SessionAsyncCtx {
204  type Changed<'to> = SessionAsyncCtx;
205}
206
207/// The session's durable persistent-process registry, stashed as
208/// userdata so `plugins.<name>` dispatch can hand a tool's `commands`
209/// binding the registry without threading it through `RunContext`.
210/// Re-installed (same `Arc`) on every VM (re)build by
211/// [`crate::session_table::BrowserSession::run`], so a persistent
212/// process outlives a VM rebuild but dies with the session record.
213pub(crate) struct SessionProcsUd(pub(crate) std::sync::Arc<crate::session_procs::SessionProcs>);
214
215// SAFETY: holds only an owned `Arc` (`'static`; no borrowed JS values).
216#[allow(unsafe_code)]
217unsafe impl rquickjs::JsLifetime<'_> for SessionProcsUd {
218  type Changed<'to> = SessionProcsUd;
219}
220
221/// Sandboxed `QuickJS` scripting engine.
222pub struct ScriptEngine {
223  config: ScriptEngineConfig,
224}
225
226impl ScriptEngine {
227  #[must_use]
228  pub fn new(config: ScriptEngineConfig) -> Self {
229    Self { config }
230  }
231
232  #[must_use]
233  pub fn config(&self) -> &ScriptEngineConfig {
234    &self.config
235  }
236
237  /// Run a script once in a throwaway VM with bound args. A one-shot
238  /// convenience for library consumers and tests that need no
239  /// continuity; the persistent MCP path uses
240  /// [`crate::session_table::SessionTable`] instead.
241  ///
242  /// `args` is bound as the `args` global (positional) and never
243  /// interpolated into `source` — preventing prompt injection. No state
244  /// survives the call.
245  pub async fn run(
246    &self,
247    source: &str,
248    args: &[serde_json::Value],
249    options: RunOptions,
250    context: RunContext,
251  ) -> ScriptResult {
252    match Session::create(self.config.clone(), &context).await {
253      Ok(session) => session.execute(source, args, options, &context).await.result,
254      Err(e) => ScriptResult::err(e, 0, Vec::new()),
255    }
256  }
257}
258
259/// Outcome of one [`Session::execute`]: the script result plus whether
260/// the VM was left in a state the caller must discard before the next
261/// execution. Poisoning means the interpreter was force-halted mid-run
262/// (timeout interrupt) or hit an allocation fault — a plain JS `throw`
263/// is NOT poisoning and leaves session state intact.
264#[derive(Debug)]
265pub struct SessionRun {
266  pub result: ScriptResult,
267  pub poisoned: bool,
268}
269
270/// A persistent `QuickJS` runtime + context reused across many script
271/// executions for one logical session.
272///
273/// User state on `globalThis` (and `var` / `function` declarations,
274/// which hoist to the global object) survives across [`execute`] calls
275/// REPL-style. Top-level `let` / `const` inside a script are scoped to
276/// the async wrapper of that single call and do NOT persist — assign to
277/// `globalThis` for continuity. Framework bindings (`page`, `context`,
278/// `request`, `browser`, `vars`, `fs`, `artifacts`, `console`, `args`)
279/// are reinstalled every call so they always reflect current session
280/// state. Plugin bindings are installed once at creation.
281///
282/// [`execute`]: Session::execute
283pub struct Session {
284  runtime: AsyncRuntime,
285  ctx: AsyncContext,
286  config: ScriptEngineConfig,
287  default_request: Arc<ferridriver::http_client::HttpClient>,
288  /// Last resource limits pushed to `runtime`. `set_memory_limit` /
289  /// `set_max_stack_size` / `set_gc_threshold` each take the runtime's
290  /// async lock; re-pushing identical values every `execute` is pure
291  /// overhead on a warm persistent session that runs many small
292  /// scripts (the MCP path). Skip the setter when the value is
293  /// unchanged.
294  applied: AppliedLimits,
295}
296
297/// Currently-applied runtime limits, so `execute` can skip redundant
298/// `AsyncRuntime` setter calls.
299struct AppliedLimits {
300  memory: AtomicUsize,
301  stack: AtomicUsize,
302  gc: AtomicUsize,
303}
304
305impl Session {
306  /// Build the persistent VM: runtime, resource limits, sandbox-rooted
307  /// module loader, context, and one-time plugin install. The module
308  /// loader is bound to `context.sandbox` for the VM's lifetime, so a
309  /// session must always be driven with the same `script_root`.
310  pub async fn create(config: ScriptEngineConfig, context: &RunContext) -> Result<Self, ScriptError> {
311    let runtime = AsyncRuntime::new().map_err(|e| ScriptError::internal(format!("rquickjs runtime init: {e}")))?;
312
313    runtime.set_memory_limit(config.default_memory_limit).await;
314    runtime.set_max_stack_size(config.default_stack_size).await;
315    // Defer cycle-GC so short automation scripts don't mark-sweep
316    // mid-run (LLRT-style). Refcounting still frees acyclic garbage
317    // immediately; memory_limit is the hard cap.
318    runtime.set_gc_threshold(config.default_gc_threshold).await;
319
320    // Module loader rooted at the sandbox — lets scripts `import './x.js'`.
321    // Resolver and loader both check containment; rquickjs's built-in
322    // ScriptLoader is replaced with our sandboxed pair so a rogue import
323    // can't escape `script_root`. Bound once: the sandbox is stable for
324    // the session's lifetime.
325    if context.trusted_modules {
326      // Trusted first-party code (BDD step files): normal filesystem
327      // ESM resolution so shared `import './helpers.js'` works from
328      // anywhere, not only under the sandbox root.
329      let mut resolver = rquickjs::loader::FileResolver::default();
330      resolver.add_path(".");
331      resolver.add_path(context.sandbox.root().to_string_lossy().as_ref());
332      runtime
333        .set_loader(resolver, rquickjs::loader::ScriptLoader::default())
334        .await;
335    } else {
336      runtime
337        .set_loader(
338          crate::modules::SandboxResolver::new(context.sandbox.clone()),
339          crate::modules::SandboxLoader::new(context.sandbox.clone()),
340        )
341        .await;
342    }
343
344    let ctx = AsyncContext::full(&runtime)
345      .await
346      .map_err(|e| ScriptError::internal(format!("rquickjs context init: {e}")))?;
347
348    // Plugin bindings are server-global and immutable post-load, so they
349    // install exactly once. The per-tool wrappers dereference
350    // `globalThis.page` / `context` / `request` lazily at invocation,
351    // by which point `execute` has refreshed those bindings.
352    let plugins = context.plugins.clone();
353    // Cloned out of `context` (a `&RunContext`) so the async_with future
354    // owns them rather than borrowing across the await.
355    let vars = context.vars.clone();
356    let sandbox = context.sandbox.clone();
357    let sandbox_root = context.sandbox.root().to_string_lossy().into_owned();
358    let artifacts = context.artifacts.clone();
359    let host = context.host;
360    let caps = context.caps.clone();
361    let ud_ctx = ctx.clone();
362    let install: Result<(), ScriptError> = async_with!(ctx => |ctx| {
363      // Stash the session's AsyncContext so script-minted pages can
364      // thread it into PageJs (route/exposeFunction cross-task
365      // dispatch). A failure here only degrades those to "no async
366      // ctx" (same as before this fix) — never a correctness break.
367      let _ = ctx.store_userdata(SessionAsyncCtx(ud_ctx));
368      // The active-tool net allow-list cell `fetch` reads (resting state
369      // = unrestricted). Stored once per VM so it survives rebuilds and
370      // is present even when no plugin runs; `plugins::dispatch_tool`
371      // swaps it around each net-restricted handler's poll.
372      let _ = ctx.store_userdata(crate::bindings::fetch::NetPolicyUd(
373        crate::bindings::fetch::NetPolicy::default(),
374      ));
375      // Native route-handler registry (context userdata): session-once
376      // so `page.route` works on ANY page (script-launched
377      // `context.newPage()`, not just the MCP-prebound one whose
378      // `install_page` also creates it).
379      crate::bindings::page::ensure_page_callbacks(&ctx);
380      install_runtime_shims(&ctx).map_err(|e| ScriptError::internal(format!("failed to install runtime shims: {e}")))?;
381
382      // Session-stable bindings: install ONCE, not per `execute`. Class
383      // prototypes are idempotent; `vars`/`fs`/`artifacts`/`browser_type`
384      // back onto Arcs that never change for a session's lifetime (the
385      // `SessionTable` slot owns the durable `vars`; the sandbox is
386      // fixed per session). Only per-call-variant handles
387      // (page/context/request/browser/console/args) refresh in `execute`.
388      crate::bindings::define_classes(&ctx)
389        .map_err(|e| ScriptError::internal(format!("failed to define classes: {e}")))?;
390      install_vars(&ctx, vars).map_err(|e| ScriptError::internal(format!("failed to install vars: {e}")))?;
391      install_fs(&ctx, sandbox).map_err(|e| ScriptError::internal(format!("failed to install fs: {e}")))?;
392      crate::bindings::process::install(&ctx, &caps, &sandbox_root)
393        .map_err(|e| ScriptError::internal(format!("failed to install process: {e}")))?;
394      if let Some(artifacts) = artifacts {
395        crate::bindings::install_artifacts(&ctx, artifacts)
396          .map_err(|e| ScriptError::internal(format!("failed to install artifacts: {e}")))?;
397      }
398      crate::bindings::install_browser_type(&ctx)
399        .map_err(|e| ScriptError::internal(format!("failed to install browser_type: {e}")))?;
400
401      // expect() global (Jest value matchers, Playwright web-first
402      // matchers, asymmetric matchers, expect.poll). Session-stable —
403      // class prototypes + factory function are installed once and
404      // survive across `execute` calls.
405      crate::bindings::expect::install_expect(&ctx)
406        .map_err(|e| ScriptError::internal(format!("failed to install expect: {e}")))?;
407
408      // The unified extension registry (userdata) + native contribution
409      // points (`Given`/`When`/`Then`/`defineTool`/...). Must precede
410      // `install_plugins`: evaluating an extension's bytecode registers
411      // its tools/steps through this native surface (`defineTool` /
412      // `Given`...), so the registry must already exist.
413      crate::bindings::install_bdd(&ctx)
414        .map_err(|e| ScriptError::internal(format!("failed to install extension registry: {e}")))?;
415
416      // `ferridriver.host` — the native context flag an extension reads
417      // to branch between MCP and the test runner. One string, set once.
418      let fd = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(format!("ferridriver global: {e}")))?;
419      fd.set("host", host.as_str())
420        .map_err(|e| ScriptError::internal(format!("ferridriver.host: {e}")))?;
421      ctx
422        .globals()
423        .set("ferridriver", fd)
424        .map_err(|e| ScriptError::internal(format!("install ferridriver global: {e}")))?;
425
426      crate::bindings::install_plugins(&ctx, &plugins)
427        .map_err(|e| ScriptError::internal(format!("failed to install plugins: {e}")))
428    })
429    .await;
430    install?;
431
432    let applied = AppliedLimits {
433      memory: AtomicUsize::new(config.default_memory_limit),
434      stack: AtomicUsize::new(config.default_stack_size),
435      gc: AtomicUsize::new(config.default_gc_threshold),
436    };
437    Ok(Self {
438      runtime,
439      ctx,
440      config,
441      default_request: Arc::new(ferridriver::http_client::HttpClient::new(
442        ferridriver::http_client::HttpClientOptions::default(),
443      )),
444      applied,
445    })
446  }
447
448  /// The session's owning [`AsyncContext`]. The BDD core clones this to
449  /// drive registered JS step functions back over the async bridge
450  /// (same mechanism as `page.route` cross-task dispatch).
451  #[must_use]
452  pub fn async_context(&self) -> AsyncContext {
453    self.ctx.clone()
454  }
455
456  /// Stash the session's persistent-process registry into VM userdata
457  /// so plugin `commands` start/status/stop reach it. Idempotent; the
458  /// same `Arc` is re-installed on each VM rebuild (the registry is
459  /// durable session state, the VM is not).
460  pub async fn install_session_procs(&self, procs: std::sync::Arc<crate::session_procs::SessionProcs>) {
461    async_with!(self.ctx => |ctx| {
462      let _ = ctx.store_userdata(SessionProcsUd(procs));
463    })
464    .await;
465  }
466
467  /// Push resource limits to the runtime, skipping any setter whose
468  /// value is unchanged since the last call (avoids the runtime's async
469  /// lock on the warm-session hot path).
470  async fn apply_limits(&self, memory: usize, stack: usize, gc: usize) {
471    if self.applied.memory.swap(memory, Ordering::Relaxed) != memory {
472      self.runtime.set_memory_limit(memory).await;
473    }
474    if self.applied.stack.swap(stack, Ordering::Relaxed) != stack {
475      self.runtime.set_max_stack_size(stack).await;
476    }
477    if self.applied.gc.swap(gc, Ordering::Relaxed) != gc {
478      self.runtime.set_gc_threshold(gc).await;
479    }
480  }
481
482  /// Fresh console capture sized by the session config.
483  fn new_console(&self) -> Arc<ConsoleCapture> {
484    Arc::new(ConsoleCapture::new(
485      self.config.max_console_entries,
486      self.config.max_console_bytes,
487      self.config.max_console_entry_bytes,
488    ))
489  }
490
491  /// Arm the interrupt handler for this call's deadline. The handler fires
492  /// regularly during interpretation; once the deadline passes it halts
493  /// the interpreter. Returns the flag set when a force-halt occurred.
494  async fn arm_timeout(&self, deadline: Instant) -> Arc<AtomicBool> {
495    let timed_out = Arc::new(AtomicBool::new(false));
496    let flag = timed_out.clone();
497    self
498      .runtime
499      .set_interrupt_handler(Some(Box::new(move || {
500        if Instant::now() >= deadline {
501          flag.store(true, Ordering::Relaxed);
502          true
503        } else {
504          false
505        }
506      })))
507      .await;
508    timed_out
509  }
510
511  /// Per-call framework globals (`console`, `page`, `context`, ...).
512  fn globals_install(&self, context: &RunContext, console: &Arc<ConsoleCapture>) -> GlobalsInstall {
513    GlobalsInstall {
514      console: console.clone(),
515      page: context.page.clone(),
516      browser_context: context.browser_context.clone(),
517      request: context.request.clone(),
518      default_request: self.default_request.clone(),
519      browser: context.browser.clone(),
520      async_ctx: self.ctx.clone(),
521    }
522  }
523
524  /// Build the `SessionRun` from an eval result, applying the poison rule:
525  /// a timeout force-halt or an OOM leaves the heap untrustworthy and must
526  /// rebuild the VM; a plain throw / recoverable stack overflow does not.
527  fn finish(
528    &self,
529    eval_result: Result<serde_json::Value, ScriptError>,
530    started: Instant,
531    console: &Arc<ConsoleCapture>,
532    timed_out: &Arc<AtomicBool>,
533    timeout: Duration,
534  ) -> SessionRun {
535    let duration = elapsed_ms(started);
536    let drained = console.drain();
537    match eval_result {
538      Ok(value) => SessionRun {
539        result: ScriptResult::ok(value, duration, drained),
540        poisoned: false,
541      },
542      Err(mut err) => {
543        let timed_out = timed_out.load(Ordering::Relaxed);
544        let oom = is_oom(&err);
545        let poisoned = timed_out || oom;
546        if timed_out {
547          err = ScriptError::timeout(duration, timeout.as_millis() as u64);
548        }
549        SessionRun {
550          result: ScriptResult::err(err, duration, drained),
551          poisoned,
552        }
553      },
554    }
555  }
556
557  /// Apply this call's resource overrides (falling back to session
558  /// defaults), and return the resolved wall-clock timeout.
559  async fn apply_call_limits(&self, options: &RunOptions) -> Duration {
560    self
561      .apply_limits(
562        options.memory_limit.unwrap_or(self.config.default_memory_limit),
563        options.stack_size.unwrap_or(self.config.default_stack_size),
564        options.gc_threshold.unwrap_or(self.config.default_gc_threshold),
565      )
566      .await;
567    options.timeout.unwrap_or(self.config.default_timeout)
568  }
569
570  /// Execute one script against the persistent VM. Framework globals are
571  /// refreshed from `context` first; user `globalThis` state from prior
572  /// executions is preserved.
573  ///
574  /// The source is wrapped in an async IIFE, so top-level `return <value>`
575  /// surfaces as the run result. For ES-module sources (TypeScript,
576  /// `import`/`export`) bundle them first and use [`Self::execute_module`].
577  pub async fn execute(
578    &self,
579    source: &str,
580    args: &[serde_json::Value],
581    options: RunOptions,
582    context: &RunContext,
583  ) -> SessionRun {
584    let started = Instant::now();
585    let console = self.new_console();
586    let timeout = self.apply_call_limits(&options).await;
587    let timed_out = self.arm_timeout(started + timeout).await;
588    let install = self.globals_install(context, &console);
589    let source_owned = source.to_string();
590
591    let eval_result: Result<serde_json::Value, ScriptError> = async_with!(self.ctx => |ctx| {
592      if let Err(e) = install_call_globals(&ctx, args, install) {
593        return Err(ScriptError::internal(format!("failed to install globals: {e}")));
594      }
595
596      let wrapped = wrap_source(&source_owned);
597
598      let promise: rquickjs::Promise<'_> = match ctx.eval(wrapped.as_bytes()) {
599        Ok(v) => v,
600        Err(e) => return Err(caught_to_script_error(rquickjs::CaughtError::from_error(&ctx, e), &source_owned)),
601      };
602
603      let result: Value<'_> = match promise.into_future::<Value<'_>>().await {
604        Ok(v) => v,
605        Err(e) => return Err(caught_to_script_error(rquickjs::CaughtError::from_error(&ctx, e), &source_owned)),
606      };
607
608      Ok(value_to_json(&ctx, result).unwrap_or(serde_json::Value::Null))
609    })
610    .await;
611
612    self.finish(eval_result, started, &console, &timed_out, timeout)
613  }
614
615  /// Execute a precompiled bundled ES module against the persistent VM —
616  /// the TypeScript / `import` / `export` path. Framework globals
617  /// (`args`, `page`, `console`, ...) are installed exactly as for
618  /// [`Self::execute`]; top-level `await` is native to the module.
619  ///
620  /// A module cannot use top-level `return`, so the run's result value is
621  /// the module's `default` export (`null` when it has none). Error
622  /// locations are remapped through the bundle's source map back to the
623  /// original `.ts`/`.js` position.
624  pub async fn execute_module(
625    &self,
626    bundle: &crate::bundle::CompiledBundle,
627    args: &[serde_json::Value],
628    options: RunOptions,
629    context: &RunContext,
630  ) -> SessionRun {
631    let started = Instant::now();
632    let console = self.new_console();
633    let timeout = self.apply_call_limits(&options).await;
634    let timed_out = self.arm_timeout(started + timeout).await;
635    let install = self.globals_install(context, &console);
636    let bytecode = Arc::clone(&bundle.bytecode);
637    let label = bundle.module_name.clone();
638
639    let eval_result: Result<serde_json::Value, ScriptError> = async_with!(self.ctx => |ctx| {
640      if let Err(e) = install_call_globals(&ctx, args, install) {
641        return Err(ScriptError::internal(format!("failed to install globals: {e}")));
642      }
643
644      // SAFETY: `bytecode` was produced by `Module::write` in THIS process
645      // with native endianness and is never persisted across an
646      // interpreter boundary in a form this load trusts — same contract
647      // `eval_bundle` / `install_plugins` rely on. (The disk cache only
648      // ever loads bytecode written by an ABI-identical toolchain.)
649      #[allow(unsafe_code)]
650      let module = match (unsafe { Module::load(ctx.clone(), &bytecode) }).catch(&ctx) {
651        Ok(m) => m,
652        Err(e) => return Err(caught_to_script_error(e, &label)),
653      };
654      let (evaluated, promise) = match module.eval().catch(&ctx) {
655        Ok(v) => v,
656        Err(e) => return Err(caught_to_script_error(e, &label)),
657      };
658      if let Err(e) = promise.into_future::<()>().await.catch(&ctx) {
659        return Err(caught_to_script_error(e, &label));
660      }
661
662      // Result = the module's `default` export, if any.
663      let default = evaluated
664        .namespace()
665        .and_then(|ns| ns.get::<_, Value<'_>>("default"))
666        .unwrap_or_else(|_| Value::new_undefined(ctx.clone()));
667      Ok(value_to_json(&ctx, default).unwrap_or(serde_json::Value::Null))
668    })
669    .await;
670
671    // Remap the failure location back to the original source.
672    let eval_result = eval_result.map_err(|mut e| {
673      if let Some(line) = e.line {
674        if let Some((src, sl, sc)) = bundle.remap(line, e.column.unwrap_or(1)) {
675          e.message = format!("{} (at {src}:{sl}:{sc})", e.message);
676        }
677      }
678      e
679    });
680
681    self.finish(eval_result, started, &console, &timed_out, timeout)
682  }
683}
684
685/// Wrap user source in an async IIFE so `await` works at the top level and
686/// the expression evaluates to a `Promise<value>` the engine can await.
687fn wrap_source(source: &str) -> String {
688  format!("(async () => {{\n{source}\n}})()")
689}
690
691/// QuickJS raises an `out of memory` error when an allocation fails
692/// after the runtime memory limit is hit. The allocation site is
693/// arbitrary, so the heap cannot be trusted afterwards — treat it as
694/// poisoning (rebuild the VM), exactly like a timeout force-halt.
695fn is_oom(err: &ScriptError) -> bool {
696  err.message.to_ascii_lowercase().contains("out of memory")
697}
698
699/// Everything `install_globals` needs beyond `ctx` + args JSON. Bundled into
700/// a struct so the helper stays under the clippy arity limit as the binding
701/// surface grows.
702struct GlobalsInstall {
703  console: Arc<ConsoleCapture>,
704  page: Option<Arc<ferridriver::Page>>,
705  browser_context: Option<Arc<ferridriver::context::ContextRef>>,
706  request: Option<Arc<ferridriver::http_client::HttpClient>>,
707  default_request: Arc<ferridriver::http_client::HttpClient>,
708  browser: Option<Arc<ferridriver::Browser>>,
709  /// `AsyncContext` driving the script — passed to `install_page` so
710  /// `page.route` callbacks can dispatch back into JS from a separate
711  /// tokio task. Always present (cloned from the session's context).
712  async_ctx: AsyncContext,
713}
714
715/// Reinstall ONLY the per-call-variant globals: `args`, `console`, and
716/// whichever of `page` / `context` / `request` / `browser` the run
717/// context carries (their backend handles are re-resolved every call).
718/// `vars` / `fs` / `artifacts` / `browser_type` / class prototypes are
719/// session-stable and installed once at [`Session::create`]; plugin
720/// bindings likewise.
721fn install_call_globals(ctx: &Ctx<'_>, args: &[serde_json::Value], inst: GlobalsInstall) -> rquickjs::Result<()> {
722  let globals = ctx.globals();
723
724  // args: build the JS array directly from the serde values — no JSON
725  // string, no JS-side `JSON.parse`, and immune to a script reassigning
726  // `globalThis.JSON` in a persistent VM.
727  let args_arr = rquickjs::Array::new(ctx.clone())?;
728  for (i, a) in args.iter().enumerate() {
729    args_arr.set(i, crate::bindings::convert::json_to_js(ctx, a)?)?;
730  }
731  globals.set("args", args_arr)?;
732
733  install_console(ctx, inst.console)?;
734
735  if let Some(page) = inst.page {
736    crate::bindings::install_page(ctx, page, inst.async_ctx.clone())?;
737  }
738  if let Some(bcx) = inst.browser_context {
739    crate::bindings::install_browser_context(ctx, bcx)?;
740  }
741  if let Some(browser) = inst.browser {
742    crate::bindings::install_browser(ctx, browser)?;
743  }
744  if let Some(req) = inst.request {
745    crate::bindings::fetch::install(ctx, req.clone())?;
746    crate::bindings::install_request(ctx, req)?;
747  } else {
748    // `fetch` is always present; with no session HTTP context it uses
749    // a session-stable default one (no shared cookies). Same net posture as the
750    // `request` binding when absent.
751    crate::bindings::fetch::install(ctx, inst.default_request)?;
752  }
753
754  Ok(())
755}
756
757fn install_console(ctx: &Ctx<'_>, capture: Arc<ConsoleCapture>) -> rquickjs::Result<()> {
758  use std::fmt::Write as _;
759
760  use rquickjs::function::Rest;
761
762  // Reuse rquickjs-extra-console's Node-style value renderer (handles
763  // `%s`/`%d` substitution, arrays, objects, `[Function: name]`, Symbol,
764  // bounded depth) — but route the rendered line into our
765  // `ConsoleCapture` sink instead of the `log` crate, so it still
766  // surfaces in `ScriptResult.console[]` for the MCP caller. The
767  // formatter is stateless (`max_depth` only), cheap to clone per level.
768  let formatter = rquickjs_extra_console::Formatter::builder().max_depth(3).build();
769  let console = Object::new(ctx.clone())?;
770
771  for (name, level) in [
772    ("log", ConsoleLevel::Log),
773    ("info", ConsoleLevel::Info),
774    ("warn", ConsoleLevel::Warn),
775    ("error", ConsoleLevel::Error),
776    ("debug", ConsoleLevel::Debug),
777  ] {
778    let cap = capture.clone();
779    let fmt = formatter.clone();
780    console.set(
781      name,
782      Func::from(move |args: Rest<Value<'_>>| -> rquickjs::Result<()> {
783        let mut msg = String::new();
784        for (i, v) in args.0.into_iter().enumerate() {
785          if i > 0 {
786            let _ = msg.write_char(' ');
787          }
788          fmt.format(&mut msg, v)?;
789        }
790        cap.push(level, strip_ansi(&msg));
791        Ok(())
792      }),
793    )?;
794  }
795
796  ctx.globals().set("console", console)?;
797  Ok(())
798}
799
800/// Install the session-lifetime runtime shims: timers, URL, and a few
801/// hand-rolled web globals. Called once at [`Session::create`]; these
802/// PERSIST across executions (browser/REPL-like) and are cancelled only
803/// when the session VM is dropped (poison / eviction / session end) —
804/// dropping the `AsyncRuntime` aborts every `setInterval`/`setTimeout`
805/// task `ctx.spawn`ed by the timers module, so no per-call teardown is
806/// needed. Sandbox-safe surface only — `os` / `sqlite` are deliberately
807/// excluded so scripts cannot escape the filesystem/db sandbox.
808fn install_runtime_shims(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
809  // Native timers (setTimeout/Interval, ctx.spawn-backed) and the
810  // URLSearchParams class.
811  rquickjs_extra_timers::init(ctx)?;
812  rquickjs_extra_url::init(ctx)?;
813  // Native TextEncoder/TextDecoder/URL classes + queueMicrotask/btoa/
814  // atob — all real #[rquickjs::class]/Func bindings, no JS glue.
815  crate::bindings::webapi::install(ctx)?;
816  Ok(())
817}
818
819fn install_vars(ctx: &Ctx<'_>, vars: Arc<dyn VarsStore>) -> rquickjs::Result<()> {
820  let obj = Object::new(ctx.clone())?;
821
822  {
823    let v = vars.clone();
824    obj.set("get", Func::from(move |name: String| v.get(&name)))?;
825  }
826  {
827    let v = vars.clone();
828    obj.set(
829      "set",
830      Func::from(move |name: String, value: String| {
831        v.set(&name, value);
832      }),
833    )?;
834  }
835  {
836    let v = vars.clone();
837    obj.set("has", Func::from(move |name: String| v.has(&name)))?;
838  }
839  {
840    let v = vars.clone();
841    obj.set(
842      "delete",
843      Func::from(move |name: String| {
844        v.delete(&name);
845      }),
846    )?;
847  }
848  {
849    let v = vars.clone();
850    obj.set("keys", Func::from(move || v.keys()))?;
851  }
852
853  ctx.globals().set("vars", obj)?;
854  Ok(())
855}
856
857fn install_fs(ctx: &Ctx<'_>, sandbox: Arc<PathSandbox>) -> rquickjs::Result<()> {
858  let obj = Object::new(ctx.clone())?;
859
860  {
861    let sb = sandbox.clone();
862    obj.set(
863      "readFile",
864      Func::from(Async(move |path: String| {
865        let sb = sb.clone();
866        async move {
867          let resolved = sb.resolve_read(&path).map_err(|e| to_rq_error(&e))?;
868          tokio::fs::read_to_string(&resolved)
869            .await
870            .map_err(|e| rquickjs::Error::new_from_js_message("fs", "readFile", e.to_string()))
871        }
872      })),
873    )?;
874  }
875  {
876    let sb = sandbox.clone();
877    obj.set(
878      "readFileBytes",
879      Func::from(Async(move |path: String| {
880        let sb = sb.clone();
881        async move {
882          let resolved = sb.resolve_read(&path).map_err(|e| to_rq_error(&e))?;
883          tokio::fs::read(&resolved)
884            .await
885            .map_err(|e| rquickjs::Error::new_from_js_message("fs", "readFileBytes", e.to_string()))
886        }
887      })),
888    )?;
889  }
890  {
891    let sb = sandbox.clone();
892    obj.set(
893      "writeFile",
894      Func::from(Async(move |path: String, contents: String| {
895        let sb = sb.clone();
896        async move {
897          let resolved = sb.resolve_write(&path).map_err(|e| to_rq_error(&e))?;
898          tokio::fs::write(&resolved, contents)
899            .await
900            .map_err(|e| rquickjs::Error::new_from_js_message("fs", "writeFile", e.to_string()))
901        }
902      })),
903    )?;
904  }
905  {
906    let sb = sandbox.clone();
907    obj.set(
908      "readdir",
909      Func::from(Async(move |path: String| {
910        let sb = sb.clone();
911        async move {
912          let resolved = sb.resolve_read(&path).map_err(|e| to_rq_error(&e))?;
913          let mut entries = tokio::fs::read_dir(&resolved)
914            .await
915            .map_err(|e| rquickjs::Error::new_from_js_message("fs", "readdir", e.to_string()))?;
916          let mut names: Vec<String> = Vec::new();
917          while let Some(entry) = entries
918            .next_entry()
919            .await
920            .map_err(|e| rquickjs::Error::new_from_js_message("fs", "readdir", e.to_string()))?
921          {
922            names.push(entry.file_name().to_string_lossy().into_owned());
923          }
924          Ok::<_, rquickjs::Error>(names)
925        }
926      })),
927    )?;
928  }
929  {
930    let sb = sandbox.clone();
931    obj.set(
932      "exists",
933      Func::from(Async(move |path: String| {
934        let sb = sb.clone();
935        async move {
936          // Syntactic checks still apply; absence or sandbox-escape returns false.
937          match sb.resolve_read(&path) {
938            Ok(resolved) => Ok::<bool, rquickjs::Error>(tokio::fs::try_exists(&resolved).await.unwrap_or(false)),
939            Err(_) => Ok(false),
940          }
941        }
942      })),
943    )?;
944  }
945
946  // Expose the sandbox root so scripts can build relative paths confidently.
947  obj.set("root", sandbox.root().to_string_lossy().into_owned())?;
948
949  ctx.globals().set("fs", obj)?;
950  Ok(())
951}
952
953fn to_rq_error(err: &ScriptError) -> rquickjs::Error {
954  // The `from`/`to` static labels are used only in rquickjs's Display impl.
955  // We route sandbox-rejection errors through the FromJs variant so the
956  // message propagates to JS as a thrown exception with our reason string.
957  rquickjs::Error::new_from_js_message("fs", "sandbox", err.message.clone())
958}
959
960/// Convert the script's return value to `serde_json::Value`.
961///
962/// `rquickjs-serde` (`from_value`) drives the deserializer: it invokes
963/// `toJSON()` / `valueOf()` (a returned `Date` still serialises as its
964/// ISO string), coerces whole f64 in the safe-integer range to `i64`,
965/// drops `undefined` / function / symbol, and renders non-finite as
966/// null. We deserialize into a small AP-immune intermediate rather than
967/// straight into `serde_json::Value`: a transitive dep force-enables
968/// `serde_json/arbitrary_precision` workspace-wide, and under that
969/// feature `serde_json::Value`'s own `Deserialize` demands a private
970/// number representation that a non-`serde_json` deserializer (here,
971/// `rquickjs-serde`) cannot provide — every numeric/array result would
972/// otherwise fail to convert and collapse to `null`. The intermediate's
973/// `Deserialize` is plain serde; the `serde_json::Value` is then built
974/// with explicit constructors, which are AP-correct.
975fn value_to_json<'js>(_ctx: &Ctx<'js>, value: Value<'js>) -> Option<serde_json::Value> {
976  rquickjs_serde::from_value::<JsonInter>(value)
977    .ok()
978    .map(JsonInter::into_json)
979}
980
981/// AP-immune mirror of a JSON value. Its `Deserialize` is plain serde
982/// (no `serde_json` number coupling); `into_json` rebuilds a
983/// `serde_json::Value` via explicit constructors.
984enum JsonInter {
985  Null,
986  Bool(bool),
987  I64(i64),
988  U64(u64),
989  F64(f64),
990  Str(String),
991  Arr(Vec<JsonInter>),
992  Obj(Vec<(String, JsonInter)>),
993}
994
995impl JsonInter {
996  fn into_json(self) -> serde_json::Value {
997    use serde_json::Value;
998    match self {
999      Self::Null => Value::Null,
1000      Self::Bool(b) => Value::Bool(b),
1001      Self::I64(n) => Value::Number(n.into()),
1002      Self::U64(n) => Value::Number(n.into()),
1003      Self::F64(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1004      Self::Str(s) => Value::String(s),
1005      Self::Arr(a) => Value::Array(a.into_iter().map(Self::into_json).collect()),
1006      Self::Obj(o) => Value::Object(o.into_iter().map(|(k, v)| (k, v.into_json())).collect()),
1007    }
1008  }
1009}
1010
1011impl<'de> serde::Deserialize<'de> for JsonInter {
1012  fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1013    struct V;
1014    impl<'de> serde::de::Visitor<'de> for V {
1015      type Value = JsonInter;
1016      fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1017        f.write_str("any JSON value")
1018      }
1019      fn visit_unit<E>(self) -> Result<JsonInter, E> {
1020        Ok(JsonInter::Null)
1021      }
1022      fn visit_none<E>(self) -> Result<JsonInter, E> {
1023        Ok(JsonInter::Null)
1024      }
1025      fn visit_bool<E>(self, v: bool) -> Result<JsonInter, E> {
1026        Ok(JsonInter::Bool(v))
1027      }
1028      fn visit_i64<E>(self, v: i64) -> Result<JsonInter, E> {
1029        Ok(JsonInter::I64(v))
1030      }
1031      fn visit_u64<E>(self, v: u64) -> Result<JsonInter, E> {
1032        Ok(JsonInter::U64(v))
1033      }
1034      fn visit_f64<E>(self, v: f64) -> Result<JsonInter, E> {
1035        Ok(JsonInter::F64(v))
1036      }
1037      fn visit_str<E>(self, v: &str) -> Result<JsonInter, E> {
1038        Ok(JsonInter::Str(v.to_owned()))
1039      }
1040      fn visit_string<E>(self, v: String) -> Result<JsonInter, E> {
1041        Ok(JsonInter::Str(v))
1042      }
1043      fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<JsonInter, A::Error> {
1044        let mut out = Vec::new();
1045        while let Some(e) = a.next_element()? {
1046          out.push(e);
1047        }
1048        Ok(JsonInter::Arr(out))
1049      }
1050      fn visit_map<A: serde::de::MapAccess<'de>>(self, mut m: A) -> Result<JsonInter, A::Error> {
1051        let mut out = Vec::new();
1052        while let Some((k, v)) = m.next_entry()? {
1053          out.push((k, v));
1054        }
1055        Ok(JsonInter::Obj(out))
1056      }
1057    }
1058    d.deserialize_any(V)
1059  }
1060}
1061
1062pub(crate) fn caught_to_script_error(caught: rquickjs::CaughtError<'_>, source: &str) -> ScriptError {
1063  let (message, stack, line, column) = match caught {
1064    rquickjs::CaughtError::Exception(ex) => {
1065      let message = ex.message().unwrap_or_else(|| "exception".to_string());
1066      let stack = ex.stack();
1067      // Playwright-style: lineNumber/columnNumber are present on most QuickJS
1068      // exceptions; read them directly off the exception object.
1069      let obj = ex.as_object();
1070      let line = obj.get::<_, u32>("lineNumber").ok();
1071      let column = obj.get::<_, u32>("columnNumber").ok();
1072      (message, stack, line, column)
1073    },
1074    rquickjs::CaughtError::Value(v) => (format!("{v:?}"), None, None, None),
1075    rquickjs::CaughtError::Error(e) => (format!("{e}"), None, None, None),
1076  };
1077
1078  ScriptError {
1079    kind: ScriptErrorKind::Runtime,
1080    message,
1081    stack,
1082    line,
1083    column,
1084    source_snippet: line.and_then(|l| snippet_around_line(source, l, 2)),
1085  }
1086}
1087
1088/// Build a 1-indexed source snippet with `context_lines` around the target
1089/// line, used in error reporting so the LLM can see where the script failed.
1090fn snippet_around_line(source: &str, line_1based: u32, context_lines: u32) -> Option<String> {
1091  use std::fmt::Write as _;
1092  let lines: Vec<&str> = source.lines().collect();
1093  if lines.is_empty() {
1094    return None;
1095  }
1096  let target = line_1based.saturating_sub(1) as usize;
1097  let start = target.saturating_sub(context_lines as usize);
1098  let end = (target + context_lines as usize + 1).min(lines.len());
1099  let mut out = String::new();
1100  for (i, text) in lines[start..end].iter().enumerate() {
1101    let ln = start + i + 1;
1102    let marker = if ln == line_1based as usize { ">>>" } else { "   " };
1103    let _ = writeln!(out, "{marker} {ln:>4}: {text}");
1104  }
1105  Some(out)
1106}
1107
1108fn elapsed_ms(started: Instant) -> u64 {
1109  started.elapsed().as_millis() as u64
1110}