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}