mounts: accept file sources and resolve relative targets
The is_dir gate dropped files and devices like /dev/null, so masking a
host file (e.g. .envrc) never bound. Relative targets now anchor to the
/workspace mount instead of being passed to Docker as invalid paths.
Assisted-by: Claude Opus 4.8 via Claude Code
diff --git a/README.md b/README.md
index 15178f7..dd094a4 100644
--- a/README.md
+++ b/README.md
@@ -38,13 +38,13 @@ ramekin completions bash > ~/.local/share/bash-completion/completions/ramekin
### Persistence
-The agent directory (`$XDG_CONFIG_HOME/ramekin/agent/`) is mounted into the container at `/root/.pi/agent`. On each run, everything except `auth.json` is cleared and reassembled from pi config entries defined in KDL config files. The `ramekin.ts` extension is always written fresh.
+The agent directory (`$XDG_CONFIG_HOME/ramekin/agent/`) is mounted into the container at `/root/.pi/agent`. On each run, everything except `auth.json` is cleared and reassembled from pi config entries defined in KDL config files. The `ramekin-prompt.md` system prompt is always written fresh.
The full pi data directory (`$XDG_DATA_HOME/ramekin/`) is mounted at `/root/.pi` for auth tokens and session history. Each workspace also gets its own sessions directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
### Volume mounts
-Additional host directories can be mounted into the container via KDL config files. Mounts whose source doesn't exist on the host are silently skipped.
+Additional host paths can be mounted into the container via KDL config files. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
**User config** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
@@ -79,14 +79,23 @@ Each `mounts` block supports:
| Field | Required | Description |
|---|---|---|
| `source` | yes | Host path (`~` expands to home directory) |
-| `target` | no | Container path (derived from source if omitted) |
+| `target` | no | 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` | no | Allow writes (read-only by default) |
+For example, mounting `/dev/null` over `.envrc` masks a host `.envrc` from the agent:
+
+```kdl
+mounts {
+ source "/dev/null"
+ target ".envrc"
+}
+```
+
Mounts are merged across scopes. When both user and project configs define a mount with the same container target, the project mount wins. Builtin mounts (workspace, pi data, agent dir) cannot be overridden.
-### Container environment extension
+### Container environment context
-A built-in pi extension (`ramekin.ts`) is mounted into the agent container. It appends container environment context to the system prompt via `before_agent_start`, telling the agent about the workspace mount, ephemeral filesystem, and networking. AGENTS.md remains fully available for user customization.
+A built-in system prompt (`ramekin-prompt.md`) is written into the agent dir and passed to pi via `--append-system-prompt`. It tells the agent about the container environment — the workspace mount, ephemeral filesystem, and networking. AGENTS.md remains fully available for user customization.
### Custom Dockerfile
diff --git a/src/config.rs b/src/config.rs
index 115f845..4dbccf0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -224,7 +224,7 @@ impl Config {
.wrap_err("failed to parse config file")
}
- /// Resolve all mounts, skipping any whose source directory does not exist.
+ /// Resolve all mounts, skipping any whose source does not exist.
fn resolve_mounts(&self) -> Vec<ResolvedMount> {
self.mounts.iter().filter_map(|m| m.resolve()).collect()
}
@@ -233,16 +233,18 @@ impl Config {
impl Mount {
/// Expand tildes and derive the container target path.
///
- /// Returns `None` if the source directory does not exist on the host.
+ /// 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) -> Option<ResolvedMount> {
let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
- if !expanded.is_dir() {
+ if !expanded.exists() {
return None;
}
let target = match &self.target {
- Some(t) => resolve_container_tilde(t),
- None => resolve_container_tilde(&self.source),
+ Some(t) => resolve_container_target(t),
+ None => resolve_container_target(&self.source),
};
Some(ResolvedMount {
@@ -364,14 +366,24 @@ impl ResolvedMount {
/// image ever switches to a non-root user, update this constant.
const CONTAINER_HOME: &str = "/root";
-/// Replace a leading `~` with the container home directory.
-fn resolve_container_tilde(path: &str) -> String {
+/// Mount point for the workspace inside the agent container. Relative mount
+/// targets are resolved against this so a bare `.envrc` lands next to the
+/// project files. Keep in sync with the workspace builtin mount in `main.rs`.
+pub const CONTAINER_WORKSPACE: &str = "/workspace";
+
+/// Resolve a configured target into an absolute container path.
+///
+/// A leading `~` expands to the container home directory. A relative path is
+/// resolved against the workspace mount. Absolute paths pass through unchanged.
+fn resolve_container_target(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
format!("{CONTAINER_HOME}/{rest}")
} else if path == "~" {
CONTAINER_HOME.to_string()
- } else {
+ } else if path.starts_with('/') {
path.to_string()
+ } else {
+ format!("{CONTAINER_WORKSPACE}/{path}")
}
}
@@ -434,21 +446,26 @@ mod tests {
}
#[test]
- fn resolve_container_tilde_with_subpath() {
+ fn resolve_container_target_with_subpath() {
assert_eq!(
- resolve_container_tilde("~/.config/git"),
+ resolve_container_target("~/.config/git"),
"/root/.config/git"
);
}
#[test]
- fn resolve_container_tilde_bare() {
- assert_eq!(resolve_container_tilde("~"), "/root");
+ fn resolve_container_target_bare() {
+ assert_eq!(resolve_container_target("~"), "/root");
+ }
+
+ #[test]
+ fn resolve_container_target_absolute_unchanged() {
+ assert_eq!(resolve_container_target("/some/path"), "/some/path");
}
#[test]
- fn resolve_container_tilde_absolute_unchanged() {
- assert_eq!(resolve_container_tilde("/some/path"), "/some/path");
+ fn resolve_container_target_relative_uses_workspace() {
+ assert_eq!(resolve_container_target(".envrc"), "/workspace/.envrc");
}
#[test]
@@ -461,6 +478,18 @@ mod tests {
assert!(mount.resolve().is_none());
}
+ #[test]
+ fn resolve_with_file_source_and_relative_target() {
+ let mount = Mount {
+ source: "/dev/null".into(),
+ target: Some(".envrc".into()),
+ writable: false,
+ };
+ let resolved = mount.resolve().unwrap();
+ assert_eq!(resolved.source, PathBuf::from("/dev/null"));
+ assert_eq!(resolved.target, "/workspace/.envrc");
+ }
+
#[test]
fn resolve_with_existing_dir_and_explicit_target() {
let mount = Mount {
diff --git a/src/main.rs b/src/main.rs
index 675aace..d302ab4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -139,7 +139,7 @@ impl Ramekin {
(&pi_data_dir, "/root/.pi"),
(&agent_dir, "/root/.pi/agent"),
(&repo_sessions_dir, "/root/.pi/agent/sessions"),
- (&workspace, "/workspace"),
+ (&workspace, config::CONTAINER_WORKSPACE),
];
let builtin_mounts: Vec<config::ResolvedMount> = builtin_entries
.into_iter()
@@ -428,7 +428,7 @@ impl Ramekin {
// Helpers
// ---------------------------------------------------------------------------
-/// Generate a session ID from the current process ID.
+/// Generate a random session ID for scoping the compose project and cache dir.
fn session_id() -> String {
format!("{:08x}", fastrand::u32(..))
}