Skip to main content

capsule_runtime/
process_driver.rs

1//! Loading a process driver: the artifact a `transport: process` manifest names under
2//! `inference.driver`, which knows how to drive one harness CLI.
3//!
4//! A process driver is pure translation and is granted nothing. [`ProcessDriver`] instantiates
5//! it on an empty WASI context with no Murmur host import, so a driver that reaches for anything
6//! else fails to load rather than running with less than it expected.
7
8use murmur_artifact::WitContracts;
9use wasmtime::component::{Component, Linker, ResourceTable};
10use wasmtime::{Engine, Store};
11use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
12
13use crate::bindings::process_driver::ProcessDriver as ProcessDriverBindings;
14use crate::errors::RuntimeError;
15use crate::limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure};
16use crate::ExecutionLimits;
17
18pub use crate::bindings::process_driver::exports::murmur::driver::process::{
19    Bridge, Description, DriverFile, Event, ExitStatus, FailureKind, InterruptMethod, LaunchPlan,
20    LaunchRequest, RetryInfo, Session, SessionInfo, SessionMode, ToolCallInfo, ToolResultInfo,
21    TurnFailure, Usage,
22};
23
24/// The instance name every process driver exports.
25pub const PROCESS_DRIVER_IFACE: &str = "murmur:driver/process@0.2.0";
26
27/// Every version of the process interface starts with this, so an export built against another
28/// version is still recognised as a process driver.
29const PROCESS_DRIVER_IFACE_PREFIX: &str = "murmur:driver/process@";
30
31/// Checks the inference driver's exports against the transport that names it.
32///
33/// Under `process` the driver's exports must include [`PROCESS_DRIVER_IFACE`] under that exact
34/// name — a neighbouring version does not satisfy it, and other instances alongside it are
35/// ignored. Under `http` it must export no version of the process interface. `contracts` is what
36/// [`murmur_artifact::extract_wit_contracts`] read out of the driver's wasm; `None` (a core
37/// module, or bytes it could not read) counts as exporting nothing. Any other transport passes.
38pub fn check_driver_interface(
39    transport: &str,
40    name: &str,
41    version: &str,
42    contracts: Option<&WitContracts>,
43) -> Result<(), RuntimeError> {
44    let process_exports: Vec<String> = contracts
45        .map(|c| {
46            c.exports
47                .iter()
48                .filter(|export| export.starts_with(PROCESS_DRIVER_IFACE_PREFIX))
49                .cloned()
50                .collect()
51        })
52        .unwrap_or_default();
53    match transport {
54        "process" if process_exports.iter().any(|e| e == PROCESS_DRIVER_IFACE) => Ok(()),
55        "process" => Err(RuntimeError::ProcessDriverInterfaceMissing {
56            name: name.to_string(),
57            version: version.to_string(),
58            expected: PROCESS_DRIVER_IFACE.to_string(),
59            found: process_exports,
60        }),
61        "http" if !process_exports.is_empty() => {
62            Err(RuntimeError::HttpDriverExportsProcessInterface {
63                name: name.to_string(),
64                version: version.to_string(),
65                exported: process_exports,
66            })
67        }
68        _ => Ok(()),
69    }
70}
71
72/// Checks that every variable `description.required_env` names is declared in
73/// `capabilities.env.allow`, given as `env_allow`. Names match exactly.
74///
75/// The refusal lists every undeclared name, in the driver's order, each once.
76pub fn check_required_env(
77    name: &str,
78    version: &str,
79    description: &Description,
80    env_allow: &[String],
81) -> Result<(), RuntimeError> {
82    let mut missing: Vec<String> = Vec::new();
83    for var in &description.required_env {
84        if !env_allow.contains(var) && !missing.contains(var) {
85            missing.push(var.clone());
86        }
87    }
88    if missing.is_empty() {
89        Ok(())
90    } else {
91        Err(RuntimeError::ProcessDriverRequiredEnvNotAllowed {
92            name: name.to_string(),
93            version: version.to_string(),
94            missing,
95        })
96    }
97}
98
99/// The store a process driver runs in: an empty WASI context and the execution limits.
100struct ProcessDriverStoreState {
101    wasi: WasiCtx,
102    table: ResourceTable,
103    limits: ExecutionLimiter,
104}
105
106impl WasiView for ProcessDriverStoreState {
107    fn ctx(&mut self) -> WasiCtxView<'_> {
108        WasiCtxView {
109            ctx: &mut self.wasi,
110            table: &mut self.table,
111        }
112    }
113}
114
115/// One instantiated process driver. The runtime keeps one for a whole run, so the driver may
116/// carry what `launch` was given into `parse`.
117pub struct ProcessDriver {
118    name: String,
119    version: String,
120    store: Store<ProcessDriverStoreState>,
121    bindings: ProcessDriverBindings,
122}
123
124impl ProcessDriver {
125    /// Instantiates `component` with no grants: a WASI context with no environment, arguments,
126    /// preopened directories, inherited stdio or network, and no Murmur host import.
127    ///
128    /// Each call into the driver is bounded by the hook call limits
129    /// ([`ExecutionLimits::for_hook_calls`] with no declared deadline), so `engine` must have an
130    /// epoch ticker running. `name` and `version` identify the driver in errors. Any failure,
131    /// including an import the empty context does not provide, is
132    /// [`RuntimeError::ProcessDriverLoad`] carrying wasmtime's message.
133    pub async fn instantiate(
134        engine: &Engine,
135        component: &Component,
136        name: &str,
137        version: &str,
138    ) -> Result<Self, RuntimeError> {
139        let load_error = |message: String| RuntimeError::ProcessDriverLoad {
140            name: name.to_string(),
141            version: version.to_string(),
142            message,
143        };
144        let limits = ExecutionLimits::default().for_hook_calls(None);
145
146        let mut linker: Linker<ProcessDriverStoreState> = Linker::new(engine);
147        wasmtime_wasi::p2::add_to_linker_async(&mut linker)
148            .map_err(|e| load_error(e.to_string()))?;
149
150        let state = ProcessDriverStoreState {
151            // The builder's defaults are the grant: no environment, arguments, preopens or
152            // stdio, no name lookup, and a socket address check that refuses every address.
153            wasi: WasiCtxBuilder::new().build(),
154            table: ResourceTable::new(),
155            limits: limits.limiter(),
156        };
157        let mut store = Store::new(engine, state);
158        store.limiter(|state| &mut state.limits);
159        store.set_epoch_deadline(limits.deadline_ticks());
160
161        let bindings = ProcessDriverBindings::instantiate_async(&mut store, component, &linker)
162            .await
163            .map_err(|e| load_error(format!("{e:#}")))?;
164        Ok(Self {
165            name: name.to_string(),
166            version: version.to_string(),
167            store,
168            bindings,
169        })
170    }
171
172    /// Calls the driver's `describe`.
173    ///
174    /// A trap, an exceeded deadline, or a `required-env` entry that is not a usable variable
175    /// name (empty, or containing `=` or NUL) is [`RuntimeError::ProcessDriverLoad`].
176    pub async fn describe(&mut self) -> Result<Description, RuntimeError> {
177        self.reset_deadline();
178        let description = self
179            .bindings
180            .murmur_driver_process()
181            .call_describe(&mut self.store)
182            .await
183            .map_err(|e| {
184                let message = match classify_guest_failure(&e, &self.store.data().limits) {
185                    GuestFailure::DeadlineExceeded { seconds } => {
186                        format!("describe() exceeded its {seconds}s deadline and was interrupted")
187                    }
188                    GuestFailure::ResourceLimit { message } => {
189                        format!("describe() exceeded its resource limits: {message}")
190                    }
191                    GuestFailure::Other => format!("describe() trapped: {e:#}"),
192                };
193                self.load_error(message)
194            })?;
195        if let Some(bad) = unusable_env_name(&description) {
196            return Err(self.load_error(format!(
197                "describe() names {bad:?} in required-env, which is not a usable variable name"
198            )));
199        }
200        Ok(description)
201    }
202
203    /// Asks the driver to plan one harness run. The inner `Err` is the driver's own refusal,
204    /// reported to the operator verbatim; the outer one is a trap or an exceeded deadline.
205    pub async fn launch(
206        &mut self,
207        request: LaunchRequest,
208    ) -> Result<Result<LaunchPlan, String>, RuntimeError> {
209        self.reset_deadline();
210        self.bindings
211            .murmur_driver_process()
212            .call_launch(&mut self.store, &request)
213            .await
214            .map_err(|e| self.call_error("launch", &e))
215    }
216
217    /// Reads one batch of complete stdout lines into events. The same instance serves a whole
218    /// run, so the driver may answer from what `launch` was given.
219    pub async fn parse(&mut self, lines: Vec<String>) -> Result<Vec<Event>, RuntimeError> {
220        self.reset_deadline();
221        self.bindings
222            .murmur_driver_process()
223            .call_parse(&mut self.store, &lines)
224            .await
225            .map_err(|e| self.call_error("parse", &e))
226    }
227
228    /// Asks the driver what a run whose output ended without a terminal event amounts to.
229    pub async fn classify_exit(&mut self, exit: ExitStatus) -> Result<Event, RuntimeError> {
230        self.reset_deadline();
231        self.bindings
232            .murmur_driver_process()
233            .call_classify_exit(&mut self.store, &exit)
234            .await
235            .map_err(|e| self.call_error("classify-exit", &e))
236    }
237
238    /// Gives the next call its own deadline. Without this the ticks the previous call spent
239    /// would count against it, so a long run would eventually interrupt a driver that had done
240    /// nothing wrong.
241    fn reset_deadline(&mut self) {
242        let deadline = self.store.data().limits.limits().deadline_ticks();
243        self.store.set_epoch_deadline(deadline);
244    }
245
246    fn call_error(&self, call: &str, error: &wasmtime::Error) -> RuntimeError {
247        let message = match classify_guest_failure(error, &self.store.data().limits) {
248            GuestFailure::DeadlineExceeded { seconds } => {
249                format!("exceeded its {seconds}s deadline and was interrupted")
250            }
251            GuestFailure::ResourceLimit { message } => {
252                format!("exceeded its resource limits: {message}")
253            }
254            GuestFailure::Other => format!("trapped: {error:#}"),
255        };
256        RuntimeError::ProcessDriverCallFailed {
257            name: self.name.clone(),
258            version: self.version.clone(),
259            call: call.to_string(),
260            message,
261        }
262    }
263
264    fn load_error(&self, message: String) -> RuntimeError {
265        RuntimeError::ProcessDriverLoad {
266            name: self.name.clone(),
267            version: self.version.clone(),
268            message,
269        }
270    }
271}
272
273/// Whether `name` can be set as an environment variable.
274///
275/// A name carrying `=` would set a second variable when an environment is built, and one carrying
276/// NUL truncates it; an empty name is neither. Both places a driver names a variable — its
277/// `required-env`, checked at load, and its launch plan's `env-set`, checked at spawn — hold to
278/// this one rule.
279pub(crate) fn is_usable_env_name(name: &str) -> bool {
280    !name.is_empty() && !name.contains('=') && !name.contains('\0')
281}
282
283/// The first `required-env` name that could not be set as an environment variable, if any.
284/// Rejected where the driver's word is first taken, rather than wherever it is later spent.
285fn unusable_env_name(description: &Description) -> Option<&str> {
286    description
287        .required_env
288        .iter()
289        .find(|var| !is_usable_env_name(var))
290        .map(String::as_str)
291}
292
293/// Compiles `wasm` as a process driver on a fresh engine, instantiates it with no grants, and
294/// returns what its `describe` reports.
295///
296/// For tests and tools that hold a driver's bytes outside any capsule. It blocks on its own
297/// current-thread runtime, so it must not be called from inside one. Errors name the driver as
298/// `<wasm>` with an empty version, since the bytes carry no artifact name.
299pub fn describe_process_driver_wasm(wasm: &[u8]) -> Result<Description, RuntimeError> {
300    const NAME: &str = "<wasm>";
301    const VERSION: &str = "";
302    let engine = crate::runtime::build_engine()?;
303    let _ticker = EpochTicker::spawn(&engine);
304    let component =
305        Component::new(&engine, wasm).map_err(|e| RuntimeError::ToolComponentCompile {
306            name: NAME.to_string(),
307            version: VERSION.to_string(),
308            message: e.to_string(),
309        })?;
310    let rt = tokio::runtime::Builder::new_current_thread()
311        .enable_all()
312        .build()
313        .map_err(|e| {
314            RuntimeError::Runtime(format!("failed to build process driver runtime: {e}"))
315        })?;
316    rt.block_on(async {
317        let mut driver = ProcessDriver::instantiate(&engine, &component, NAME, VERSION).await?;
318        driver.describe().await
319    })
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn contracts(exports: &[&str]) -> WitContracts {
327        WitContracts {
328            exports: exports.iter().map(|e| e.to_string()).collect(),
329            imports: Vec::new(),
330        }
331    }
332
333    #[test]
334    fn process_driver_interface_process_transport_accepts_the_process_export() {
335        let c = contracts(&[PROCESS_DRIVER_IFACE]);
336        assert!(check_driver_interface("process", "d", "1.0.0", Some(&c)).is_ok());
337    }
338
339    #[test]
340    fn process_driver_interface_process_transport_refuses_a_tool_export() {
341        let c = contracts(&["murmur:tool/run@0.1.0"]);
342        let err = check_driver_interface("process", "d", "1.0.0", Some(&c)).unwrap_err();
343        match &err {
344            RuntimeError::ProcessDriverInterfaceMissing {
345                expected, found, ..
346            } => {
347                assert_eq!(expected, PROCESS_DRIVER_IFACE);
348                assert!(found.is_empty());
349            }
350            other => panic!("unexpected error: {other:?}"),
351        }
352        let message = err.to_string();
353        assert!(message.contains("'d@1.0.0'"), "{message}");
354        assert!(message.contains(PROCESS_DRIVER_IFACE), "{message}");
355    }
356
357    #[test]
358    fn process_driver_interface_process_transport_names_the_version_found() {
359        let c = contracts(&["murmur:driver/process@0.0.1"]);
360        let err = check_driver_interface("process", "d", "1.0.0", Some(&c)).unwrap_err();
361        match &err {
362            RuntimeError::ProcessDriverInterfaceMissing { found, .. } => {
363                assert_eq!(found, &vec!["murmur:driver/process@0.0.1".to_string()]);
364            }
365            other => panic!("unexpected error: {other:?}"),
366        }
367        let message = err.to_string();
368        assert!(message.contains("murmur:driver/process@0.0.1"), "{message}");
369        assert!(message.contains("rebuild it"), "{message}");
370    }
371
372    #[test]
373    fn process_driver_interface_process_transport_refuses_no_contracts() {
374        let err = check_driver_interface("process", "d", "1.0.0", None).unwrap_err();
375        assert!(matches!(
376            err,
377            RuntimeError::ProcessDriverInterfaceMissing { ref found, .. } if found.is_empty()
378        ));
379    }
380
381    #[test]
382    fn process_driver_interface_http_transport_refuses_the_process_export() {
383        let c = contracts(&[PROCESS_DRIVER_IFACE]);
384        let err = check_driver_interface("http", "d", "1.0.0", Some(&c)).unwrap_err();
385        match &err {
386            RuntimeError::HttpDriverExportsProcessInterface { name, exported, .. } => {
387                assert_eq!(name, "d");
388                assert_eq!(exported, &vec![PROCESS_DRIVER_IFACE.to_string()]);
389            }
390            other => panic!("unexpected error: {other:?}"),
391        }
392    }
393
394    #[test]
395    fn process_driver_interface_http_transport_accepts_a_tool_export() {
396        let c = contracts(&["murmur:tool/run@0.1.0"]);
397        assert!(check_driver_interface("http", "d", "1.0.0", Some(&c)).is_ok());
398    }
399
400    fn description(required_env: &[&str]) -> Description {
401        Description {
402            harness: "h".to_string(),
403            binary: "b".to_string(),
404            version_args: Vec::new(),
405            tested_versions: Vec::new(),
406            interrupt: InterruptMethod::Unsupported,
407            required_env: required_env.iter().map(|v| v.to_string()).collect(),
408            streams_text: false,
409            reports_usage: false,
410        }
411    }
412
413    #[test]
414    fn required_env_all_declared_passes() {
415        let allow = vec!["A".to_string(), "B".to_string(), "C".to_string()];
416        assert!(check_required_env("d", "1.0.0", &description(&["B", "A"]), &allow).is_ok());
417    }
418
419    #[test]
420    fn required_env_reports_missing_names_in_order_once_each() {
421        let allow = vec!["B".to_string()];
422        let err = check_required_env("d", "1.0.0", &description(&["Z", "B", "A", "Z"]), &allow)
423            .unwrap_err();
424        match &err {
425            RuntimeError::ProcessDriverRequiredEnvNotAllowed { missing, .. } => {
426                assert_eq!(missing, &vec!["Z".to_string(), "A".to_string()]);
427            }
428            other => panic!("unexpected error: {other:?}"),
429        }
430        let message = err.to_string();
431        assert!(message.contains("'d@1.0.0'"), "{message}");
432        assert!(message.contains("Z, A"), "{message}");
433    }
434
435    #[test]
436    fn required_env_usable_names_pass() {
437        assert_eq!(
438            unusable_env_name(&description(&["HOME", "PROFILE_2"])),
439            None
440        );
441        assert_eq!(unusable_env_name(&description(&[])), None);
442    }
443
444    #[test]
445    fn required_env_refuses_a_name_that_is_not_a_variable() {
446        for bad in ["", "A=B", "A\0B"] {
447            assert_eq!(
448                unusable_env_name(&description(&["HOME", bad, "TAIL"])),
449                Some(bad),
450                "{bad:?} should be refused"
451            );
452        }
453    }
454
455    /// What one `parse` call costs, and what instantiating a driver costs.
456    ///
457    /// Ignored: it measures rather than asserts, and the numbers only mean anything in a release
458    /// build. Run it with
459    /// `cargo test -p capsule-runtime --release process_driver_parse_cost -- --ignored --nocapture`.
460    #[test]
461    #[ignore = "a measurement, not an assertion; run with --release --ignored --nocapture"]
462    fn process_driver_parse_cost() {
463        use std::time::Instant;
464
465        const WASM: &[u8] = include_bytes!(concat!(
466            env!("CARGO_MANIFEST_DIR"),
467            "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
468        ));
469        let engine = crate::runtime::build_engine().expect("engine");
470        let _ticker = EpochTicker::spawn(&engine);
471        let component = Component::new(&engine, WASM).expect("fixture compiles");
472        let rt = tokio::runtime::Builder::new_current_thread()
473            .enable_all()
474            .build()
475            .expect("runtime");
476
477        let instantiations = 50;
478        let start = Instant::now();
479        for _ in 0..instantiations {
480            rt.block_on(ProcessDriver::instantiate(
481                &engine, &component, "d", "0.1.0",
482            ))
483            .expect("instantiates");
484        }
485        let per_instantiation = start.elapsed() / instantiations;
486
487        let mut driver = rt
488            .block_on(ProcessDriver::instantiate(
489                &engine, &component, "d", "0.1.0",
490            ))
491            .expect("instantiates");
492        for lines in [1usize, 64] {
493            let batch: Vec<String> = (0..lines).map(|i| format!("text line {i}")).collect();
494            let runs = 2_000;
495            let mut samples = Vec::with_capacity(runs);
496            for _ in 0..runs {
497                let start = Instant::now();
498                let events = rt.block_on(driver.parse(batch.clone())).expect("parses");
499                samples.push(start.elapsed());
500                assert_eq!(events.len(), lines);
501            }
502            samples.sort_unstable();
503            let median = samples[samples.len() / 2];
504            let p95 = samples[samples.len() * 95 / 100];
505            crate::diagnostic::raw_to_stdout(&format!(
506                "parse {lines}-line batch on one reused instance: median {median:?}, p95 {p95:?} \
507                 ({runs} calls)\n"
508            ));
509        }
510        crate::diagnostic::raw_to_stdout(&format!(
511            "instantiate one driver: {per_instantiation:?} (mean of {instantiations})\n"
512        ));
513    }
514
515    #[test]
516    fn process_driver_granted_nothing() {
517        let wat = r#"(component
518            (import "murmur:text/chunks@0.1.0" (instance
519                (export "emit-chunk" (func (param "chunk" string)))
520            ))
521        )"#;
522        let bytes = wat::parse_str(wat).expect("component WAT parses");
523        let engine = crate::runtime::build_engine().expect("engine");
524        let _ticker = EpochTicker::spawn(&engine);
525        let component = Component::new(&engine, &bytes).expect("component compiles");
526        let rt = tokio::runtime::Builder::new_current_thread()
527            .enable_all()
528            .build()
529            .expect("runtime");
530        let result = rt.block_on(ProcessDriver::instantiate(
531            &engine, &component, "d", "1.0.0",
532        ));
533        match result {
534            Err(RuntimeError::ProcessDriverLoad { name, message, .. }) => {
535                assert_eq!(name, "d");
536                assert!(message.contains("murmur:text/chunks@0.1.0"), "{message}");
537            }
538            Err(other) => panic!("unexpected error: {other:?}"),
539            Ok(_) => panic!("a driver importing a Murmur interface instantiated"),
540        }
541    }
542}