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