Skip to main content

capsule_runtime/
records_dir.rs

1//! The session's records directory: every record the runtime writes about a session, outside the
2//! workdir the capsule's commands can write, and kept after the session ends.
3//!
4//! ```text
5//! ~/.murmur/records/                              0700
6//! └── <workdir root without its leading />/       0700, each component
7//!     └── <session-id>/                           0700, created at staging, kept after the session
8//!         ├── trace.jsonl
9//!         ├── blobs/<sha256>
10//!         ├── logs/bootstrap.log | otel.log | hook-<name>.log
11//!         ├── logs/shell-<ms>.log | <work-id>.log
12//!         │                                       a shell command's full output
13//!         ├── completion.json                     a delegated child's outcome
14//!         ├── hooks/                              `.` of every hook declared
15//!         │                                       `capabilities.filesystem.records: true`
16//!         ├── out/result.txt | result_<task-id>.txt | compaction-summaries.jsonl
17//!         │                                       an agent's answers and compaction summaries
18//!         ├── tmp/                                `$TMPDIR` of every command
19//!         ├── home/                               `$HOME` of every command, created at staging
20//!         └── children/<capsule>-<16 hex>/        0700, a delegated child's `--workdir`
21//! ```
22//!
23//! The group a session's directory sits in is its **workdir root**: the directory passed to
24//! `mur run --workdir`, otherwise the directory holding the accessible workdir
25//! (`<manifest-dir>/workdir`). Neither is beneath the accessible workdir, so no capsule command
26//! can rename or replace it, and every reader given the same directory computes the same group.
27//!
28//! The Landlock rule and sealed bind that keep it read-only to commands are the runtime
29//! directory's ([`crate::runtime_dir`]). `tmp/` and `home/` are the two exceptions, granted the
30//! work folder's rights by their own rule and bind; [`crate::containment::records_dir_report`] says per tier
31//! what holds. Unlike the runtime directory it is not removed when the session ends;
32//! `trace.retain` ([`crate::retention::prune_sessions`]) prunes it.
33
34use std::path::{Component, Path, PathBuf};
35
36use crate::murmur_home::HomeUnusable;
37use crate::protected_paths::canonicalize_existing_prefix;
38use crate::workdir_fs::WorkdirRoot;
39
40/// `~/.murmur/records`, relative to the murmur home.
41pub const RECORDS_DIR_NAME: &str = "records";
42
43/// The variable naming the records directory in every shell command's, plan shell step's,
44/// `murmur:process` program's and `transport: process` harness's environment. Runtime-owned like
45/// [`crate::runtime_dir::RUNTIME_DIR_ENV`]: no manifest, override or host value can set it.
46pub const RECORDS_DIR_ENV: &str = "MURMUR_RECORDS_DIR";
47
48/// The directory beneath `<session-id>/` a hook declared `capabilities.filesystem.records: true`
49/// is preopened at as `"."`, read-write.
50pub const HOOKS_SUBDIR: &str = "hooks";
51
52/// The directory beneath `<session-id>/` that is `$HOME` for every shell command, plan shell step
53/// and `murmur:process` program, created at staging and granted the work folder's rights.
54pub const HOME_SUBDIR: &str = "home";
55
56/// The directory beneath `<session-id>/` an agent's `result.txt`, `result_<task-id>.txt` and
57/// `compaction-summaries.jsonl` are written to, by the runtime alone.
58pub const RESULTS_SUBDIR: &str = "out";
59
60/// [`RESULTS_SUBDIR`]'s `result.txt`, relative to `<session-id>/`.
61pub const RESULT_FILE: &str = "out/result.txt";
62
63/// The mode every directory beneath `~/.murmur/records` is created at.
64const PRIVATE_DIR_MODE: u32 = crate::murmur_home::MURMUR_HOME_DIR_MODE;
65
66/// `~/.murmur/records`, decided without creating anything.
67pub fn records_dir_root() -> Result<PathBuf, HomeUnusable> {
68    Ok(crate::murmur_home::usable_murmur_home()?.join(RECORDS_DIR_NAME))
69}
70
71/// The directory every session of `workdir_root` keeps its records in:
72/// `~/.murmur/records/<workdir_root, absolute and canonical, without its leading />`. Creates
73/// nothing.
74///
75/// `workdir_root` is made absolute against the current directory and canonicalized through its
76/// nearest existing ancestor, the rest appended, so the runtime at staging and a reader given the
77/// same directory compute the same group whether or not it exists yet.
78pub fn records_group(workdir_root: &Path) -> Result<PathBuf, HomeUnusable> {
79    Ok(records_group_under(
80        &crate::murmur_home::usable_murmur_home()?,
81        workdir_root,
82    ))
83}
84
85/// [`records_group`] beneath `murmur_home` instead of the process's `~/.murmur`, for a caller
86/// that knows the home a session ran under without it being this process's `HOME`.
87pub fn records_group_under(murmur_home: &Path, workdir_root: &Path) -> PathBuf {
88    murmur_home
89        .join(RECORDS_DIR_NAME)
90        .join(mirrored(workdir_root))
91}
92
93/// `workdir_root` as a relative path beneath `~/.murmur/records`: absolute, canonical through its
94/// nearest existing ancestor, and with no `.` or `..` left in it, so the mirror never names a
95/// directory outside the records root.
96fn mirrored(workdir_root: &Path) -> PathBuf {
97    let absolute = std::path::absolute(workdir_root).unwrap_or_else(|_| workdir_root.to_path_buf());
98    let canonical = canonicalize_existing_prefix(&absolute);
99    let mut mirror = PathBuf::new();
100    for component in canonical.components() {
101        match component {
102            Component::Normal(name) => mirror.push(name),
103            Component::ParentDir => {
104                mirror.pop();
105            }
106            Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
107        }
108    }
109    mirror
110}
111
112/// One session's records directory, created at staging and kept after the session.
113#[derive(Debug, Clone)]
114pub struct RecordsDir {
115    /// `<group>/<session-id>`, absolute.
116    path: PathBuf,
117    /// [`Self::path`], held, sharing the accessible root's refusal latch.
118    root: WorkdirRoot,
119}
120
121impl RecordsDir {
122    /// Creates `<group>/<session_id>/` and every directory above it up to `~/.murmur/records` at
123    /// `0700`, with no link followed beneath `~/.murmur/records`, and holds it. Every refusal
124    /// beneath it lands on `latch_of`'s latch and names its path absolutely.
125    ///
126    /// `group` is [`records_group`]'s answer for the session's workdir root, the one the launch
127    /// was judged against.
128    pub fn create(group: &Path, session_id: &str, latch_of: &WorkdirRoot) -> Result<Self, String> {
129        let located = |path: &Path, reason: String| format!("{}: {reason}", path.display());
130        let home = crate::murmur_home::ensure_murmur_home()?;
131        let records = home.join(RECORDS_DIR_NAME);
132        let rel = group
133            .strip_prefix(&records)
134            .map_err(|_| located(group, format!("is not beneath {}", records.display())))?
135            .join(session_id);
136        crate::murmur_home::hold_private_dir(&records)
137            .map_err(|reason| located(&records, reason))?;
138        let records_tree = WorkdirRoot::open(&records).map_err(|error| error.to_string())?;
139        records_tree
140            .create_dir_all(&rel, PRIVATE_DIR_MODE)
141            .map_err(|error| error.to_string())?;
142        let rel_text = rel.to_string_lossy();
143        let walked = records_tree
144            .open_beneath(&rel_text)
145            .map_err(|error| error.to_string())?
146            .ok_or_else(|| located(&records.join(&rel), "vanished after its creation".into()))?;
147        let path = records.join(&rel);
148        let root = WorkdirRoot::open_sharing_latch_absolute(&path, latch_of)
149            .map_err(|error| error.to_string())?;
150        // The held root was opened by path; it must be the directory the no-follow walk created.
151        if root.identity().map_err(|error| error.to_string())?
152            != walked.identity().map_err(|error| error.to_string())?
153        {
154            return Err(located(
155                &path,
156                "changed between its creation and its open".to_string(),
157            ));
158        }
159        Ok(Self { path, root })
160    }
161
162    /// The records directory's absolute path, the value of [`RECORDS_DIR_ENV`].
163    #[must_use]
164    pub fn path(&self) -> &Path {
165        &self.path
166    }
167
168    /// The held directory every runtime record of the session is written through.
169    #[must_use]
170    pub fn root(&self) -> &WorkdirRoot {
171        &self.root
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn records_dir_mirror_drops_the_root_and_every_dot_segment() {
181        let temp = tempfile::tempdir().unwrap();
182        let real = temp.path().canonicalize().unwrap();
183        let mirror = mirrored(&real.join("proj/./workdir/../workdir"));
184        assert_eq!(
185            mirror,
186            real.strip_prefix("/").unwrap().join("proj").join("workdir")
187        );
188        assert!(mirror.is_relative());
189        assert_eq!(mirrored(Path::new("/../../etc")), PathBuf::from("etc"));
190    }
191
192    #[test]
193    fn records_dir_mirror_resolves_a_link_above_the_workdir_root() {
194        let temp = tempfile::tempdir().unwrap();
195        let real = temp.path().join("real");
196        std::fs::create_dir_all(&real).unwrap();
197        let link = temp.path().join("link");
198        std::os::unix::fs::symlink(&real, &link).unwrap();
199        assert_eq!(
200            mirrored(&link.join("workdir")),
201            mirrored(&real.join("workdir"))
202        );
203    }
204
205    /// The group is the workdir root mirrored under `~/.murmur/records`; creating a session's
206    /// directory makes every level `0700`, and a link planted in the mirrored path is refused.
207    #[test]
208    fn records_dir_is_created_owner_only_beneath_the_murmur_home() {
209        let home = tempfile::tempdir().unwrap();
210        crate::murmur_home::run_with_home(
211            "records_dir::tests::records_dir_create_inner",
212            home.path(),
213        );
214    }
215
216    #[test]
217    #[ignore = "run by records_dir_is_created_owner_only_beneath_the_murmur_home"]
218    fn records_dir_create_inner() {
219        if !crate::murmur_home::in_scratch_home() {
220            return;
221        }
222        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
223        let project = home.join("proj");
224        std::fs::create_dir_all(&project).unwrap();
225        let group = records_group(&project.join("workdir")).unwrap();
226        assert_eq!(
227            group,
228            home.join(".murmur/records")
229                .join(mirrored(&project.join("workdir")))
230        );
231        let accessible = WorkdirRoot::open(&project).unwrap();
232        let created = RecordsDir::create(&group, "ses_0001", &accessible).unwrap();
233        assert_eq!(created.path(), group.join("ses_0001"));
234        let mut level = created.path().to_path_buf();
235        while level != home.join(".murmur") {
236            assert_eq!(
237                crate::murmur_home::mode_of(&level),
238                0o700,
239                "{}",
240                level.display()
241            );
242            level.pop();
243        }
244        created
245            .root()
246            .write_file("trace.jsonl", b"{}\n", crate::workdir_fs::DEFAULT_FILE_MODE)
247            .unwrap();
248
249        let elsewhere = home.join("elsewhere");
250        std::fs::create_dir_all(&elsewhere).unwrap();
251        let planted_group = records_group(&home.join("planted/workdir")).unwrap();
252        std::fs::create_dir_all(planted_group.parent().unwrap()).unwrap();
253        std::os::unix::fs::symlink(&elsewhere, &planted_group).unwrap();
254        assert!(RecordsDir::create(&planted_group, "ses_0002", &accessible).is_err());
255        assert!(!elsewhere.join("ses_0002").exists());
256    }
257}