1use 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
24pub const LOGIN_REASON_MAX_CHARS: usize = 200;
26
27pub 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#[derive(Debug)]
91pub struct HarnessCheck {
92 pub harness: String,
94 pub binary: HarnessBinary,
96 pub version: Option<HarnessVersion>,
98 pub login: Option<HarnessLogin>,
100}
101
102#[derive(Debug)]
104pub enum HarnessBinary {
105 Resolved { path: PathBuf, source: String },
108 NotFound(RuntimeError),
110}
111
112#[derive(Debug, PartialEq, Eq)]
114pub enum HarnessVersion {
115 Tested { line: String },
117 Untested { message: String },
119}
120
121#[derive(Debug, PartialEq, Eq)]
123pub enum HarnessLogin {
124 LoggedIn,
125 LoggedOut {
127 reason: String,
128 },
129 NotChecked {
133 reason: String,
134 },
135}
136
137pub 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
173fn 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 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
245async 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
291fn 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 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 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 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 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 #[test]
387 fn harness_check_probes_run_with_the_harness_base_env() {
388 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 #[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 #[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 #[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 #[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 #[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 #[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}