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::{
9    current_platform, version_fit, FitSubject, VersionFit, WitContracts, WitLevel,
10};
11use wasmtime::component::{Component, Linker, ResourceTable};
12use wasmtime::{Engine, Store};
13use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
14
15use crate::bindings::process_driver::ProcessDriver as ProcessDriverBindings;
16use crate::errors::RuntimeError;
17use crate::limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure};
18use crate::ExecutionLimits;
19
20pub use crate::bindings::process_driver::exports::murmur::driver::process::{
21    Bridge, Description, DriverFile, Event, ExitStatus, FailureKind, InterruptMethod, LaunchPlan,
22    LaunchRequest, RetryInfo, Session, SessionInfo, SessionMode, ToolCallInfo, ToolCallProgress,
23    ToolCallStart, ToolResultInfo, TurnFailure, Usage,
24};
25
26/// The instance name every process driver exports.
27pub const PROCESS_DRIVER_IFACE: &str = "murmur:driver/process@0.3.0";
28
29/// Every version of the process interface starts with this, so an export built against another
30/// version is still recognised as a process driver.
31const PROCESS_DRIVER_IFACE_PREFIX: &str = "murmur:driver/process@";
32
33/// Checks the inference driver's exports against the transport that names it and the WIT level
34/// this host serves.
35///
36/// Under `process` the driver's exports must include the process interface at the version
37/// `level` lists for `murmur:driver` (for the served level, [`PROCESS_DRIVER_IFACE`]) under that
38/// exact name; other instances alongside it are ignored. A driver exporting the process interface
39/// only at other versions does not fit the level and is refused with
40/// [`RuntimeError::WitLevelMismatch`]; one exporting no version of it is not a process driver and
41/// is refused with [`RuntimeError::ProcessDriverInterfaceMissing`]. Under `http` it must export
42/// no version of the process interface. `contracts` is what
43/// [`murmur_artifact::extract_wit_contracts`] read out of the driver's wasm; `None` (a core
44/// module, or bytes it could not read) counts as exporting nothing. Any other transport passes.
45pub fn check_driver_interface(
46    transport: &str,
47    name: &str,
48    version: &str,
49    contracts: Option<&WitContracts>,
50    level: &WitLevel,
51) -> Result<(), RuntimeError> {
52    let process_exports: Vec<String> = contracts
53        .map(|c| {
54            c.exports
55                .iter()
56                .filter(|export| export.starts_with(PROCESS_DRIVER_IFACE_PREFIX))
57                .cloned()
58                .collect()
59        })
60        .unwrap_or_default();
61    let expected = level_interface(level, "murmur:driver", "process")
62        .unwrap_or_else(|| PROCESS_DRIVER_IFACE.to_string());
63    match transport {
64        "process" if process_exports.contains(&expected) => Ok(()),
65        "process" if process_exports.is_empty() => {
66            Err(RuntimeError::ProcessDriverInterfaceMissing {
67                name: name.to_string(),
68                version: version.to_string(),
69                expected,
70            })
71        }
72        // Refuse even if every export fits the level: none is `expected`, so the driver must not
73        // pass.
74        "process" => Err(
75            level_mismatch(name, version, level, process_exports, Vec::new()).unwrap_or_else(
76                || RuntimeError::ProcessDriverInterfaceMissing {
77                    name: name.to_string(),
78                    version: version.to_string(),
79                    expected,
80                },
81            ),
82        ),
83        "http" if !process_exports.is_empty() => {
84            Err(RuntimeError::HttpDriverExportsProcessInterface {
85                name: name.to_string(),
86                version: version.to_string(),
87                exported: process_exports,
88            })
89        }
90        _ => Ok(()),
91    }
92}
93
94/// Every instance of the retired `murmur:text` package starts with this. No version of it is
95/// served: `murmur:stream/events` replaced it.
96const RETIRED_TEXT_IFACE_PREFIX: &str = "murmur:text/";
97
98/// Every instance of the `murmur:stream` package starts with this.
99const STREAM_IFACE_PREFIX: &str = "murmur:stream/";
100
101/// Checks a `runtime: driver` artifact's imports against the stream interface of the WIT level
102/// this host serves.
103///
104/// Refuses a component importing any `murmur:text/*` instance, or any `murmur:stream/*` instance
105/// other than exactly `murmur:stream/events` at the version `level` lists (for the served level,
106/// [`WIT_STREAM_EVENTS_IFACE`]): the host serves neither, so the driver would fail to instantiate
107/// on its first turn. The imports among those that do not fit `level` are refused with
108/// [`RuntimeError::WitLevelMismatch`]; only when every one is at the level's version of its
109/// package, under an interface name that does not exist, is it
110/// [`RuntimeError::DriverStreamInterfaceNotServed`]. `contracts` is what
111/// [`murmur_artifact::extract_wit_contracts`] read out of the driver's wasm; `None`, and a
112/// component importing neither package, pass.
113///
114/// [`WIT_STREAM_EVENTS_IFACE`]: crate::runtime::WIT_STREAM_EVENTS_IFACE
115pub fn check_stream_interface(
116    name: &str,
117    version: &str,
118    contracts: Option<&WitContracts>,
119    level: &WitLevel,
120) -> Result<(), RuntimeError> {
121    let expected = level_interface(level, "murmur:stream", "events")
122        .unwrap_or_else(|| crate::runtime::WIT_STREAM_EVENTS_IFACE.to_string());
123    let imported: Vec<String> = contracts
124        .map(|c| {
125            c.imports
126                .iter()
127                .filter(|import| {
128                    import.starts_with(RETIRED_TEXT_IFACE_PREFIX)
129                        || (import.starts_with(STREAM_IFACE_PREFIX) && **import != expected)
130                })
131                .cloned()
132                .collect()
133        })
134        .unwrap_or_default();
135    if imported.is_empty() {
136        return Ok(());
137    }
138    if let Some(mismatch) = level_mismatch(name, version, level, Vec::new(), imported.clone()) {
139        return Err(mismatch);
140    }
141    Err(RuntimeError::DriverStreamInterfaceNotServed {
142        name: name.to_string(),
143        version: version.to_string(),
144        imported,
145        expected,
146    })
147}
148
149/// The one refusal of a driver both [`check_stream_interface`] and [`check_driver_interface`]
150/// checked: a level mismatch over any other, both checks' mismatches together (exports first)
151/// when each found one, and otherwise the stream check's refusal before the driver check's.
152pub(crate) fn driver_refusal(
153    stream: Result<(), RuntimeError>,
154    driver: Result<(), RuntimeError>,
155) -> Result<(), RuntimeError> {
156    match (stream, driver) {
157        (
158            Err(RuntimeError::WitLevelMismatch {
159                mismatched: imports,
160                ..
161            }),
162            Err(RuntimeError::WitLevelMismatch {
163                name,
164                version,
165                level,
166                mismatched: mut exports,
167            }),
168        ) => {
169            exports.extend(imports);
170            Err(RuntimeError::WitLevelMismatch {
171                name,
172                version,
173                level,
174                mismatched: exports,
175            })
176        }
177        (Err(_), Err(driver @ RuntimeError::WitLevelMismatch { .. })) => Err(driver),
178        (Err(stream), _) => Err(stream),
179        (Ok(()), driver) => driver,
180    }
181}
182
183/// `<package>/<interface>@<version>` at the version `level` lists for `package`, if it lists one.
184fn level_interface(level: &WitLevel, package: &str, interface: &str) -> Option<String> {
185    level
186        .version_of(package)
187        .map(|version| format!("{package}/{interface}@{version}"))
188}
189
190/// [`RuntimeError::WitLevelMismatch`] for the refused names among `exports` and `imports` that do
191/// not fit `level`, or `None` when every one of them does.
192fn level_mismatch(
193    name: &str,
194    version: &str,
195    level: &WitLevel,
196    exports: Vec<String>,
197    imports: Vec<String>,
198) -> Option<RuntimeError> {
199    let subject = FitSubject::component(WitContracts { exports, imports });
200    // One untagged payload: the platform never decides a component's fit.
201    match version_fit(&subject, level, current_platform()) {
202        VersionFit::Unserved(mismatched) => Some(RuntimeError::WitLevelMismatch {
203            name: name.to_string(),
204            version: version.to_string(),
205            level: level.level,
206            mismatched,
207        }),
208        VersionFit::Fits | VersionFit::NoPayloadFor(_) => None,
209    }
210}
211
212/// Checks that every variable `description.required_env` names is declared in
213/// `capabilities.env.allow`, given as `env_allow`. Names match exactly.
214///
215/// The refusal lists every undeclared name, in the driver's order, each once.
216pub fn check_required_env(
217    name: &str,
218    version: &str,
219    description: &Description,
220    env_allow: &[String],
221) -> Result<(), RuntimeError> {
222    let mut missing: Vec<String> = Vec::new();
223    for var in &description.required_env {
224        if !env_allow.contains(var) && !missing.contains(var) {
225            missing.push(var.clone());
226        }
227    }
228    if missing.is_empty() {
229        Ok(())
230    } else {
231        Err(RuntimeError::ProcessDriverRequiredEnvNotAllowed {
232            name: name.to_string(),
233            version: version.to_string(),
234            missing,
235        })
236    }
237}
238
239/// The store a process driver runs in: an empty WASI context and the execution limits.
240struct ProcessDriverStoreState {
241    wasi: WasiCtx,
242    table: ResourceTable,
243    limits: ExecutionLimiter,
244}
245
246impl WasiView for ProcessDriverStoreState {
247    fn ctx(&mut self) -> WasiCtxView<'_> {
248        WasiCtxView {
249            ctx: &mut self.wasi,
250            table: &mut self.table,
251        }
252    }
253}
254
255/// One instantiated process driver. The runtime keeps one for a whole run, so the driver may
256/// carry what `launch` was given into `parse`.
257pub struct ProcessDriver {
258    name: String,
259    version: String,
260    store: Store<ProcessDriverStoreState>,
261    bindings: ProcessDriverBindings,
262}
263
264impl ProcessDriver {
265    /// Instantiates `component` with no grants: a WASI context with no environment, arguments,
266    /// preopened directories, inherited stdio or network, and no Murmur host import.
267    ///
268    /// Each call into the driver is bounded by the hook call limits
269    /// ([`ExecutionLimits::for_hook_calls`] with no declared deadline), so `engine` must have an
270    /// epoch ticker running. `name` and `version` identify the driver in errors. Any failure,
271    /// including an import the empty context does not provide, is
272    /// [`RuntimeError::ProcessDriverLoad`] carrying wasmtime's message.
273    pub async fn instantiate(
274        engine: &Engine,
275        component: &Component,
276        name: &str,
277        version: &str,
278    ) -> Result<Self, RuntimeError> {
279        let load_error = |message: String| RuntimeError::ProcessDriverLoad {
280            name: name.to_string(),
281            version: version.to_string(),
282            message,
283        };
284        let limits = ExecutionLimits::default().for_hook_calls(None);
285
286        let mut linker: Linker<ProcessDriverStoreState> = Linker::new(engine);
287        wasmtime_wasi::p2::add_to_linker_async(&mut linker)
288            .map_err(|e| load_error(e.to_string()))?;
289
290        let state = ProcessDriverStoreState {
291            // The builder's defaults are the grant: no environment, arguments, preopens or
292            // stdio, no name lookup, and a socket address check that refuses every address.
293            wasi: WasiCtxBuilder::new().build(),
294            table: ResourceTable::new(),
295            limits: limits.limiter(),
296        };
297        let mut store = Store::new(engine, state);
298        store.limiter(|state| &mut state.limits);
299        store.set_epoch_deadline(limits.deadline_ticks());
300
301        let bindings = ProcessDriverBindings::instantiate_async(&mut store, component, &linker)
302            .await
303            .map_err(|e| load_error(format!("{e:#}")))?;
304        Ok(Self {
305            name: name.to_string(),
306            version: version.to_string(),
307            store,
308            bindings,
309        })
310    }
311
312    /// Calls the driver's `describe`.
313    ///
314    /// A trap, an exceeded deadline, or a `required-env` entry that is not a usable variable
315    /// name (empty, or containing `=` or NUL) is [`RuntimeError::ProcessDriverLoad`].
316    pub async fn describe(&mut self) -> Result<Description, RuntimeError> {
317        self.reset_deadline();
318        let description = self
319            .bindings
320            .murmur_driver_process()
321            .call_describe(&mut self.store)
322            .await
323            .map_err(|e| {
324                let message = match classify_guest_failure(&e, &self.store.data().limits) {
325                    GuestFailure::DeadlineExceeded { seconds } => {
326                        format!("describe() exceeded its {seconds}s deadline and was interrupted")
327                    }
328                    GuestFailure::ResourceLimit { message } => {
329                        format!("describe() exceeded its resource limits: {message}")
330                    }
331                    GuestFailure::Other => format!("describe() trapped: {e:#}"),
332                };
333                self.load_error(message)
334            })?;
335        if let Some(bad) = unusable_env_name(&description) {
336            return Err(self.load_error(format!(
337                "describe() names {bad:?} in required-env, which is not a usable variable name"
338            )));
339        }
340        Ok(description)
341    }
342
343    /// Asks the driver to plan one harness run. The inner `Err` is the driver's own refusal,
344    /// reported to the operator verbatim; the outer one is a trap or an exceeded deadline.
345    pub async fn launch(
346        &mut self,
347        request: LaunchRequest,
348    ) -> Result<Result<LaunchPlan, String>, RuntimeError> {
349        self.reset_deadline();
350        self.bindings
351            .murmur_driver_process()
352            .call_launch(&mut self.store, &request)
353            .await
354            .map_err(|e| self.call_error("launch", &e))
355    }
356
357    /// Reads one batch of complete stdout lines into events. The same instance serves a whole
358    /// run, so the driver may answer from what `launch` was given.
359    pub async fn parse(&mut self, lines: Vec<String>) -> Result<Vec<Event>, RuntimeError> {
360        self.reset_deadline();
361        self.bindings
362            .murmur_driver_process()
363            .call_parse(&mut self.store, &lines)
364            .await
365            .map_err(|e| self.call_error("parse", &e))
366    }
367
368    /// Asks the driver what a run whose output ended without a terminal event amounts to.
369    pub async fn classify_exit(&mut self, exit: ExitStatus) -> Result<Event, RuntimeError> {
370        self.reset_deadline();
371        self.bindings
372            .murmur_driver_process()
373            .call_classify_exit(&mut self.store, &exit)
374            .await
375            .map_err(|e| self.call_error("classify-exit", &e))
376    }
377
378    /// Gives the next call its own deadline. Without this the ticks the previous call spent
379    /// would count against it, so a long run would eventually interrupt a driver that had done
380    /// nothing wrong.
381    fn reset_deadline(&mut self) {
382        let deadline = self.store.data().limits.limits().deadline_ticks();
383        self.store.set_epoch_deadline(deadline);
384    }
385
386    fn call_error(&self, call: &str, error: &wasmtime::Error) -> RuntimeError {
387        let message = match classify_guest_failure(error, &self.store.data().limits) {
388            GuestFailure::DeadlineExceeded { seconds } => {
389                format!("exceeded its {seconds}s deadline and was interrupted")
390            }
391            GuestFailure::ResourceLimit { message } => {
392                format!("exceeded its resource limits: {message}")
393            }
394            GuestFailure::Other => format!("trapped: {error:#}"),
395        };
396        RuntimeError::ProcessDriverCallFailed {
397            name: self.name.clone(),
398            version: self.version.clone(),
399            call: call.to_string(),
400            message,
401        }
402    }
403
404    fn load_error(&self, message: String) -> RuntimeError {
405        RuntimeError::ProcessDriverLoad {
406            name: self.name.clone(),
407            version: self.version.clone(),
408            message,
409        }
410    }
411}
412
413/// Whether `name` can be set as an environment variable.
414///
415/// A name carrying `=` would set a second variable when an environment is built, and one carrying
416/// NUL truncates it; an empty name is neither. Both places a driver names a variable — its
417/// `required-env`, checked at load, and its launch plan's `env-set`, checked at spawn — hold to
418/// this one rule.
419pub(crate) fn is_usable_env_name(name: &str) -> bool {
420    !name.is_empty() && !name.contains('=') && !name.contains('\0')
421}
422
423/// The first `required-env` name that could not be set as an environment variable, if any.
424/// Rejected where the driver's word is first taken, rather than wherever it is later spent.
425fn unusable_env_name(description: &Description) -> Option<&str> {
426    description
427        .required_env
428        .iter()
429        .find(|var| !is_usable_env_name(var))
430        .map(String::as_str)
431}
432
433/// The name and version the `*_process_driver_wasm` helpers give a driver in errors, since bare
434/// bytes carry no artifact name.
435const STANDALONE_NAME: &str = "<wasm>";
436const STANDALONE_VERSION: &str = "";
437
438/// `wasm` compiled on a fresh engine with its own epoch ticker, and a current-thread runtime to
439/// drive one instance of it on: everything the `*_process_driver_wasm` helpers share.
440struct StandaloneDriver {
441    engine: Engine,
442    component: Component,
443    rt: tokio::runtime::Runtime,
444    _ticker: EpochTicker,
445}
446
447impl StandaloneDriver {
448    fn compile(wasm: &[u8]) -> Result<Self, RuntimeError> {
449        let engine = crate::runtime::build_engine()?;
450        let ticker = EpochTicker::spawn(&engine);
451        let component =
452            Component::new(&engine, wasm).map_err(|e| RuntimeError::ToolComponentCompile {
453                name: STANDALONE_NAME.to_string(),
454                version: STANDALONE_VERSION.to_string(),
455                message: e.to_string(),
456            })?;
457        let rt = tokio::runtime::Builder::new_current_thread()
458            .enable_all()
459            .build()
460            .map_err(|e| {
461                RuntimeError::Runtime(format!("failed to build process driver runtime: {e}"))
462            })?;
463        Ok(StandaloneDriver {
464            engine,
465            component,
466            rt,
467            _ticker: ticker,
468        })
469    }
470
471    async fn instantiate(&self) -> Result<ProcessDriver, RuntimeError> {
472        ProcessDriver::instantiate(
473            &self.engine,
474            &self.component,
475            STANDALONE_NAME,
476            STANDALONE_VERSION,
477        )
478        .await
479    }
480}
481
482/// Compiles `wasm` as a process driver on a fresh engine, instantiates it with no grants, and
483/// returns what its `describe` reports.
484///
485/// For tests and tools that hold a driver's bytes outside any capsule. It blocks on its own
486/// current-thread runtime, so it must not be called from inside one. Errors name the driver as
487/// `<wasm>` with an empty version, since the bytes carry no artifact name.
488pub fn describe_process_driver_wasm(wasm: &[u8]) -> Result<Description, RuntimeError> {
489    let driver = StandaloneDriver::compile(wasm)?;
490    driver.rt.block_on(async {
491        let mut instance = driver.instantiate().await?;
492        instance.describe().await
493    })
494}
495
496/// Compiles `wasm` as a process driver the way [`describe_process_driver_wasm`] does, then calls
497/// `launch` with `request` and `parse` with `lines` on that one instance, as a run would.
498///
499/// Returns the driver's launch answer — its own refusal is the inner `Err` — and the events
500/// `parse` read. `parse` is called whether or not `launch` refused, so a driver's line reading
501/// can be checked on its own. The same blocking and error-naming rules apply.
502pub fn launch_and_parse_process_driver_wasm(
503    wasm: &[u8],
504    request: LaunchRequest,
505    lines: Vec<String>,
506) -> Result<(Result<LaunchPlan, String>, Vec<Event>), RuntimeError> {
507    let driver = StandaloneDriver::compile(wasm)?;
508    driver.rt.block_on(async {
509        let mut instance = driver.instantiate().await?;
510        let plan = instance.launch(request).await?;
511        let events = instance.parse(lines).await?;
512        Ok((plan, events))
513    })
514}
515
516#[cfg(test)]
517mod tests {
518    use murmur_artifact::ContractDirection;
519
520    use super::*;
521
522    fn served() -> &'static WitLevel {
523        crate::served_wit_level()
524    }
525
526    fn contracts(exports: &[&str]) -> WitContracts {
527        WitContracts {
528            exports: exports.iter().map(|e| e.to_string()).collect(),
529            imports: Vec::new(),
530        }
531    }
532
533    #[test]
534    fn process_driver_interface_process_transport_accepts_the_process_export() {
535        let c = contracts(&[PROCESS_DRIVER_IFACE]);
536        assert!(check_driver_interface("process", "d", "1.0.0", Some(&c), served()).is_ok());
537    }
538
539    #[test]
540    fn process_driver_interface_process_transport_refuses_a_tool_export() {
541        let c = contracts(&["murmur:tool/run@0.1.0"]);
542        let err = check_driver_interface("process", "d", "1.0.0", Some(&c), served()).unwrap_err();
543        match &err {
544            RuntimeError::ProcessDriverInterfaceMissing { expected, .. } => {
545                assert_eq!(expected, PROCESS_DRIVER_IFACE);
546            }
547            other => panic!("unexpected error: {other:?}"),
548        }
549        let message = err.to_string();
550        assert!(message.contains("'d@1.0.0'"), "{message}");
551        assert!(message.contains(PROCESS_DRIVER_IFACE), "{message}");
552    }
553
554    #[test]
555    fn process_driver_interface_process_transport_refuses_another_version_as_a_level_mismatch() {
556        let c = contracts(&["murmur:driver/process@0.0.1"]);
557        let err = check_driver_interface("process", "d", "1.0.0", Some(&c), served()).unwrap_err();
558        match &err {
559            RuntimeError::WitLevelMismatch {
560                level, mismatched, ..
561            } => {
562                assert_eq!(*level, served().level);
563                assert_eq!(mismatched.len(), 1);
564                assert_eq!(mismatched[0].interface, "murmur:driver/process@0.0.1");
565                assert_eq!(mismatched[0].direction, ContractDirection::Export);
566            }
567            other => panic!("unexpected error: {other:?}"),
568        }
569        assert_eq!(
570            err.to_string(),
571            format!(
572                "artifact 'd@1.0.0' does not fit WIT level {0}, the level this mur serves: it \
573                 exports murmur:driver/process@0.0.1, and level {0} has murmur:driver@{1}",
574                served().level,
575                served().version_of("murmur:driver").unwrap()
576            )
577        );
578    }
579
580    /// The real level 1 and a synthetic level 2 that moves only `murmur:driver` to 0.4.0.
581    fn level_2() -> WitLevel {
582        let mut level = served().clone();
583        level.level = 2;
584        for (package, version) in &mut level.packages {
585            if package == "murmur:driver" {
586                *version = "0.4.0".to_string();
587            }
588        }
589        level
590    }
591
592    #[test]
593    fn process_driver_interface_follows_the_level_it_is_given() {
594        let c = contracts(&["murmur:driver/process@0.3.0"]);
595        assert!(check_driver_interface("process", "d", "1.0.0", Some(&c), served()).is_ok());
596        match check_driver_interface("process", "d", "1.0.0", Some(&c), &level_2()).unwrap_err() {
597            RuntimeError::WitLevelMismatch {
598                level, mismatched, ..
599            } => {
600                assert_eq!(level, 2);
601                assert_eq!(mismatched[0].direction, ContractDirection::Export);
602                assert_eq!(mismatched[0].package, "murmur:driver");
603                assert_eq!(mismatched[0].served_version.as_deref(), Some("0.4.0"));
604            }
605            other => panic!("unexpected error: {other:?}"),
606        }
607        let stream = importing(&["murmur:stream/events@0.1.0"]);
608        assert!(check_stream_interface("d", "1.0.0", Some(&stream), &level_2()).is_ok());
609    }
610
611    #[test]
612    fn process_driver_interface_process_transport_refuses_no_contracts() {
613        let err = check_driver_interface("process", "d", "1.0.0", None, served()).unwrap_err();
614        assert!(matches!(
615            err,
616            RuntimeError::ProcessDriverInterfaceMissing { .. }
617        ));
618    }
619
620    #[test]
621    fn process_driver_interface_http_transport_refuses_the_process_export() {
622        let c = contracts(&[PROCESS_DRIVER_IFACE]);
623        let err = check_driver_interface("http", "d", "1.0.0", Some(&c), served()).unwrap_err();
624        match &err {
625            RuntimeError::HttpDriverExportsProcessInterface { name, exported, .. } => {
626                assert_eq!(name, "d");
627                assert_eq!(exported, &vec![PROCESS_DRIVER_IFACE.to_string()]);
628            }
629            other => panic!("unexpected error: {other:?}"),
630        }
631    }
632
633    #[test]
634    fn process_driver_interface_http_transport_accepts_a_tool_export() {
635        let c = contracts(&["murmur:tool/run@0.1.0"]);
636        assert!(check_driver_interface("http", "d", "1.0.0", Some(&c), served()).is_ok());
637    }
638
639    fn importing(imports: &[&str]) -> WitContracts {
640        WitContracts {
641            exports: vec!["murmur:tool/run@0.1.0".to_string()],
642            imports: imports.iter().map(|i| i.to_string()).collect(),
643        }
644    }
645
646    #[test]
647    fn stream_interface_refuses_and_lists_a_murmur_text_import() {
648        let c = importing(&[
649            "wasi:cli/environment@0.2.12",
650            "murmur:text/chunks@0.1.0",
651            "murmur:task/task@0.1.0",
652        ]);
653        let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
654        match &err {
655            RuntimeError::WitLevelMismatch {
656                name,
657                version,
658                mismatched,
659                ..
660            } => {
661                assert_eq!((name.as_str(), version.as_str()), ("d", "1.0.0"));
662                assert_eq!(mismatched.len(), 1);
663                assert_eq!(mismatched[0].interface, "murmur:text/chunks@0.1.0");
664                assert_eq!(mismatched[0].served_version, None);
665            }
666            other => panic!("unexpected error: {other:?}"),
667        }
668        let message = err.to_string();
669        assert!(message.contains("'d@1.0.0'"), "{message}");
670        assert!(
671            message.contains(
672                "it imports murmur:text/chunks@0.1.0, and level 1 has no version of murmur:text"
673            ),
674            "{message}"
675        );
676    }
677
678    #[test]
679    fn stream_interface_refuses_another_version_of_murmur_stream() {
680        let c = importing(&["murmur:stream/events@0.2.0"]);
681        let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
682        assert!(matches!(
683            err,
684            RuntimeError::WitLevelMismatch { ref mismatched, .. }
685                if mismatched.len() == 1 && mismatched[0].interface == "murmur:stream/events@0.2.0"
686        ));
687    }
688
689    /// An import at the served version of `murmur:stream` under an interface the package does not
690    /// define fits the level, so it is not a level mismatch: the interface does not exist.
691    #[test]
692    fn stream_interface_refuses_an_unknown_interface_at_the_served_version_as_not_served() {
693        let c = importing(&["murmur:stream/other@0.1.0"]);
694        let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
695        match &err {
696            RuntimeError::DriverStreamInterfaceNotServed {
697                imported, expected, ..
698            } => {
699                assert_eq!(imported, &vec!["murmur:stream/other@0.1.0".to_string()]);
700                assert_eq!(expected, crate::runtime::WIT_STREAM_EVENTS_IFACE);
701            }
702            other => panic!("unexpected error: {other:?}"),
703        }
704        // With a level mismatch beside it, the mismatch is what is reported.
705        let both = importing(&["murmur:stream/other@0.1.0", "murmur:text/chunks@0.1.0"]);
706        assert!(matches!(
707            check_stream_interface("d", "1.0.0", Some(&both), served()).unwrap_err(),
708            RuntimeError::WitLevelMismatch { ref mismatched, .. }
709                if mismatched.len() == 1 && mismatched[0].interface == "murmur:text/chunks@0.1.0"
710        ));
711    }
712
713    #[test]
714    fn driver_refusal_reports_a_level_mismatch_over_any_other() {
715        let not_served = || {
716            check_stream_interface(
717                "d",
718                "1.0.0",
719                Some(&importing(&["murmur:stream/other@0.1.0"])),
720                served(),
721            )
722        };
723        let old_export = || {
724            check_driver_interface(
725                "process",
726                "d",
727                "1.0.0",
728                Some(&contracts(&["murmur:driver/process@0.2.0"])),
729                served(),
730            )
731        };
732        let text_import = || {
733            check_stream_interface(
734                "d",
735                "1.0.0",
736                Some(&importing(&["murmur:text/chunks@0.1.0"])),
737                served(),
738            )
739        };
740        let no_export = || check_driver_interface("process", "d", "1.0.0", None, served());
741
742        assert!(matches!(
743            driver_refusal(not_served(), old_export()),
744            Err(RuntimeError::WitLevelMismatch { .. })
745        ));
746        assert!(matches!(
747            driver_refusal(text_import(), no_export()),
748            Err(RuntimeError::WitLevelMismatch { .. })
749        ));
750        assert!(matches!(
751            driver_refusal(not_served(), no_export()),
752            Err(RuntimeError::DriverStreamInterfaceNotServed { .. })
753        ));
754        match driver_refusal(text_import(), old_export()) {
755            Err(RuntimeError::WitLevelMismatch { mismatched, .. }) => {
756                let names: Vec<&str> = mismatched.iter().map(|m| m.interface.as_str()).collect();
757                assert_eq!(
758                    names,
759                    ["murmur:driver/process@0.2.0", "murmur:text/chunks@0.1.0"]
760                );
761            }
762            other => panic!("unexpected result: {other:?}"),
763        }
764        assert!(driver_refusal(Ok(()), Ok(())).is_ok());
765    }
766
767    #[test]
768    fn stream_interface_accepts_exactly_the_served_instance() {
769        let c = importing(&["murmur:stream/events@0.1.0", "wasi:http/types@0.2.12"]);
770        assert!(check_stream_interface("d", "1.0.0", Some(&c), served()).is_ok());
771    }
772
773    #[test]
774    fn stream_interface_accepts_a_component_with_no_murmur_import() {
775        let c = importing(&["wasi:cli/environment@0.2.12"]);
776        assert!(check_stream_interface("d", "1.0.0", Some(&c), served()).is_ok());
777        assert!(check_stream_interface("d", "1.0.0", Some(&importing(&[])), served()).is_ok());
778    }
779
780    #[test]
781    fn stream_interface_accepts_no_contracts() {
782        assert!(check_stream_interface("d", "1.0.0", None, served()).is_ok());
783    }
784
785    fn description(required_env: &[&str]) -> Description {
786        Description {
787            harness: "h".to_string(),
788            binary: "b".to_string(),
789            version_args: Vec::new(),
790            tested_versions: Vec::new(),
791            interrupt: InterruptMethod::Unsupported,
792            required_env: required_env.iter().map(|v| v.to_string()).collect(),
793            streams_text: false,
794            reports_usage: false,
795        }
796    }
797
798    #[test]
799    fn required_env_all_declared_passes() {
800        let allow = vec!["A".to_string(), "B".to_string(), "C".to_string()];
801        assert!(check_required_env("d", "1.0.0", &description(&["B", "A"]), &allow).is_ok());
802    }
803
804    #[test]
805    fn required_env_reports_missing_names_in_order_once_each() {
806        let allow = vec!["B".to_string()];
807        let err = check_required_env("d", "1.0.0", &description(&["Z", "B", "A", "Z"]), &allow)
808            .unwrap_err();
809        match &err {
810            RuntimeError::ProcessDriverRequiredEnvNotAllowed { missing, .. } => {
811                assert_eq!(missing, &vec!["Z".to_string(), "A".to_string()]);
812            }
813            other => panic!("unexpected error: {other:?}"),
814        }
815        let message = err.to_string();
816        assert!(message.contains("'d@1.0.0'"), "{message}");
817        assert!(message.contains("Z, A"), "{message}");
818    }
819
820    #[test]
821    fn required_env_usable_names_pass() {
822        assert_eq!(
823            unusable_env_name(&description(&["HOME", "PROFILE_2"])),
824            None
825        );
826        assert_eq!(unusable_env_name(&description(&[])), None);
827    }
828
829    #[test]
830    fn required_env_refuses_a_name_that_is_not_a_variable() {
831        for bad in ["", "A=B", "A\0B"] {
832            assert_eq!(
833                unusable_env_name(&description(&["HOME", bad, "TAIL"])),
834                Some(bad),
835                "{bad:?} should be refused"
836            );
837        }
838    }
839
840    /// What one `parse` call costs, and what instantiating a driver costs.
841    ///
842    /// Ignored: it measures rather than asserts, and the numbers only mean anything in a release
843    /// build. Run it with
844    /// `cargo test -p capsule-runtime --release process_driver_parse_cost -- --ignored --nocapture`.
845    #[test]
846    #[ignore = "a measurement, not an assertion; run with --release --ignored --nocapture"]
847    fn process_driver_parse_cost() {
848        use std::time::Instant;
849
850        const WASM: &[u8] = include_bytes!(concat!(
851            env!("CARGO_MANIFEST_DIR"),
852            "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
853        ));
854        let engine = crate::runtime::build_engine().expect("engine");
855        let _ticker = EpochTicker::spawn(&engine);
856        let component = Component::new(&engine, WASM).expect("fixture compiles");
857        let rt = tokio::runtime::Builder::new_current_thread()
858            .enable_all()
859            .build()
860            .expect("runtime");
861
862        let instantiations = 50;
863        let start = Instant::now();
864        for _ in 0..instantiations {
865            rt.block_on(ProcessDriver::instantiate(
866                &engine, &component, "d", "0.1.0",
867            ))
868            .expect("instantiates");
869        }
870        let per_instantiation = start.elapsed() / instantiations;
871
872        let mut driver = rt
873            .block_on(ProcessDriver::instantiate(
874                &engine, &component, "d", "0.1.0",
875            ))
876            .expect("instantiates");
877        for lines in [1usize, 64] {
878            let batch: Vec<String> = (0..lines).map(|i| format!("text line {i}")).collect();
879            let runs = 2_000;
880            let mut samples = Vec::with_capacity(runs);
881            for _ in 0..runs {
882                let start = Instant::now();
883                let events = rt.block_on(driver.parse(batch.clone())).expect("parses");
884                samples.push(start.elapsed());
885                assert_eq!(events.len(), lines);
886            }
887            samples.sort_unstable();
888            let median = samples[samples.len() / 2];
889            let p95 = samples[samples.len() * 95 / 100];
890            crate::diagnostic::raw_to_stdout(&format!(
891                "parse {lines}-line batch on one reused instance: median {median:?}, p95 {p95:?} \
892                 ({runs} calls)\n"
893            ));
894        }
895        crate::diagnostic::raw_to_stdout(&format!(
896            "instantiate one driver: {per_instantiation:?} (mean of {instantiations})\n"
897        ));
898    }
899
900    #[test]
901    fn process_driver_granted_nothing() {
902        let wat = r#"(component
903            (import "murmur:stream/events@0.1.0" (instance
904                (export "emit-chunk" (func (param "chunk" string)))
905            ))
906        )"#;
907        let bytes = wat::parse_str(wat).expect("component WAT parses");
908        let engine = crate::runtime::build_engine().expect("engine");
909        let _ticker = EpochTicker::spawn(&engine);
910        let component = Component::new(&engine, &bytes).expect("component compiles");
911        let rt = tokio::runtime::Builder::new_current_thread()
912            .enable_all()
913            .build()
914            .expect("runtime");
915        let result = rt.block_on(ProcessDriver::instantiate(
916            &engine, &component, "d", "1.0.0",
917        ));
918        match result {
919            Err(RuntimeError::ProcessDriverLoad { name, message, .. }) => {
920                assert_eq!(name, "d");
921                assert!(message.contains("murmur:stream/events@0.1.0"), "{message}");
922            }
923            Err(other) => panic!("unexpected error: {other:?}"),
924            Ok(_) => panic!("a driver importing a Murmur interface instantiated"),
925        }
926    }
927
928    #[test]
929    fn launch_and_parse_run_on_one_instance() {
930        const WASM: &[u8] = include_bytes!(concat!(
931            env!("CARGO_MANIFEST_DIR"),
932            "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
933        ));
934        let request = LaunchRequest {
935            model: None,
936            system_prompt: String::new(),
937            config: None,
938            bridge: Some(Bridge {
939                server_name: "murmur".to_string(),
940                url: "http://127.0.0.1:1/mcp".to_string(),
941                bearer_token: "token".to_string(),
942                tool_names: vec!["python3".to_string()],
943            }),
944            session: Session {
945                id: "s1".to_string(),
946                mode: SessionMode::New,
947            },
948            harness_version: None,
949            task: "probe".to_string(),
950        };
951        let lines = vec![
952            r#"tool c1 murmur__python3 {"command":"x"}"#.to_string(),
953            "end done".to_string(),
954        ];
955        let (plan, events) =
956            launch_and_parse_process_driver_wasm(WASM, request, lines).expect("driver runs");
957        let plan = plan.expect("the fixture accepts a non-empty task");
958        assert!(plan
959            .env_set
960            .iter()
961            .any(|(name, value)| name == "FIXTURE_BRIDGE_TOKEN" && value == "token"));
962        // The fixture strips the `<server-name>__` prefix it remembered in `launch`, so a bare
963        // name here shows `parse` ran on the instance `launch` configured.
964        match &events[..] {
965            [Event::ToolCall(call), Event::TurnEnd(summary)] => {
966                assert_eq!(call.name, "python3");
967                assert_eq!(call.input, r#"{"command":"x"}"#);
968                assert_eq!(summary, "done");
969            }
970            other => panic!("unexpected events: {other:?}"),
971        }
972    }
973}