config: merge /dev/null mounts by plain precedence
Two layers masking the same target cancelled each other, so a user
config and a project config both hiding .envrc left it visible. Masks
now win and lose by ordinary precedence, with emission binding an empty
directory where /dev/null cannot.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index 8cc96d4..10e3901 100644
--- a/README.md
+++ b/README.md
@@ -117,21 +117,32 @@ mounts {
}
```
-A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory), with optional properties:
+A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory, and a relative path resolves against the workspace), with optional properties:
| Property | Description |
|---|---|
| `target` | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
| `writable` | `#true` allows writes (read-only by default) |
-When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source *masks*: it removes a mount inherited from a lower layer (say, a staple or an agent-config entry this machine or project doesn't want), and where there's nothing to remove it stays a real bind, blanking a workspace file from the agent:
+When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source follows that same rule and binds nothing readable, which hides whatever the target would otherwise hold — a mount from a lower layer, a file the image ships, or a file in your own workspace. The path still exists in the container; only its contents are gone. Hiding a directory binds a session-scoped empty directory instead, so listing the path succeeds and comes back empty:
```kdl
mounts {
- // Remove the inherited skills mount
+ // Hide an agent-config entry this machine doesn't want
"/dev/null" target="/root/.pi/agent/skills"
// Hide the repo's .envrc from the agent
"/dev/null" target=".envrc"
+ // Same spelling for a directory; the agent sees it empty
+ "/dev/null" target="secrets"
+}
+```
+
+Precedence runs the other way too, so a project can mount back a path the user layer hides:
+
+```kdl
+// <workspace>/.ramekin/config.kdl — this repo's .envrc is fine to read
+mounts {
+ ".envrc"
}
```
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 74bb5e9..d28df0b 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -211,7 +211,7 @@ Layers, lowest to highest precedence:
Merging: `env` merges per variable, overlaying the active profile's env, so
any layer can adjust one variable without redefining the profile; mounts
dedupe by resolved target; profiles by name; scalars last-writer-wins;
-`/dev/null` masking removes an inherited mount. A project `Dockerfile`
+a `/dev/null` source hides a target through that same precedence. A project `Dockerfile`
stays at `.ramekin/Dockerfile`, beside the KDL.
### Session model
diff --git a/src/config.rs b/src/config.rs
index c90972d..484744d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -134,6 +134,10 @@ impl Profile {
}
}
+/// Source that hides a target: binds nothing readable over whatever the
+/// image, the workspace, or a lower layer would have put there.
+pub const MASK_SOURCE: &str = "/dev/null";
+
/// The profile selected in the binary when no layer or flag picks one.
const DEFAULT_PROFILE: &str = "pi";
@@ -154,6 +158,13 @@ pub struct ResolvedMount {
}
impl ResolvedMount {
+ /// A mount that hides its target instead of binding anything readable.
+ /// Merging treats it like any other mount; only emission cares, because
+ /// what a mask binds depends on whether it hides a file or a directory.
+ pub fn is_mask(&self) -> bool {
+ self.source == Path::new(MASK_SOURCE)
+ }
+
/// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
pub fn display_target(&self) -> String {
if self.writable {
@@ -271,12 +282,6 @@ impl ScopedConfig {
}
}
-/// Mask source: a mount whose source is `/dev/null` *removes* an inherited
-/// mount at the same target instead of binding anything. With no inherited
-/// mount to remove, it stays a real `/dev/null` bind, which blanks a file
-/// that exists in the image or workspace.
-const MASK_SOURCE: &str = "/dev/null";
-
impl ScopedConfig {
/// Load all configuration layers for the given workspace.
///
@@ -335,7 +340,7 @@ impl ScopedConfig {
if !files.is_empty() {
let mut builder = LayerBuilder::new(Scope::User, Some(config_dir.to_path_buf()));
for file in &files {
- builder.add_file(file, workspace_target)?;
+ builder.add_file(file, workspace, workspace_target)?;
}
builders.push(builder);
}
@@ -345,7 +350,7 @@ impl ScopedConfig {
let project_path = workspace.join(".ramekin/config.kdl");
if project_path.exists() {
let mut builder = LayerBuilder::new(Scope::Project, Some(project_path.clone()));
- builder.add_file(&project_path, workspace_target)?;
+ builder.add_file(&project_path, workspace, workspace_target)?;
builders.push(builder);
}
@@ -411,7 +416,7 @@ impl ScopedConfig {
mounts: profile
.mounts
.iter()
- .filter_map(|m| m.resolve(workspace_target))
+ .filter_map(|m| m.resolve(workspace, workspace_target))
.collect(),
env: profile.env.clone(),
caches: Vec::new(),
@@ -428,35 +433,43 @@ impl ScopedConfig {
/// Return merged mounts from all layers, de-duplicated by container target.
///
- /// Higher-precedence layers override mounts with the same target, and a
- /// `/dev/null` source masks (removes) a mount inherited from a lower
- /// layer. Output order is lexicographic by target, which puts parent
- /// paths before any child path (`/root/.pi/agent` precedes
+ /// Higher-precedence layers override mounts with the same target,
+ /// including a `/dev/null` mount, which binds nothing readable over the
+ /// target and so hides whatever the image or the workspace has there. A
+ /// project wanting a path a lower layer hid mounts it back, like any
+ /// other override. Output order is lexicographic by target, which puts
+ /// parent paths before any child path (`/root/.pi/agent` precedes
/// `/root/.pi/agent/AGENTS.md`). Docker processes mount declarations in
/// order; a parent declared after its child would shadow the child. The
/// deterministic ordering also keeps repeat runs identical.
pub fn merged_mounts(&self) -> Vec<ScopedValue<&ResolvedMount>> {
- let mut by_target: BTreeMap<&str, (ScopedValue<&ResolvedMount>, bool)> = BTreeMap::new();
+ let mut by_target: BTreeMap<&str, ScopedValue<&ResolvedMount>> = BTreeMap::new();
for layer in &self.layers {
for mount in &layer.mounts {
- let inherited = by_target.contains_key(mount.target.as_str());
by_target.insert(
mount.target.as_str(),
- (
- ScopedValue {
- scope: layer.scope,
- value: mount,
- },
- inherited,
- ),
+ ScopedValue {
+ scope: layer.scope,
+ value: mount,
+ },
);
}
}
- by_target
- .into_values()
- .filter(|(sv, inherited)| !(*inherited && sv.value.source == Path::new(MASK_SOURCE)))
- .map(|(sv, _)| sv)
- .collect()
+ by_target.into_values().collect()
+ }
+
+ /// The host source a mask at `target` hides, when a layer declared one.
+ ///
+ /// Emission needs the shape of what a mask covers, which the merged
+ /// output no longer carries: the mask won precedence, so the mount it
+ /// overrode is gone from the result.
+ pub fn overridden_source(&self, target: &str) -> Option<&Path> {
+ self.layers
+ .iter()
+ .rev()
+ .flat_map(|layer| layer.mounts.iter())
+ .find(|mount| mount.target == target && !mount.is_mask())
+ .map(|mount| mount.source.as_path())
}
/// Merge caches from all layers, de-duplicated by name, so a higher layer
@@ -553,16 +566,22 @@ impl LayerBuilder {
}
}
- fn add_file(&mut self, file: &Path, workspace_target: &str) -> Result<()> {
+ fn add_file(&mut self, file: &Path, workspace: &Path, workspace_target: &str) -> Result<()> {
let raw = parse_file(file)?;
- self.add(raw, file, workspace_target)
+ self.add(raw, file, workspace, workspace_target)
}
- fn add(&mut self, raw: RawConfig, file: &Path, workspace_target: &str) -> Result<()> {
+ fn add(
+ &mut self,
+ raw: RawConfig,
+ file: &Path,
+ workspace: &Path,
+ workspace_target: &str,
+ ) -> Result<()> {
for mount in &raw.mounts {
// Mounts with a missing host source are skipped entirely, so
// they don't participate in duplicate detection either.
- let Some(resolved) = mount.resolve(workspace_target) else {
+ let Some(resolved) = mount.resolve(workspace, workspace_target) else {
continue;
};
ensure!(
@@ -946,13 +965,22 @@ fn binary_mounts(agent: Agent) -> Vec<ResolvedMount> {
}
impl Mount {
- /// Expand tildes and derive the container target path.
+ /// Expand tildes and derive the host source and container target paths.
+ ///
+ /// A relative source resolves against the workspace directory, the way a
+ /// relative target resolves against the workspace mount, so a committed
+ /// project config can name a file in its own repo without a host path.
///
/// Returns `None` if the source does not exist on the host. Files and
/// devices (such as `/dev/null`) resolve like directories — Docker binds
/// them all the same way.
- pub fn resolve(&self, workspace_target: &str) -> Option<ResolvedMount> {
+ pub fn resolve(&self, workspace: &Path, workspace_target: &str) -> Option<ResolvedMount> {
let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
+ let expanded = if expanded.is_relative() {
+ workspace.join(expanded)
+ } else {
+ expanded
+ };
if !expanded.exists() {
return None;
}
@@ -996,6 +1024,9 @@ mod tests {
use super::*;
const WS: &str = "/workspace/test-slug";
+ /// Host workspace for tests that resolve mounts; only relative sources
+ /// touch it, and those tests point it at a temp dir instead.
+ const WS_HOST: &str = "/nonexistent-workspace";
/// A ScopedConfig with the given layers and an inert trivial profile,
/// for tests that only exercise merging.
@@ -1106,8 +1137,12 @@ mod tests {
let mut builder = LayerBuilder::new(Scope::User, None);
let a = parse_config(r#"env { FOO "1" }"#).unwrap();
let b = parse_config(r#"env { FOO "2" }"#).unwrap();
- builder.add(a, Path::new("a.kdl"), WS).unwrap();
- let err = builder.add(b, Path::new("b.kdl"), WS).unwrap_err();
+ builder
+ .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
+ let err = builder
+ .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+ .unwrap_err();
assert!(err.to_string().contains("FOO"), "{err}");
}
@@ -1116,8 +1151,12 @@ mod tests {
let mut builder = LayerBuilder::new(Scope::User, None);
let a = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
let b = parse_config(r#"mounts { "/dev/null" target="/t" }"#).unwrap();
- builder.add(a, Path::new("a.kdl"), WS).unwrap();
- let err = builder.add(b, Path::new("b.kdl"), WS).unwrap_err();
+ builder
+ .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
+ let err = builder
+ .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+ .unwrap_err();
assert!(err.to_string().contains("/t"), "{err}");
}
@@ -1126,8 +1165,12 @@ mod tests {
let mut builder = LayerBuilder::new(Scope::User, None);
let a = parse_config(r#"mounts { "/nonexistent-a" target="/t" }"#).unwrap();
let b = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
- builder.add(a, Path::new("a.kdl"), WS).unwrap();
- builder.add(b, Path::new("b.kdl"), WS).unwrap();
+ builder
+ .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
+ builder
+ .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
let layer = builder.build();
assert_eq!(layer.mounts.len(), 1);
assert_eq!(layer.mounts[0].source, PathBuf::from("/tmp"));
@@ -1166,7 +1209,7 @@ mod tests {
target: None,
writable: false,
};
- assert!(mount.resolve(WS).is_none());
+ assert!(mount.resolve(Path::new(WS_HOST), WS).is_none());
}
#[test]
@@ -1176,11 +1219,27 @@ mod tests {
target: Some(".envrc".into()),
writable: false,
};
- let resolved = mount.resolve(WS).unwrap();
+ let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
assert_eq!(resolved.source, PathBuf::from("/dev/null"));
assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
}
+ #[test]
+ fn relative_source_resolves_against_the_workspace() {
+ let workspace = tempfile::tempdir().unwrap();
+ fs_err::write(workspace.path().join(".envrc"), "export FOO=1").unwrap();
+ let mount = Mount {
+ source: ".envrc".into(),
+ target: None,
+ writable: false,
+ };
+
+ let resolved = mount.resolve(workspace.path(), WS).unwrap();
+
+ assert_eq!(resolved.source, workspace.path().join(".envrc"));
+ assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
+ }
+
#[test]
fn resolve_expands_tilde_in_explicit_target() {
let mount = Mount {
@@ -1188,7 +1247,7 @@ mod tests {
target: Some("~/downloads".into()),
writable: true,
};
- let resolved = mount.resolve(WS).unwrap();
+ let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
assert_eq!(resolved.target, "/root/downloads");
}
@@ -1199,7 +1258,7 @@ mod tests {
target: None,
writable: true,
};
- let resolved = mount.resolve(WS).unwrap();
+ let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
assert_eq!(resolved.target, "/tmp");
}
@@ -1235,7 +1294,9 @@ mod tests {
fn cache_target_resolves_against_the_workspace() {
let raw = parse_config(r#"cache { target "target" }"#).unwrap();
let mut builder = LayerBuilder::new(Scope::Project, None);
- builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+ builder
+ .add(raw, Path::new("c.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
let layer = builder.build();
assert_eq!(layer.caches[0].target, "/workspace/test-slug/target");
}
@@ -1269,7 +1330,9 @@ mod tests {
);
let mut builder = LayerBuilder::new(Scope::Project, None);
- builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+ builder
+ .add(raw, Path::new("c.kdl"), Path::new(WS_HOST), WS)
+ .unwrap();
assert_eq!(
builder.build().caches[0].target,
"/workspace/test-slug/target"
@@ -1415,7 +1478,7 @@ mod tests {
}
#[test]
- fn dev_null_mask_removes_inherited_mount() {
+ fn dev_null_overrides_an_inherited_mount() {
let config = scoped(vec![
layer(
Scope::Binary,
@@ -1427,7 +1490,33 @@ mod tests {
),
]);
let merged = config.merged_mounts();
- assert!(merged.is_empty(), "mask should remove the inherited mount");
+ assert_eq!(merged.len(), 1);
+ assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+ assert_eq!(merged[0].scope, Scope::Project);
+ }
+
+ #[test]
+ fn a_higher_layer_mounts_back_what_a_lower_one_hid() {
+ let config = scoped(vec![
+ layer(
+ Scope::User,
+ vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+ ),
+ layer(
+ Scope::Project,
+ vec![mount(
+ "/host/workspace/.envrc",
+ "/workspace/test-slug/.envrc",
+ false,
+ )],
+ ),
+ ]);
+ let merged = config.merged_mounts();
+ assert_eq!(merged.len(), 1);
+ assert_eq!(
+ merged[0].value.source,
+ PathBuf::from("/host/workspace/.envrc")
+ );
}
#[test]
@@ -1441,6 +1530,23 @@ mod tests {
assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
}
+ #[test]
+ fn stacked_masks_still_blank_the_file() {
+ let config = scoped(vec![
+ layer(
+ Scope::User,
+ vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+ ),
+ layer(
+ Scope::Project,
+ vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+ ),
+ ]);
+ let merged = config.merged_mounts();
+ assert_eq!(merged.len(), 1, "stacked masks must not cancel each other");
+ assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+ }
+
#[test]
fn mount_overriding_mask_survives() {
let config = scoped(vec![
diff --git a/src/main.rs b/src/main.rs
index b0c2004..078be79 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -572,6 +572,20 @@ impl Ramekin {
mounts
}
+ /// Host path a mask at `target` hides, when ramekin can name one: a
+ /// path inside the workspace maps back to the host repo, and anywhere
+ /// else the mount a lower layer declared is what the mask covers.
+ /// Neither exists for a path the image alone supplies.
+ fn hidden_host_path(&self, target: &str) -> Option<PathBuf> {
+ if let Some(rel) = target
+ .strip_prefix(&self.workspace_target)
+ .and_then(|rest| rest.strip_prefix('/'))
+ {
+ return Some(self.workspace.join(rel));
+ }
+ self.config.overridden_source(target).map(Path::to_path_buf)
+ }
+
/// Merge config mounts with the forced ones (session plumbing and
/// caches), ordered lexicographically by target so parents precede
/// children.
@@ -643,14 +657,22 @@ impl Ramekin {
println!();
println!("Mounts");
let scopes: BTreeSet<_> = merged_mounts.iter().map(|sv| sv.scope).collect();
+ // Masks over a directory bind a session-scoped empty dir, so show
+ // that rather than the /dev/null the config file spells.
+ let empty_placeholder = self.cache_dir.join("sessions/<session>/empty");
for scope in scopes {
println!(" {}", scope_label(scope));
for sv in merged_mounts.iter().filter(|sv| sv.scope == scope) {
- println!(
- " {} → {}",
- sv.value.source.display(),
- sv.value.display_target()
- );
+ let hides_a_dir = sv.value.is_mask()
+ && self
+ .hidden_host_path(&sv.value.target)
+ .is_some_and(|path| path.is_dir());
+ let source = if hides_a_dir {
+ &empty_placeholder
+ } else {
+ &sv.value.source
+ };
+ println!(" {} → {}", source.display(), sv.value.display_target());
}
}
}
@@ -816,7 +838,15 @@ impl Ramekin {
fs_err::create_dir_all(&mount.source).into_diagnostic()?;
forced_mounts.push(mount);
}
- let all_mounts = self.final_mounts(&forced_mounts);
+ // A mask over a directory needs an empty directory to bind, not
+ // /dev/null; the dir is session-scoped so nothing can write into it
+ // and have it outlive the run.
+ let empty_dir = session_dir.join("empty");
+ fs_err::create_dir_all(&empty_dir).into_diagnostic()?;
+ let emitted = elide_directory_masks(&self.final_mounts(&forced_mounts), &empty_dir, |t| {
+ self.hidden_host_path(t)
+ });
+ let all_mounts: Vec<&config::ResolvedMount> = emitted.iter().collect();
let env_vars = self.config.merged_env();
let compose = generate_compose(ComposeParams {
dockerfile: &dockerfile,
@@ -1101,6 +1131,31 @@ struct ComposeParams<'a> {
agent_args: &'a [String],
}
+/// Bind an empty directory for masks that hide a directory.
+///
+/// `/dev/null` binds over a file and blanks it, but over a directory Docker
+/// refuses the mount and the run dies at startup. An empty directory elides
+/// the contents while keeping the target the shape callers expect, so
+/// listing it succeeds and comes back empty.
+fn elide_directory_masks(
+ mounts: &[&config::ResolvedMount],
+ empty_dir: &Path,
+ hidden_host_path: impl Fn(&str) -> Option<PathBuf>,
+) -> Vec<config::ResolvedMount> {
+ mounts
+ .iter()
+ .map(|mount| {
+ let mut mount = (*mount).clone();
+ let hides_a_dir = mount.is_mask()
+ && hidden_host_path(&mount.target).is_some_and(|path| path.is_dir());
+ if hides_a_dir {
+ mount.source = empty_dir.to_path_buf();
+ }
+ mount
+ })
+ .collect()
+}
+
/// Generate a Docker Compose config with all volume mounts.
fn generate_compose(params: ComposeParams) -> String {
let ComposeParams {
@@ -1217,6 +1272,54 @@ mod tests {
);
}
+ #[test]
+ fn directory_mask_binds_an_empty_dir() {
+ let workspace = tempfile::tempdir().unwrap();
+ fs_err::create_dir_all(workspace.path().join("node_modules")).unwrap();
+ let mask = config::ResolvedMount {
+ source: PathBuf::from(config::MASK_SOURCE),
+ target: "/workspace/slug/node_modules".to_string(),
+ writable: false,
+ };
+
+ let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| {
+ Some(workspace.path().join("node_modules"))
+ });
+
+ assert_eq!(emitted[0].source, PathBuf::from("/session/empty"));
+ assert_eq!(emitted[0].target, mask.target);
+ }
+
+ #[test]
+ fn file_mask_still_binds_dev_null() {
+ let workspace = tempfile::tempdir().unwrap();
+ fs_err::write(workspace.path().join(".envrc"), "export FOO=1").unwrap();
+ let mask = config::ResolvedMount {
+ source: PathBuf::from(config::MASK_SOURCE),
+ target: "/workspace/slug/.envrc".to_string(),
+ writable: false,
+ };
+
+ let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| {
+ Some(workspace.path().join(".envrc"))
+ });
+
+ assert_eq!(emitted[0].source, PathBuf::from(config::MASK_SOURCE));
+ }
+
+ #[test]
+ fn a_mask_over_nothing_nameable_binds_dev_null() {
+ let mask = config::ResolvedMount {
+ source: PathBuf::from(config::MASK_SOURCE),
+ target: "/root/.config/nowhere".to_string(),
+ writable: false,
+ };
+
+ let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| None);
+
+ assert_eq!(emitted[0].source, PathBuf::from(config::MASK_SOURCE));
+ }
+
#[test]
fn generate_compose_long_form_binds() {
let mount = config::ResolvedMount {