Skip to main content

capsule_runtime/
harness_check.rs

1//! Whether a `transport: process` capsule's harness is installed, at a tested version, and logged
2//! in for this user, asked without launching a session.
3//!
4//! `mur doctor` asks it. Every answer comes from what `mur run` uses: the same driver checks at
5//! staging, the same binary resolution, the same probe environment and bound as the version probe
6//! a run makes. Only the driver knows how its harness reports a login, so the login question is
7//! the driver's `login-check` and `read-login`, never a guess made here.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use crate::agent::process::{
13    harness_base_env, probe_harness_version, run_harness_probe, untested_version_message,
14    version_is_tested, VersionProbe,
15};
16use crate::errors::RuntimeError;
17use crate::process_driver::{
18    check_driver_interface, check_required_env, LoginCheck, LoginOutput, LoginState, ProcessDriver,
19    StandaloneDriver,
20};
21use crate::shell::HostAccountEnv;
22use crate::types::CapabilityPolicy;
23
24/// The longest reason, in characters, a [`HarnessLogin`] carries.
25pub const LOGIN_REASON_MAX_CHARS: usize = 200;
26
27/// The `harness` report `mur run --explain-scope` prints and `session_start.effective_grants`
28/// carries: the harness's needs resolved against the manifest and this host, read-only, with every
29/// need's status. A need the manifest does not allow is `refused` in the report rather than
30/// raising — explain-scope exits 0. A driver that cannot be read (missing, unloadable, wrong WIT
31/// level) is an [`crate::harness_needs::HarnessReport`] with `unresolved` naming why and every
32/// list empty.
33///
34/// `driver_bytes` is the driver artifact's `.mur.zip` bytes; `command` is `inference.command`;
35/// `workdir` is the launch's work folder root. Blocks on its own current-thread runtime.
36pub fn harness_needs_report(
37    driver_bytes: &[u8],
38    name: &str,
39    version: &str,
40    command: Option<&str>,
41    policy: &CapabilityPolicy,
42    workdir: &Path,
43) -> crate::harness_needs::HarnessReport {
44    let driver_ref = format!("{name}@{version}");
45    let unresolved = |reason: String| crate::harness_needs::unresolved_report(&driver_ref, reason);
46    let wasm = match crate::artifact::extract_root_wasm(name, version, driver_bytes) {
47        Ok(wasm) => wasm,
48        Err(error) => return unresolved(error.to_string()),
49    };
50    let contracts = murmur_artifact::extract_wit_contracts(&wasm).ok().flatten();
51    if let Err(error) = check_driver_interface(
52        "process",
53        name,
54        version,
55        contracts.as_ref(),
56        crate::served_wit_level(),
57    ) {
58        return unresolved(error.to_string());
59    }
60    let standalone = match StandaloneDriver::compile_named(&wasm, name, version) {
61        Ok(standalone) => standalone,
62        Err(error) => return unresolved(error.to_string()),
63    };
64    standalone.rt.block_on(async {
65        let description = match async {
66            let mut driver = standalone.instantiate().await?;
67            driver.describe().await
68        }
69        .await
70        {
71            Ok(description) => description,
72            Err(error) => return unresolved(error.to_string()),
73        };
74        let binary = crate::runtime::resolve_harness_binary(command, &description.binary)
75            .ok()
76            .map(|(path, _)| path);
77        crate::harness_needs::resolve_capsule_harness(
78            &driver_ref,
79            &description,
80            binary.as_deref(),
81            policy,
82            workdir,
83        )
84        .grants
85        .report
86    })
87}
88
89/// What [`check_process_harness`] found about a process capsule's harness.
90#[derive(Debug)]
91pub struct HarnessCheck {
92    /// The harness the driver drives, as its `describe()` names it.
93    pub harness: String,
94    /// Where the harness binary resolved to, or the refusal staging would make.
95    pub binary: HarnessBinary,
96    /// What the version probe read; `None` when the binary did not resolve.
97    pub version: Option<HarnessVersion>,
98    /// What the driver made of the login check; `None` when the binary did not resolve.
99    pub login: Option<HarnessLogin>,
100}
101
102/// Where the harness binary resolved to.
103#[derive(Debug)]
104pub enum HarnessBinary {
105    /// The absolute path `mur run` would spawn, and the field that named it: `inference.command`
106    /// or `the process driver's describe()`.
107    Resolved { path: PathBuf, source: String },
108    /// [`RuntimeError::HarnessBinaryNotFound`], as staging builds it (`E-RUN-006`).
109    NotFound(RuntimeError),
110}
111
112/// What the version probe read, judged against the driver's `tested-versions`.
113#[derive(Debug, PartialEq, Eq)]
114pub enum HarnessVersion {
115    /// The line the harness printed names a tested version.
116    Tested { line: String },
117    /// The version is untested or could not be read: the `W-RUN-002` body `mur run` prints.
118    Untested { message: String },
119}
120
121/// Whether the harness is logged in, as the driver reads its login check.
122#[derive(Debug, PartialEq, Eq)]
123pub enum HarnessLogin {
124    LoggedIn,
125    /// The driver's `logged-out` reason, cut to its first line and [`LOGIN_REASON_MAX_CHARS`].
126    LoggedOut {
127        reason: String,
128    },
129    /// Nothing could tell: the driver's `unavailable` or `unknown` reason, the probe's reason
130    /// when the login command could not be run or did not answer, or the driver's failure in
131    /// `read-login`. Cut like [`HarnessLogin::LoggedOut`]'s.
132    NotChecked {
133        reason: String,
134    },
135}
136
137/// Checks the process driver in `driver_bytes` (an artifact's `.mur.zip` bytes) and the harness it
138/// drives on this host.
139///
140/// Errs only where staging would refuse before resolving the binary: the artifact holds no root
141/// wasm, the driver does not export the process interface at the served WIT level, it cannot be
142/// compiled, loaded or described (`E-RUN-032`), or a variable it requires is not in
143/// `capabilities.env.allow`. A binary that does not resolve is a [`HarnessBinary::NotFound`] in
144/// the answer, with no version or login probe run. Otherwise the binary is run twice, the way the
145/// version probe of a run runs it: with the driver's `version-args`, then, when the driver names a
146/// `login-check` command, with those arguments, each with the host values of the
147/// `capabilities.env.allow` names and the host's `USER` and `LOGNAME`, empty stdin, `cwd`, and the
148/// same 10-second bound. What the login command printed goes to the driver and nowhere else.
149///
150/// `name` and `version` name the driver in errors; `command` is the manifest's
151/// `inference.command`. It blocks on its own current-thread runtime, so it must not be called from
152/// inside one.
153pub fn check_process_harness(
154    driver_bytes: &[u8],
155    name: &str,
156    version: &str,
157    command: Option<&str>,
158    policy: &CapabilityPolicy,
159    cwd: &Path,
160) -> Result<HarnessCheck, RuntimeError> {
161    let wasm = crate::artifact::extract_root_wasm(name, version, driver_bytes)?;
162    check_process_harness_wasm(
163        &wasm,
164        name,
165        version,
166        command,
167        &harness_base_env(policy, &HostAccountEnv::from_host()),
168        policy,
169        cwd,
170    )
171}
172
173/// [`check_process_harness`] on the driver's component bytes, with the probe environment given.
174fn check_process_harness_wasm(
175    wasm: &[u8],
176    name: &str,
177    version: &str,
178    command: Option<&str>,
179    probe_env: &BTreeMap<String, String>,
180    policy: &CapabilityPolicy,
181    cwd: &Path,
182) -> Result<HarnessCheck, RuntimeError> {
183    let contracts = murmur_artifact::extract_wit_contracts(wasm).ok().flatten();
184    check_driver_interface(
185        "process",
186        name,
187        version,
188        contracts.as_ref(),
189        crate::served_wit_level(),
190    )?;
191    let standalone = StandaloneDriver::compile_named(wasm, name, version)?;
192    standalone.rt.block_on(async {
193        let mut driver = standalone.instantiate().await?;
194        let description = driver.describe().await?;
195        check_required_env(name, version, &description, &policy.env_allow)?;
196        let harness = description.harness.clone();
197        let resolved = crate::runtime::resolve_harness_binary(command, &description.binary);
198        // The harness's declared needs against the manifest, right after `check_required_env`, as
199        // staging checks them — so `mur doctor` reports a disallowed need with `E-CAP-021` before
200        // any probe runs. Resolved whether or not the binary did, since a need refusal does not
201        // depend on the binary.
202        let grants = crate::harness_needs::resolve_capsule_harness(
203            &format!("{name}@{version}"),
204            &description,
205            resolved.as_ref().ok().map(|(path, _)| path.as_path()),
206            policy,
207            cwd,
208        )
209        .grants;
210        if let Some(refusal) = grants.refusal {
211            return Err(refusal);
212        }
213        let (path, source) = match resolved {
214            Ok(resolved) => resolved,
215            Err(error) => {
216                return Ok(HarnessCheck {
217                    harness,
218                    binary: HarnessBinary::NotFound(error),
219                    version: None,
220                    login: None,
221                })
222            }
223        };
224
225        let probe =
226            probe_harness_version(&path, &description.version_args, probe_env, cwd, None).await;
227        let version = match &probe {
228            VersionProbe::Read(line) if version_is_tested(line, &description.tested_versions) => {
229                HarnessVersion::Tested { line: line.clone() }
230            }
231            _ => HarnessVersion::Untested {
232                message: untested_version_message(&probe, &description.tested_versions),
233            },
234        };
235        let login = check_login(&mut driver, &description.login_check, &path, probe_env, cwd).await;
236        Ok(HarnessCheck {
237            harness,
238            binary: HarnessBinary::Resolved { path, source },
239            version: Some(version),
240            login: Some(login),
241        })
242    })
243}
244
245/// Runs the driver's login check against the resolved `binary` and has the driver read it.
246///
247/// `describe()` refused a `command` with no arguments at load, so the bare binary, which may start
248/// a session, is never run here.
249async fn check_login(
250    driver: &mut ProcessDriver,
251    login_check: &LoginCheck,
252    binary: &Path,
253    probe_env: &BTreeMap<String, String>,
254    cwd: &Path,
255) -> HarnessLogin {
256    let args = match login_check {
257        LoginCheck::Unavailable(reason) => {
258            return HarnessLogin::NotChecked {
259                reason: login_reason(reason),
260            }
261        }
262        LoginCheck::Command(args) => args,
263    };
264    let output = match run_harness_probe(binary, args, probe_env, cwd, None).await {
265        Ok(output) => output,
266        Err(reason) => {
267            return HarnessLogin::NotChecked {
268                reason: login_reason(&reason),
269            }
270        }
271    };
272    let output = LoginOutput {
273        exit_code: output.exit_code,
274        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
275        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
276    };
277    match driver.read_login(output).await {
278        Ok(LoginState::LoggedIn) => HarnessLogin::LoggedIn,
279        Ok(LoginState::LoggedOut(reason)) => HarnessLogin::LoggedOut {
280            reason: login_reason(&reason),
281        },
282        Ok(LoginState::Unknown(reason)) => HarnessLogin::NotChecked {
283            reason: login_reason(&reason),
284        },
285        Err(error) => HarnessLogin::NotChecked {
286            reason: login_reason(&error.to_string()),
287        },
288    }
289}
290
291/// `reason`'s first line, trimmed and cut to [`LOGIN_REASON_MAX_CHARS`] characters: what a driver
292/// says is shown on one line, and a driver that echoed its harness's output into a reason shows at
293/// most the start of it.
294fn login_reason(reason: &str) -> String {
295    let first_line = reason.lines().next().unwrap_or_default().trim();
296    first_line.chars().take(LOGIN_REASON_MAX_CHARS).collect()
297}
298
299#[cfg(test)]
300mod tests {
301    use std::os::unix::fs::PermissionsExt;
302
303    use tempfile::TempDir;
304
305    use super::*;
306
307    /// The fixture process driver: `describe()` names `fixture-cli`, tested version `1.0.0`,
308    /// required `HOME` and `FIXTURE_HARNESS_PROFILE`, and `login-check: command(["auth",
309    /// "status"])`; `read-login` reads `loggedIn` out of a JSON object.
310    const DRIVER: &[u8] = include_bytes!(concat!(
311        env!("CARGO_MANIFEST_DIR"),
312        "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
313    ));
314
315    /// The same driver built with `no-login-check`: `login-check: unavailable(…)`.
316    const NO_LOGIN_CHECK_DRIVER: &[u8] = include_bytes!(concat!(
317        env!("CARGO_MANIFEST_DIR"),
318        "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver-no-login-check.wasm"
319    ));
320
321    /// Sets process environment variables and puts each one back as it was when dropped, also
322    /// when the test panics.
323    struct RestoreEnv(Vec<(&'static str, Option<std::ffi::OsString>)>);
324
325    impl RestoreEnv {
326        fn set(vars: &[(&'static str, &str)]) -> Self {
327            let previous = vars
328                .iter()
329                .map(|(name, value)| {
330                    let old = std::env::var_os(name);
331                    std::env::set_var(name, value);
332                    (*name, old)
333                })
334                .collect();
335            Self(previous)
336        }
337    }
338
339    impl Drop for RestoreEnv {
340        fn drop(&mut self) {
341            for (name, old) in self.0.drain(..).rev() {
342                match old {
343                    Some(value) => std::env::set_var(name, value),
344                    None => std::env::remove_var(name),
345                }
346            }
347        }
348    }
349
350    fn policy() -> CapabilityPolicy {
351        CapabilityPolicy {
352            env_allow: vec!["HOME".to_string(), "FIXTURE_HARNESS_PROFILE".to_string()],
353            ..CapabilityPolicy::default()
354        }
355    }
356
357    /// A harness script at `<dir>/harness` running `body` under bash, mode 0755.
358    fn harness(dir: &TempDir, body: &str) -> PathBuf {
359        let path = dir.path().join("harness");
360        std::fs::write(&path, format!("#!/bin/bash\n{body}\n")).unwrap();
361        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
362        path
363    }
364
365    fn check(
366        wasm: &[u8],
367        binary: &Path,
368        env: &BTreeMap<String, String>,
369        cwd: &Path,
370    ) -> HarnessCheck {
371        check_process_harness_wasm(
372            wasm,
373            "local/fixture-driver",
374            "1.0.0",
375            binary.to_str(),
376            env,
377            &policy(),
378            cwd,
379        )
380        .unwrap()
381    }
382
383    /// Both probes run with exactly the environment `harness_base_env` builds: the script answers
384    /// a tested version and a login only when it sees the account and the declared name, and not
385    /// a variable this process holds but the policy does not declare.
386    #[test]
387    fn harness_check_probes_run_with_the_harness_base_env() {
388        // The process environment is shared by every test thread, and `HOME` is read by many
389        // of them, so this test never changes it: the harness has to see the `HOME` this process
390        // already has. The other two names are read by no other test, and are put back once the
391        // map is built.
392        let home = std::env::var("HOME").expect("the test process has a HOME");
393        let env = {
394            let _restore = RestoreEnv::set(&[
395                ("FIXTURE_HARNESS_PROFILE", "declared"),
396                ("MURMUR_TEST_HARNESS_CHECK_UNDECLARED", "leak"),
397            ]);
398            harness_base_env(
399                &policy(),
400                &HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]),
401            )
402        };
403        let dir = TempDir::new().unwrap();
404        let binary = harness(
405            &dir,
406            &r#"ok=no
407if [ "${USER:-}" = alice ] && [ "${LOGNAME:-}" = alice-login ] \
408   && [ "${FIXTURE_HARNESS_PROFILE:-}" = declared ] && [ "${HOME:-}" = '@HOME@' ] \
409   && [ -z "${MURMUR_TEST_HARNESS_CHECK_UNDECLARED:-}" ]; then ok=yes; fi
410if [ "$1" = auth ] && [ "$2" = status ]; then
411  if [ $ok = yes ]; then echo '{"loggedIn": true}'; else echo '{"loggedIn": false}'; fi
412  exit 0
413fi
414if [ $ok = yes ]; then echo "fixture-harness 1.0.0"; else echo "fixture-harness 0.0.1"; fi"#
415                .replace("@HOME@", &home),
416        );
417
418        let found = check(DRIVER, &binary, &env, dir.path());
419        assert_eq!(found.harness, "fixture-harness");
420        match &found.binary {
421            HarnessBinary::Resolved { path, source } => {
422                assert_eq!(path, &std::fs::canonicalize(&binary).unwrap());
423                assert_eq!(source, "inference.command");
424            }
425            other => panic!("unexpected binary: {other:?}"),
426        }
427        assert_eq!(
428            found.version,
429            Some(HarnessVersion::Tested {
430                line: "fixture-harness 1.0.0".to_string()
431            })
432        );
433        assert_eq!(found.login, Some(HarnessLogin::LoggedIn));
434    }
435
436    /// A logged-out answer carries the driver's reason, and nothing the harness printed.
437    #[test]
438    fn harness_check_reports_the_drivers_logged_out_reason_only() {
439        let dir = TempDir::new().unwrap();
440        let binary = harness(
441            &dir,
442            r#"if [ "$1" = auth ]; then echo '{"loggedIn": false, "token": "secret-77"}'; exit 0; fi
443echo "fixture-harness 1.0.0""#,
444        );
445        let found = check(DRIVER, &binary, &BTreeMap::new(), dir.path());
446        let Some(HarnessLogin::LoggedOut { reason }) = &found.login else {
447            panic!("unexpected login: {:?}", found.login);
448        };
449        assert_eq!(
450            reason,
451            "fixture-harness is not logged in for this user; run fixture-cli auth login"
452        );
453        assert!(!format!("{found:?}").contains("secret-77"));
454    }
455
456    /// A driver whose harness cannot be asked says why, and an answer the driver cannot read is
457    /// not a login.
458    #[test]
459    fn harness_check_cannot_tell_without_a_login_command_or_a_readable_answer() {
460        let dir = TempDir::new().unwrap();
461        let binary = harness(
462            &dir,
463            r#"if [ "$1" = auth ]; then echo 'not json'; exit 0; fi
464echo "fixture-harness 1.0.0""#,
465        );
466        let unavailable = check(NO_LOGIN_CHECK_DRIVER, &binary, &BTreeMap::new(), dir.path());
467        assert_eq!(
468            unavailable.login,
469            Some(HarnessLogin::NotChecked {
470                reason: "fixture-harness has no way to report its login without a turn".to_string()
471            })
472        );
473        let unreadable = check(DRIVER, &binary, &BTreeMap::new(), dir.path());
474        assert_eq!(
475            unreadable.login,
476            Some(HarnessLogin::NotChecked {
477                reason: "fixture-harness printed no loggedIn field".to_string()
478            })
479        );
480    }
481
482    /// A harness that hangs is given up on after the same bound, for the same reason, whichever
483    /// probe asked.
484    #[test]
485    fn harness_check_gives_the_same_timeout_reason_for_login_and_version() {
486        let dir = TempDir::new().unwrap();
487        let binary = harness(&dir, "exec sleep 30");
488        let found = check(DRIVER, &binary, &BTreeMap::new(), dir.path());
489        let reason = "it did not answer within 10s";
490        assert_eq!(
491            found.login,
492            Some(HarnessLogin::NotChecked {
493                reason: reason.to_string()
494            })
495        );
496        let Some(HarnessVersion::Untested { message }) = &found.version else {
497            panic!("unexpected version: {:?}", found.version);
498        };
499        assert_eq!(
500            message,
501            &format!(
502                "the harness version could not be read: {reason}; this process driver is tested \
503                 against 1.0.0"
504            )
505        );
506    }
507
508    /// An untested version is the `W-RUN-002` body a run prints.
509    #[test]
510    fn harness_check_reports_an_untested_version_in_the_runs_words() {
511        let dir = TempDir::new().unwrap();
512        let binary = harness(
513            &dir,
514            r#"if [ "$1" = auth ]; then echo '{"loggedIn": true}'; exit 0; fi
515echo "fixture-harness 0.9.0""#,
516        );
517        let found = check(DRIVER, &binary, &BTreeMap::new(), dir.path());
518        assert_eq!(
519            found.version,
520            Some(HarnessVersion::Untested {
521                message: untested_version_message(
522                    &VersionProbe::Read("fixture-harness 0.9.0".to_string()),
523                    &["1.0.0".to_string()]
524                )
525            })
526        );
527    }
528
529    /// A binary that does not resolve is staging's refusal, and neither probe runs.
530    #[test]
531    fn harness_check_runs_no_probe_for_a_binary_that_does_not_resolve() {
532        let dir = TempDir::new().unwrap();
533        let missing = dir.path().join("no-such-harness");
534        let found = check(DRIVER, &missing, &BTreeMap::new(), dir.path());
535        match &found.binary {
536            HarnessBinary::NotFound(RuntimeError::HarnessBinaryNotFound {
537                binary,
538                binary_source,
539            }) => {
540                assert_eq!(binary, missing.to_str().unwrap());
541                assert_eq!(binary_source, "inference.command");
542            }
543            other => panic!("unexpected binary: {other:?}"),
544        }
545        assert!(found.version.is_none());
546        assert!(found.login.is_none());
547    }
548
549    /// A required variable the policy does not declare is staging's `E-CAP-019` refusal.
550    #[test]
551    fn harness_check_refuses_an_undeclared_required_variable() {
552        let dir = TempDir::new().unwrap();
553        let err = check_process_harness_wasm(
554            DRIVER,
555            "local/fixture-driver",
556            "1.0.0",
557            Some("/bin/true"),
558            &BTreeMap::new(),
559            &CapabilityPolicy::default(),
560            dir.path(),
561        )
562        .unwrap_err();
563        assert!(
564            matches!(err, RuntimeError::ProcessDriverRequiredEnvNotAllowed { .. }),
565            "{err:?}"
566        );
567    }
568
569    #[test]
570    fn harness_check_login_reason_is_one_line_of_at_most_200_chars() {
571        assert_eq!(login_reason("  first line  \nsecond line"), "first line");
572        let long = "é".repeat(300);
573        let cut = login_reason(&long);
574        assert_eq!(cut.chars().count(), LOGIN_REASON_MAX_CHARS);
575        assert_eq!(login_reason(""), "");
576    }
577}