1use std::os::fd::OwnedFd;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, SystemTime};
29
30use rustix::fs::{AtFlags, FileType, FlockOperation, Mode, OFlags};
31
32use crate::protected_paths::canonicalize_existing_prefix;
33use crate::workdir_fs::WorkdirRoot;
34
35pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
42
43pub const RUNTIME_DIR_ENV: &str = "MURMUR_RUNTIME_DIR";
47
48pub const RUNTIME_DIR_GUEST_PATH: &str = "murmur-runtime";
51
52#[must_use]
56pub(crate) fn gets_runtime_dir_preopen(
57 runtime: &murmur_artifact::ArtifactRuntime,
58 declares_filesystem_scope: bool,
59) -> bool {
60 *runtime == murmur_artifact::ArtifactRuntime::Tool && !declares_filesystem_scope
61}
62
63pub(crate) fn runtime_dir_preopened_tools<'a>(
67 installed: impl IntoIterator<Item = &'a crate::types::InstalledArtifactSummary>,
68 scoped: impl Fn(&str) -> bool,
69) -> Vec<String> {
70 let mut names: Vec<String> = installed
71 .into_iter()
72 .map(|artifact| {
73 (
74 murmur_artifact::tool_name_of(&artifact.name),
75 &artifact.runtime,
76 )
77 })
78 .filter(|(name, runtime)| gets_runtime_dir_preopen(runtime, scoped(name)))
79 .map(|(name, _)| name.to_string())
80 .collect();
81 names.sort();
82 names.dedup();
83 names
84}
85
86pub const SESSIONS_DIR_NAME: &str = "sessions";
88
89pub const RUNTIME_SUBDIR: &str = "runtime";
91
92pub const DELEGATIONS_SUBDIR: &str = "delegations";
97
98pub const SEALED_ETC_SUBDIR: &str = "sealed-etc";
100
101pub const HARNESS_SUBDIR: &str = "harness";
104
105pub const LOCK_FILE_NAME: &str = ".lock";
107
108pub const STALE_AFTER: Duration = Duration::from_secs(60);
111
112pub(crate) const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
114
115#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct RuntimeDirPlacement {
119 pub sessions_dir: PathBuf,
121 pub records_group: PathBuf,
124 pub workdir: PathBuf,
126 pub sessions_inside_workdir: bool,
129 pub records_inside_workdir: bool,
131}
132
133impl RuntimeDirPlacement {
134 #[must_use]
137 pub fn inside_workdir(&self) -> bool {
138 self.sessions_inside_workdir || self.records_inside_workdir
139 }
140
141 #[must_use]
143 pub fn inside(&self) -> Option<&Self> {
144 self.inside_workdir().then_some(self)
145 }
146
147 #[cfg(test)]
150 pub(crate) fn outside_for_test() -> Self {
151 Self {
152 sessions_dir: PathBuf::from("/home/test/.murmur/sessions"),
153 records_group: PathBuf::from("/home/test/.murmur/records/work"),
154 workdir: PathBuf::from("/work"),
155 sessions_inside_workdir: false,
156 records_inside_workdir: false,
157 }
158 }
159
160 #[must_use]
163 pub fn report_path(&self) -> String {
164 self.sessions_dir
165 .join(SESSION_ID_PLACEHOLDER)
166 .join(RUNTIME_SUBDIR)
167 .display()
168 .to_string()
169 }
170
171 #[must_use]
174 pub fn records_report_path(&self) -> String {
175 self.records_group
176 .join(SESSION_ID_PLACEHOLDER)
177 .display()
178 .to_string()
179 }
180
181 fn inside_subject(&self) -> (String, &'static str) {
187 debug_assert!(self.inside_workdir());
188 let runtime = format!("the runtime directory {}", self.sessions_dir.display());
189 let records = format!("the records directory {}", self.records_report_path());
190 match (self.sessions_inside_workdir, self.records_inside_workdir) {
191 (true, true) => (
192 format!("{runtime} and {records} lie"),
193 "the files the runtime owns — task.md, MURMUR.md, plans and installed tools — \
194 and the records it keeps about the session — its trace, logs, eval scores and \
195 delegation outcome",
196 ),
197 (true, false) => (
198 format!("{runtime} lies"),
199 "the files the runtime owns — task.md, MURMUR.md, plans and installed tools",
200 ),
201 (false, _) => (
202 format!("{records} lies"),
203 "the records the runtime keeps about the session — its trace, logs, eval scores \
204 and delegation outcome",
205 ),
206 }
207 }
208
209 #[must_use]
211 pub(crate) fn inside_shortfall(&self) -> String {
212 let (subject, changes) = self.inside_subject();
213 format!(
214 "{subject} inside the workdir {}, so this capsule's commands can change {changes}, \
215 and no host can back a class above advisory for it; run with a --workdir that does \
216 not contain ~/.murmur",
217 self.workdir.display()
218 )
219 }
220}
221
222#[must_use]
225pub fn runtime_dir_inside_warning(placement: &RuntimeDirPlacement) -> String {
226 use murmur_artifact::{security_warning_link, W_SEC_033};
227 let (subject, changes) = placement.inside_subject();
228 format!(
229 "warning[{W_SEC_033}]: {subject} inside the workdir {}, so this capsule's commands can \
230 change {changes}; run with a --workdir that does not contain ~/.murmur ({})",
231 placement.workdir.display(),
232 security_warning_link(W_SEC_033)
233 )
234}
235
236pub fn sessions_dir() -> Result<PathBuf, crate::murmur_home::HomeUnusable> {
240 Ok(crate::murmur_home::usable_murmur_home()?.join(SESSIONS_DIR_NAME))
241}
242
243pub fn runtime_dir_placement(
250 workdir: &Path,
251) -> Result<RuntimeDirPlacement, crate::murmur_home::HomeUnusable> {
252 let workdir = std::path::absolute(workdir).unwrap_or_else(|_| workdir.to_path_buf());
253 let sessions_dir = sessions_dir()?;
254 let records_group = crate::records_dir::records_group(&workdir)?;
255 let canonical_workdir = canonicalize_existing_prefix(&workdir);
256 let sessions_inside_workdir =
257 canonicalize_existing_prefix(&sessions_dir).starts_with(&canonical_workdir);
258 let records_inside_workdir =
259 canonicalize_existing_prefix(&records_group).starts_with(&canonical_workdir);
260 Ok(RuntimeDirPlacement {
261 sessions_dir,
262 records_group,
263 workdir,
264 sessions_inside_workdir,
265 records_inside_workdir,
266 })
267}
268
269#[derive(Clone, Debug)]
273pub struct RuntimeDirRoots {
274 pub runtime: WorkdirRoot,
276 pub sealed_etc: WorkdirRoot,
278 pub harness: WorkdirRoot,
280 pub records: Option<WorkdirRoot>,
284 pub temp: Option<WorkdirRoot>,
288 pub home: Option<WorkdirRoot>,
292}
293
294impl RuntimeDirRoots {
295 #[must_use]
298 pub fn new(runtime: WorkdirRoot, sealed_etc: WorkdirRoot, harness: WorkdirRoot) -> Self {
299 Self {
300 runtime,
301 sealed_etc,
302 harness,
303 records: None,
304 temp: None,
305 home: None,
306 }
307 }
308
309 #[must_use]
311 pub fn with_records(mut self, records: WorkdirRoot) -> Self {
312 self.records = Some(records);
313 self
314 }
315
316 #[must_use]
318 pub fn with_temp(mut self, temp: WorkdirRoot) -> Self {
319 self.temp = Some(temp);
320 self
321 }
322
323 #[must_use]
325 pub fn with_home(mut self, home: WorkdirRoot) -> Self {
326 self.home = Some(home);
327 self
328 }
329}
330
331pub struct RuntimeDir {
334 session_id: String,
335 sessions: WorkdirRoot,
337 _lock: OwnedFd,
339 roots: RuntimeDirRoots,
340}
341
342impl std::fmt::Debug for RuntimeDir {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 f.debug_struct("RuntimeDir")
345 .field("path", &self.roots.runtime.path())
346 .finish()
347 }
348}
349
350impl RuntimeDir {
351 pub fn create(
358 sessions_path: &Path,
359 session_id: &str,
360 latch_of: &WorkdirRoot,
361 ) -> Result<Self, String> {
362 let located = |path: &Path, reason: String| format!("{}: {reason}", path.display());
363 let home = crate::murmur_home::ensure_murmur_home()?;
364 if sessions_path.parent() != Some(home.as_path()) {
365 return Err(located(
366 sessions_path,
367 format!("is not beneath {}", home.display()),
368 ));
369 }
370 let sessions_path = sessions_path.to_path_buf();
371 crate::murmur_home::hold_private_dir(&sessions_path)
372 .map_err(|reason| located(&sessions_path, reason))?;
373 let sessions = WorkdirRoot::open(&sessions_path).map_err(|error| error.to_string())?;
374 sweep_stale(&sessions, SystemTime::now());
375
376 let session_path = sessions_path.join(session_id);
377 crate::murmur_home::hold_private_dir(&session_path)
378 .map_err(|reason| located(&session_path, reason))?;
379 let session_dir_root = sessions
380 .open_beneath(session_id)
381 .map_err(|error| error.to_string())?
382 .ok_or_else(|| located(&session_path, "vanished after its creation".to_string()))?;
383 let lock = take_lock(&session_dir_root, &session_path)?;
384 for sub in [RUNTIME_SUBDIR, SEALED_ETC_SUBDIR, HARNESS_SUBDIR] {
385 session_dir_root
386 .create_dir_all(sub, PRIVATE_DIR_MODE)
387 .map_err(|error| error.to_string())?;
388 }
389 let open = |sub: &str| {
390 WorkdirRoot::open_sharing_latch_absolute(&session_path.join(sub), latch_of)
391 .map_err(|error| error.to_string())
392 };
393 Ok(Self {
394 session_id: session_id.to_string(),
395 sessions,
396 _lock: lock,
397 roots: RuntimeDirRoots::new(
398 open(RUNTIME_SUBDIR)?,
399 open(SEALED_ETC_SUBDIR)?,
400 open(HARNESS_SUBDIR)?,
401 ),
402 })
403 }
404
405 #[must_use]
407 pub fn path(&self) -> &Path {
408 self.roots.runtime.path()
409 }
410
411 #[must_use]
413 pub fn roots(&self) -> &RuntimeDirRoots {
414 &self.roots
415 }
416}
417
418impl Drop for RuntimeDir {
419 fn drop(&mut self) {
422 if let Err(error) = self.sessions.remove_dir_all(&self.session_id) {
423 if !error.is_not_found() {
424 crate::runtime_err!(
425 "[capsule-runtime] warning: failed to remove the runtime directory {}: {error}",
426 self.sessions.host_path(&self.session_id).display()
427 );
428 }
429 }
430 }
431}
432
433#[derive(Clone, Default)]
437pub(crate) struct SharedRuntimeDir(Arc<Mutex<Option<RuntimeDir>>>);
438
439impl std::fmt::Debug for SharedRuntimeDir {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 f.write_str("SharedRuntimeDir")
442 }
443}
444
445impl SharedRuntimeDir {
446 pub(crate) fn new(dir: Option<RuntimeDir>) -> Self {
447 Self(Arc::new(Mutex::new(dir)))
448 }
449
450 pub(crate) fn remove(&self) {
452 let taken = self
453 .0
454 .lock()
455 .unwrap_or_else(std::sync::PoisonError::into_inner)
456 .take();
457 drop(taken);
458 }
459
460 pub(crate) fn remove_on_drop(&self) -> RemoveRuntimeDirOnDrop {
462 RemoveRuntimeDirOnDrop(self.clone())
463 }
464}
465
466pub(crate) struct RemoveRuntimeDirOnDrop(SharedRuntimeDir);
468
469impl Drop for RemoveRuntimeDirOnDrop {
470 fn drop(&mut self) {
471 self.0.remove();
472 }
473}
474
475fn take_lock(session_dir_root: &WorkdirRoot, session_path: &Path) -> Result<OwnedFd, String> {
477 let lock_path = session_path.join(LOCK_FILE_NAME);
478 let dir = session_dir_root
479 .dir_fd()
480 .map_err(|error| format!("{}: {error}", session_path.display()))?;
481 let fd = rustix::fs::openat(
482 &dir,
483 LOCK_FILE_NAME,
484 OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC,
485 Mode::from_raw_mode(crate::murmur_home::PRIVATE_FILE_MODE as rustix::fs::RawMode),
486 )
487 .map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
488 rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive)
489 .map_err(|errno| format!("{}: {}", lock_path.display(), std::io::Error::from(errno)))?;
490 Ok(fd)
491}
492
493fn sweep_stale(sessions: &WorkdirRoot, now: SystemTime) {
498 let Ok(Some(entries)) = sessions.list_dir("") else {
499 return;
500 };
501 let Ok(dir) = sessions.dir_fd() else {
502 return;
503 };
504 let older_than_floor = |stat: &rustix::fs::Stat| {
505 let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(stat.st_mtime.max(0) as u64);
506 now.duration_since(modified)
507 .is_ok_and(|age| age > STALE_AFTER)
508 };
509 for (name, _) in entries {
510 let Ok(entry_stat) = rustix::fs::statat(&dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)
511 else {
512 continue;
513 };
514 if FileType::from_raw_mode(entry_stat.st_mode) != FileType::Directory {
515 if older_than_floor(&entry_stat) {
516 let _ = sessions.remove_file(&name);
517 }
518 continue;
519 }
520 let lock = rustix::fs::openat(&dir, name.as_str(), DIR_NOFOLLOW, Mode::empty()).and_then(
521 |session| {
522 rustix::fs::openat(
523 &session,
524 LOCK_FILE_NAME,
525 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
526 Mode::empty(),
527 )
528 },
529 );
530 match lock {
531 Ok(lock) => {
532 let free = rustix::fs::flock(&lock, FlockOperation::NonBlockingLockExclusive);
533 let stale = rustix::fs::fstat(&lock).is_ok_and(|stat| older_than_floor(&stat));
534 if free.is_ok() && stale {
535 let _ = sessions.remove_dir_all(&name);
536 }
537 }
538 Err(rustix::io::Errno::NOENT) if older_than_floor(&entry_stat) => {
539 let _ = sessions.remove_dir_all(&name);
540 }
541 Err(_) => {}
542 }
543 }
544}
545
546const DIR_NOFOLLOW: OFlags = OFlags::RDONLY
547 .union(OFlags::DIRECTORY)
548 .union(OFlags::NOFOLLOW)
549 .union(OFlags::CLOEXEC);
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::os::unix::fs::PermissionsExt;
555
556 fn set_mtime(path: &Path, age: Duration) {
557 let file = std::fs::File::open(path).unwrap();
558 file.set_modified(SystemTime::now() - age).unwrap();
559 }
560
561 #[test]
562 fn runtime_dir_sweep_removes_unlocked_stale_and_keeps_locked_and_recent() {
563 let temp = tempfile::tempdir().unwrap();
564 let sessions = temp.path();
565 let make = |name: &str, age: Duration| {
566 let dir = sessions.join(name);
567 std::fs::create_dir_all(dir.join("runtime")).unwrap();
568 std::fs::write(dir.join(LOCK_FILE_NAME), b"").unwrap();
569 set_mtime(&dir.join(LOCK_FILE_NAME), age);
570 dir
571 };
572 let stale = make("ses_stale", Duration::from_secs(61));
573 let locked = make("ses_locked", Duration::from_secs(61));
574 let recent = make("ses_recent", Duration::from_secs(10));
575 let unlocked_dir = sessions.join("ses_nolock");
576 std::fs::create_dir_all(&unlocked_dir).unwrap();
577 set_mtime(&unlocked_dir, Duration::from_secs(61));
578
579 let held = std::fs::File::open(locked.join(LOCK_FILE_NAME)).unwrap();
580 rustix::fs::flock(&held, FlockOperation::NonBlockingLockExclusive).unwrap();
581
582 let root = WorkdirRoot::open(sessions).unwrap();
583 sweep_stale(&root, SystemTime::now());
584
585 assert!(
586 !stale.exists(),
587 "an unlocked 61-second-old directory is removed"
588 );
589 assert!(
590 !unlocked_dir.exists(),
591 "an old directory with no lock is removed"
592 );
593 assert!(locked.exists(), "a locked directory is kept");
594 assert!(recent.exists(), "a 10-second-old directory is kept");
595 drop(held);
596 }
597
598 #[test]
599 fn runtime_dir_placement_resolves_through_the_nearest_existing_ancestor() {
600 let temp = tempfile::tempdir().unwrap();
601 let real = temp.path().join("real");
602 std::fs::create_dir_all(&real).unwrap();
603 let link = temp.path().join("link");
604 std::os::unix::fs::symlink(&real, &link).unwrap();
605
606 let resolved = canonicalize_existing_prefix(&link.join("not-yet").join("deeper"));
607 assert_eq!(
608 resolved,
609 real.canonicalize().unwrap().join("not-yet").join("deeper")
610 );
611 assert!(canonicalize_existing_prefix(&link.join(".murmur/sessions"))
612 .starts_with(canonicalize_existing_prefix(&real)));
613 }
614
615 #[test]
618 fn runtime_dir_placement_judges_the_records_directory_too() {
619 let home = tempfile::tempdir().unwrap();
620 crate::murmur_home::run_with_home(
621 "runtime_dir::tests::runtime_dir_placement_records_inner",
622 home.path(),
623 );
624 }
625
626 #[test]
627 #[ignore = "run by runtime_dir_placement_judges_the_records_directory_too"]
628 fn runtime_dir_placement_records_inner() {
629 if !crate::murmur_home::in_scratch_home() {
630 return;
631 }
632 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
633 let both = runtime_dir_placement(&home).unwrap();
634 assert!(both.sessions_inside_workdir && both.records_inside_workdir);
635 assert!(both.inside_workdir());
636 assert!(runtime_dir_inside_warning(&both).contains("the records directory"));
637
638 let records = home
639 .join(".murmur")
640 .join(crate::records_dir::RECORDS_DIR_NAME);
641 let records_only = runtime_dir_placement(&records).unwrap();
642 assert!(!records_only.sessions_inside_workdir);
643 assert!(records_only.records_inside_workdir && records_only.inside_workdir());
644 let reason = records_only.inside_shortfall();
645 assert!(reason.starts_with("the records directory "), "{reason}");
646 assert!(reason.contains("run with a --workdir that does not contain ~/.murmur"));
647
648 let elsewhere = runtime_dir_placement(&home.join("project/workdir")).unwrap();
649 assert!(!elsewhere.inside_workdir());
650 assert_eq!(
651 elsewhere.records_report_path(),
652 crate::records_dir::records_group(&home.join("project/workdir"))
653 .unwrap()
654 .join("<session-id>")
655 .display()
656 .to_string()
657 );
658 assert!(!home.join(".murmur").exists(), "judging creates nothing");
659 }
660
661 #[test]
662 fn runtime_dir_lock_is_exclusive_across_open_file_descriptions() {
663 let temp = tempfile::tempdir().unwrap();
664 let session = temp.path().join("ses_x");
665 std::fs::create_dir_all(&session).unwrap();
666 let root = WorkdirRoot::open(&session).unwrap();
667 let held = take_lock(&root, &session).unwrap();
668 let mode = std::fs::metadata(session.join(LOCK_FILE_NAME))
669 .unwrap()
670 .permissions()
671 .mode()
672 & 0o777;
673 assert_eq!(mode & 0o077, 0, "the lock file is owner-only");
674 assert!(
675 take_lock(&root, &session).is_err(),
676 "a second lock is refused"
677 );
678 drop(held);
679 assert!(take_lock(&root, &session).is_ok());
680 }
681}