Implement step 1 of the config redesign: pi session model
Rework the session model per docs/config-redesign.md, replacing
copy-and-clear assembly with mounts so concurrent sessions can't yank
files from under each other:
- Pi state is ephemeral by default: each session mounts a fresh empty
writable dir at /root/.pi/agent, with the allowlisted persistent
pieces bound on top — auth.json (global, now at
$XDG_DATA_HOME/ramekin/agents/pi/, migrated from the old config-dir
location) and the per-repo sessions/ dir.
- Host agent config replaces the pi {} block: the config-shaped
entries of ~/.pi/agent/ (AGENTS.md, skills/) mount read-only at
their normal paths, canonicalized and skipped when missing. In-
container edits to config fail loudly instead of vanishing.
- Staples (~/.config/git, ~/.config/jj) move into the binary as the
lowest config layer, overridable by user/project KDL; a /dev/null
source masks (removes) an inherited mount.
- Workspaces mount at /workspace/<slug> so cwd-keyed agent state
never collides across repos; the rendered prompt is templated with
the workspace path and mounted read-only.
- Teardown logs anything the agent wrote to its session-scoped dir
before discarding it, the learning loop for refining the
persistence allowlist.
Harvested from the claude-code branch: long-form compose binds,
deterministic parent-before-child mount ordering, per-slug workspace
mounts, and a side-effect-free ramekin config (resolve computes,
prepare materializes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
diff --git a/AGENTS.md b/AGENTS.md
index 4687527..624c61a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Ramekin is a containerized harness for running the [pi coding agent](https://git
Cargo.toml # Single-crate workspace
src/
main.rs # CLI: builds image, generates compose, starts container, attaches
- config.rs # KDL config loading, mount resolution, pi assembly
+ config.rs # KDL config layers, mount resolution and merging
build.rs # Sets RAMEKIN_VERSION from env or git rev
assets/
Dockerfile # Agent container image (Node.js + pi + jj + Rust)
@@ -42,11 +42,14 @@ just # All four
## Architecture notes
-- Docker compose config is generated at runtime, not a static file. The `generate_compose` function builds a YAML string from resolved paths and volume mounts.
-- XDG directories under the `ramekin` prefix store pi state: data in `$XDG_DATA_HOME/ramekin`, config in `$XDG_CONFIG_HOME/ramekin`.
-- Each workspace gets a per-repo sessions directory keyed by a `<dirname>-<hash>` slug.
+- The config redesign is documented in `docs/config-redesign.md`; the session model, layer structure, and persistence policy below implement its step 1 (pi only).
+- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), which sidesteps the colon-delimited `source:target[:ro]` format and its quoting hazards. Mounts are ordered lexicographically by target so parents precede children.
+- Config merges three layers, lowest precedence first: binary (compiled-in staples `~/.config/git`/`~/.config/jj` plus the pi agent-config allowlist `~/.pi/agent/{AGENTS.md,skills}`, mounted read-only, canonicalized, skip-if-missing), user (`~/.config/ramekin/config.kdl`), and project (`<workspace>/.ramekin/config.kdl`). A `/dev/null` source masks (removes) a mount inherited from a lower layer.
+- Pi state is ephemeral by default: each session mounts a fresh empty dir at `/root/.pi/agent`, with persistent pieces bound on top — `auth.json` from `$XDG_DATA_HOME/ramekin/agents/pi/`, per-repo `sessions/` from `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/` (slug is `<dirname>-<hash>`). On teardown, anything else the agent wrote to the session dir is logged before being discarded.
+- Each workspace mounts at `/workspace/<slug>` (never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
+- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); `Ramekin::prepare`, called from `run`, creates directories and initializes/migrates `auth.json`.
- If the workspace contains `.ramekin/Dockerfile`, the CLI builds it on top of `ramekin-agent` instead of using the base image directly.
-- The `ramekin-prompt.md` file is written into the agent config directory on every run and passed to pi via `--append-system-prompt`. It injects container environment context into the system prompt.
+- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only into the agent dir, and passed to pi via `--append-system-prompt`.
- Version is set at build time via the `RAMEKIN_VERSION` env var (used by CI) or falls back to `dev+<short-sha>`.
## Dependencies
diff --git a/README.md b/README.md
index dd094a4..f8db01c 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,12 @@ A Rust CLI orchestrates a Docker Compose stack. On each run it:
1. Creates XDG directories for persistent state
2. Writes the embedded Dockerfile to `$XDG_CACHE_HOME/ramekin/`
-3. Generates a compose config and writes it to a session-scoped cache directory
+3. Generates a compose config, renders the system prompt, and creates a fresh agent dir, all in a session-scoped cache directory
4. Builds the agent image (and a project-specific layer, if one exists)
-5. Starts the agent container with the workspace mounted at `/workspace`
-6. Attaches interactively, then tears down on exit
+5. Starts the agent container with the workspace mounted at `/workspace/<slug>` (where `<slug>` is `<dirname>-<hash>`, so cwd-keyed agent state never collides across repos)
+6. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dir before discarding it
+
+Concurrent sessions don't interfere: everything a run touches is either read-only config, session-scoped plumbing under a random session id, or agent state the agent itself manages concurrently.
### Subcommands
@@ -36,27 +38,34 @@ ramekin completions zsh > ~/.zfunc/_ramekin
ramekin completions bash > ~/.local/share/bash-completion/completions/ramekin
```
+### Agent config
+
+Pi's config comes from the host's own agent dir: the config-shaped entries of `~/.pi/agent/` (`AGENTS.md`, `skills/`) mount read-only at their normal paths inside the container. Ramekin keeps no parallel copy — edit the host files (or the dotfiles they symlink to) and the next session sees the changes. The rest of `~/.pi/agent/` is host runtime state (credentials, session history) and never enters the container.
+
+Config is immutable from inside the container by design: in-container edits to it fail loudly instead of silently disappearing.
+
### 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-prompt.md` system prompt is always written fresh.
+Pi's container state is ephemeral by default: each session gets a fresh, empty writable dir at `/root/.pi/agent`, discarded on teardown. What persists is allowlisted and bind-mounted on top:
-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/`.
+- `auth.json` — global, at `$XDG_DATA_HOME/ramekin/agents/pi/auth.json`, so the containerized agent keeps its own credentials (separate from the host's)
+- `sessions/` — per-repo, at `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`
+
+On teardown, ramekin logs anything else the agent wrote to its session dir before discarding it, so a path that deserves persistence gets noticed rather than silently vanishing.
### Volume mounts
-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.
+Mount configuration merges across three layers, lowest to highest precedence:
+
+1. **Binary** — compiled-in staples (`~/.config/git`, `~/.config/jj`, read-only, skipped when missing) and the pi agent-config mounts described above
+2. **User** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
+3. **Project** — `<workspace>/.ramekin/config.kdl`
+
+Additional host paths can be mounted into the container via the KDL layers. 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`
```kdl
-// Mount git and jj config (read-only by default)
-mounts {
- source "~/.config/git"
-}
-mounts {
- source "~/.config/jj"
-}
-
// Mount ranger database (writable)
mounts {
source "~/.local/share/ranger"
@@ -79,23 +88,30 @@ Each `mounts` block supports:
| Field | Required | Description |
|---|---|---|
| `source` | yes | Host path (`~` expands to home directory) |
-| `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 |
+| `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:
+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:
```kdl
+// Remove the inherited skills mount
+mounts {
+ source "/dev/null"
+ target "/root/.pi/agent/skills"
+}
+
+// Hide the repo's .envrc from the agent
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.
+Session mounts (the workspace, the agent dir plumbing, `auth.json`, `sessions/`, the rendered prompt) are forced and cannot be overridden from config.
### Container environment context
-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.
+A built-in system prompt (`ramekin-prompt.md`) is rendered per session, mounted read-only 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, read-only config, and networking. AGENTS.md remains fully available for user customization.
### Custom Dockerfile
diff --git a/assets/ramekin-prompt.md b/assets/ramekin-prompt.md
index 83e824f..8c8efde 100644
--- a/assets/ramekin-prompt.md
+++ b/assets/ramekin-prompt.md
@@ -4,11 +4,13 @@ You are running inside a Docker container managed by **ramekin**.
## Workspace
-The project workspace is bind-mounted at `/workspace`. This is the only directory where your changes are visible to the host.
+The project workspace is bind-mounted at `{{WORKSPACE_PATH}}` (the container starts there). This is the only directory where your changes are visible to the host.
## Filesystem
-The container filesystem is ephemeral. Any files written outside `/workspace` will be lost when the session ends. System packages installed with `apt-get` do not persist across sessions — use a custom `.ramekin/Dockerfile` to add permanent dependencies.
+The container filesystem is ephemeral. Any files written outside `{{WORKSPACE_PATH}}` will be lost when the session ends. System packages installed with `apt-get` do not persist across sessions — use a custom `.ramekin/Dockerfile` to add permanent dependencies.
+
+Agent configuration (`AGENTS.md`, `skills/`, and the like) is mounted read-only by design; edits to it inside the container fail. If a config change seems worthwhile, tell the user about it instead.
## Networking
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 91e00cc..2395589 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -1,6 +1,7 @@
# Config redesign
-Status: proposal (2026-07). Ramekin is single-user; this design leans on that
+Status: in progress (2026-07) — step 1 of the sequencing (session model on
+pi) is implemented. Ramekin is single-user; this design leans on that
hard. The `claude-code` branch is a prototype to learn from, not a baseline
to preserve.
diff --git a/src/config.rs b/src/config.rs
index 4dbccf0..3a85e92 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::path::{Path, PathBuf};
@@ -10,17 +10,9 @@ pub struct Config {
#[serde(default)]
pub mounts: Vec<Mount>,
#[serde(default)]
- pub pi: Vec<PiEntry>,
- #[serde(default)]
pub env: HashMap<String, String>,
}
-#[derive(Debug, PartialEq, Serialize, Deserialize)]
-pub struct PiEntry {
- pub source: String,
- pub target: Option<String>,
-}
-
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Mount {
pub source: String,
@@ -35,20 +27,20 @@ pub struct Mount {
/// Configuration scope, ordered from lowest to highest precedence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Scope {
+ /// Compiled into the binary: staples and host agent-config mounts.
+ Binary,
/// User-level `~/.config/ramekin/config.kdl`.
User,
/// Project-level `<workspace>/.ramekin/config.kdl`.
Project,
- /// Internal mounts managed by ramekin (highest precedence).
- Builtin,
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
+ Self::Binary => write!(f, "binary"),
Self::User => write!(f, "user"),
Self::Project => write!(f, "project"),
- Self::Builtin => write!(f, "builtin"),
}
}
}
@@ -57,10 +49,9 @@ impl fmt::Display for Scope {
#[derive(Debug)]
pub struct ConfigLayer {
pub scope: Scope,
- /// `None` for the default scope; `Some(path)` for file-backed scopes.
+ /// `None` for the binary scope; `Some(path)` for file-backed scopes.
pub path: Option<PathBuf>,
pub mounts: Vec<ResolvedMount>,
- pub pi: Vec<PiEntry>,
pub env: HashMap<String, String>,
}
@@ -80,71 +71,64 @@ pub struct ScopedConfig {
pub layers: Vec<ConfigLayer>,
}
+/// 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 {
/// Return merged mounts from all layers, de-duplicated by container target.
///
- /// Higher-precedence layers override mounts with the same 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
+ /// `/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>> {
- self.layers
- .iter()
- .flat_map(|layer| {
- layer.mounts.iter().map(move |mount| {
+ let mut by_target: BTreeMap<&str, (ScopedValue<&ResolvedMount>, bool)> = 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(),
(
- &mount.target,
ScopedValue {
scope: layer.scope,
value: mount,
},
- )
- })
- })
- .collect::<HashMap<_, _>>()
- .into_values()
- .collect()
- }
-
- /// Merge pi entries from all layers, de-duplicated by resolved target.
- ///
- /// Higher-precedence layers override entries with the same target.
- pub fn merged_pi(&self) -> Vec<ScopedValue<&PiEntry>> {
- self.layers
- .iter()
- .flat_map(|layer| {
- layer.pi.iter().map(move |entry| {
- (
- entry.resolve().target,
- ScopedValue {
- scope: layer.scope,
- value: entry,
- },
- )
- })
- })
- .collect::<HashMap<_, _>>()
+ inherited,
+ ),
+ );
+ }
+ }
+ by_target
.into_values()
+ .filter(|(sv, inherited)| !(*inherited && sv.value.source == Path::new(MASK_SOURCE)))
+ .map(|(sv, _)| sv)
.collect()
}
/// Merge environment variables from all layers, de-duplicated by name.
///
/// Higher-precedence layers override variables with the same name.
+ /// Output order is lexicographic by name for reproducibility.
pub fn merged_env(&self) -> Vec<ScopedValue<(&str, &str)>> {
- self.layers
- .iter()
- .flat_map(|layer| {
- layer.env.iter().map(move |(name, value)| {
- (
- name.as_str(),
- ScopedValue {
- scope: layer.scope,
- value: (name.as_str(), value.as_str()),
- },
- )
- })
- })
- .collect::<HashMap<_, _>>()
- .into_values()
- .collect()
+ let mut by_name: BTreeMap<&str, ScopedValue<(&str, &str)>> = BTreeMap::new();
+ for layer in &self.layers {
+ for (name, value) in &layer.env {
+ by_name.insert(
+ name.as_str(),
+ ScopedValue {
+ scope: layer.scope,
+ value: (name.as_str(), value.as_str()),
+ },
+ );
+ }
+ }
+ by_name.into_values().collect()
}
}
@@ -156,17 +140,82 @@ pub struct ResolvedMount {
pub writable: bool,
}
+impl ResolvedMount {
+ /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
+ pub fn display_target(&self) -> String {
+ if self.writable {
+ self.target.clone()
+ } else {
+ format!("{} (ro)", self.target)
+ }
+ }
+}
+
+/// Staple mounts every machine gets: read-only, skipped when missing on the
+/// host, overridable (or maskable) by any config layer. The bar for a staple
+/// is "true on every machine".
+const STAPLES: &[&str] = &["~/.config/git", "~/.config/jj"];
+
+/// Host directory where pi keeps its agent config and state.
+const HOST_PI_AGENT_DIR: &str = "~/.pi/agent";
+
+/// The config-shaped entries of the host's pi agent dir. Only these mount
+/// into the container (read-only); the rest of the dir is runtime state —
+/// credentials, session history — which must not leak in.
+pub const PI_AGENT_CONFIG: &[&str] = &["AGENTS.md", "skills"];
+
+/// Pi's agent dir inside the container.
+pub const PI_AGENT_DIR: &str = "/root/.pi/agent";
+
+/// Mounts compiled into the binary: staples plus the host's pi agent config.
+///
+/// Sources are canonicalized because agent dirs and staples commonly symlink
+/// into dotfiles, and bind sources need real paths. Missing entries are
+/// skipped.
+fn binary_mounts() -> Vec<ResolvedMount> {
+ let staples = STAPLES
+ .iter()
+ .map(|source| ((*source).to_string(), resolve_container_target(source, "")));
+ let agent_config = PI_AGENT_CONFIG.iter().map(|entry| {
+ (
+ format!("{HOST_PI_AGENT_DIR}/{entry}"),
+ format!("{PI_AGENT_DIR}/{entry}"),
+ )
+ });
+
+ staples
+ .chain(agent_config)
+ .filter_map(|(source, target)| {
+ let expanded = PathBuf::from(shellexpand::tilde(&source).as_ref());
+ let canonical = expanded.canonicalize().ok()?;
+ Some(ResolvedMount {
+ source: canonical,
+ target,
+ writable: false,
+ })
+ })
+ .collect()
+}
+
impl Config {
/// Load all configuration layers for the given workspace.
///
+ /// `workspace_target` is the container path of the workspace mount;
+ /// relative mount targets resolve against it.
+ ///
/// Layers are returned in precedence order (lowest first):
- /// 1. User (`~/.config/ramekin/config.kdl`) — only if the file exists
- /// 2. Project (`<workspace>/.ramekin/config.kdl`) — only if the file exists
- /// 3. Builtin (internal mounts passed by the caller)
+ /// 1. Binary (staples and host agent-config mounts)
+ /// 2. User (`~/.config/ramekin/config.kdl`) — only if the file exists
+ /// 3. Project (`<workspace>/.ramekin/config.kdl`) — only if the file exists
///
/// Returns an error if a config file exists but can't be parsed.
- pub fn load(workspace: &Path, builtin_mounts: Vec<ResolvedMount>) -> Result<ScopedConfig> {
- let mut layers = Vec::new();
+ pub fn load(workspace: &Path, workspace_target: &str) -> Result<ScopedConfig> {
+ let mut layers = vec![ConfigLayer {
+ scope: Scope::Binary,
+ path: None,
+ mounts: binary_mounts(),
+ env: HashMap::new(),
+ }];
// User layer
let xdg = xdg::BaseDirectories::with_prefix("ramekin");
@@ -181,8 +230,7 @@ impl Config {
layers.push(ConfigLayer {
scope: Scope::User,
path: Some(user_path),
- mounts: config.resolve_mounts(),
- pi: config.pi,
+ mounts: config.resolve_mounts(workspace_target),
env: config.env,
});
}
@@ -196,21 +244,11 @@ impl Config {
layers.push(ConfigLayer {
scope: Scope::Project,
path: Some(project_path),
- mounts: config.resolve_mounts(),
- pi: config.pi,
+ mounts: config.resolve_mounts(workspace_target),
env: config.env,
});
}
- // Builtin layer (always present, highest precedence)
- layers.push(ConfigLayer {
- scope: Scope::Builtin,
- path: None,
- mounts: builtin_mounts,
- pi: vec![],
- env: HashMap::new(),
- });
-
Ok(ScopedConfig { layers })
}
@@ -225,8 +263,11 @@ impl Config {
}
/// 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()
+ fn resolve_mounts(&self, workspace_target: &str) -> Vec<ResolvedMount> {
+ self.mounts
+ .iter()
+ .filter_map(|m| m.resolve(workspace_target))
+ .collect()
}
}
@@ -236,15 +277,15 @@ impl Mount {
/// 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> {
+ pub fn resolve(&self, workspace_target: &str) -> Option<ResolvedMount> {
let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
if !expanded.exists() {
return None;
}
let target = match &self.target {
- Some(t) => resolve_container_target(t),
- None => resolve_container_target(&self.source),
+ Some(t) => resolve_container_target(t, workspace_target),
+ None => resolve_container_target(&self.source, workspace_target),
};
Some(ResolvedMount {
@@ -255,127 +296,16 @@ impl Mount {
}
}
-/// A pi entry with its source path expanded and target resolved.
-#[derive(Debug)]
-pub struct ResolvedPiEntry {
- pub source: PathBuf,
- /// Target path relative to the agent dir.
- pub target: String,
-}
-
-impl PiEntry {
- /// Expand tildes and resolve the target.
- ///
- /// Uses the explicit target when set, otherwise falls back to
- /// the source's basename.
- pub fn resolve(&self) -> ResolvedPiEntry {
- let source = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
- let target = self.target.clone().unwrap_or_else(|| {
- source
- .file_name()
- .map(|n| n.to_string_lossy().into_owned())
- .unwrap_or_default()
- });
- ResolvedPiEntry { source, target }
- }
-}
-
-/// Clear the agent directory, preserving only `auth.json`.
-pub fn clear_agent_dir(agent_dir: &Path) -> Result<()> {
- if !agent_dir.exists() {
- return Ok(());
- }
- for entry in fs_err::read_dir(agent_dir).into_diagnostic()? {
- let entry = entry.into_diagnostic()?;
- if entry.file_name() == "auth.json" {
- continue;
- }
- if entry.file_type().into_diagnostic()?.is_dir() {
- fs_err::remove_dir_all(entry.path()).into_diagnostic()?;
- } else {
- fs_err::remove_file(entry.path()).into_diagnostic()?;
- }
- }
- Ok(())
-}
-
-/// Copy pi config entries into the agent directory.
-///
-/// Auto-detects file vs directory from the source. Warns and skips
-/// sources that don't exist on the host.
-pub fn assemble_pi(agent_dir: &Path, entries: &[ResolvedPiEntry]) -> Result<()> {
- for entry in entries {
- if !entry.source.exists() {
- tracing::warn!(
- source = %entry.source.display(),
- target = %entry.target,
- "pi source does not exist, skipping"
- );
- continue;
- }
-
- let target = agent_dir.join(&entry.target);
-
- if entry.source.is_dir() {
- copy_dir(&entry.source, &target)?;
- } else {
- fs_err::copy(&entry.source, &target).into_diagnostic()?;
- }
- }
- Ok(())
-}
-
-/// Recursively copy a directory tree.
-fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
- fs_err::create_dir_all(dst).into_diagnostic()?;
- for entry in fs_err::read_dir(src).into_diagnostic()? {
- let entry = entry.into_diagnostic()?;
- let file_type = entry.file_type().into_diagnostic()?;
- let target = dst.join(entry.file_name());
- if file_type.is_dir() {
- copy_dir(&entry.path(), &target)?;
- } else {
- fs_err::copy(entry.path(), &target).into_diagnostic()?;
- }
- }
- Ok(())
-}
-
-impl ResolvedMount {
- /// Format as a Docker volume mount string (`source:target` or `source:target:ro`).
- pub fn to_volume_string(&self) -> String {
- if self.writable {
- format!("{}:{}", self.source.display(), self.target)
- } else {
- format!("{}:{}:ro", self.source.display(), self.target)
- }
- }
-
- /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
- pub fn display_target(&self) -> String {
- if self.writable {
- self.target.clone()
- } else {
- format!("{} (ro)", self.target)
- }
- }
-}
-
/// Home directory inside the agent container. The ramekin Dockerfile runs
/// everything as root, so `~` in container target paths maps here. If the
/// image ever switches to a non-root user, update this constant.
const CONTAINER_HOME: &str = "/root";
-/// 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 {
+fn resolve_container_target(path: &str, workspace_target: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
format!("{CONTAINER_HOME}/{rest}")
} else if path == "~" {
@@ -383,7 +313,7 @@ fn resolve_container_target(path: &str) -> String {
} else if path.starts_with('/') {
path.to_string()
} else {
- format!("{CONTAINER_WORKSPACE}/{path}")
+ format!("{workspace_target}/{path}")
}
}
@@ -391,6 +321,8 @@ fn resolve_container_target(path: &str) -> String {
mod tests {
use super::*;
+ const WS: &str = "/workspace/test-slug";
+
#[test]
fn kdl_deserialization_works() {
let kdl_content = r#"
@@ -398,7 +330,7 @@ mod tests {
source "~/.config/git"
}
mounts{
- source "~/.config/jj"
+ source "~/.config/jj"
}
mounts{
source "~/.local/share/ranger"
@@ -445,27 +377,42 @@ mod tests {
assert!(result.is_err());
}
+ #[test]
+ fn kdl_dash_notation_mounts_writable() {
+ let kdl = r#"
+ mounts {
+ - { source "~/.local/share/ranger"; writable }
+ }
+ "#;
+ let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
+ assert_eq!(parsed.mounts.len(), 1);
+ assert!(parsed.mounts[0].writable);
+ }
+
#[test]
fn resolve_container_target_with_subpath() {
assert_eq!(
- resolve_container_target("~/.config/git"),
+ resolve_container_target("~/.config/git", WS),
"/root/.config/git"
);
}
#[test]
fn resolve_container_target_bare() {
- assert_eq!(resolve_container_target("~"), "/root");
+ assert_eq!(resolve_container_target("~", WS), "/root");
}
#[test]
fn resolve_container_target_absolute_unchanged() {
- assert_eq!(resolve_container_target("/some/path"), "/some/path");
+ assert_eq!(resolve_container_target("/some/path", WS), "/some/path");
}
#[test]
fn resolve_container_target_relative_uses_workspace() {
- assert_eq!(resolve_container_target(".envrc"), "/workspace/.envrc");
+ assert_eq!(
+ resolve_container_target(".envrc", WS),
+ "/workspace/test-slug/.envrc"
+ );
}
#[test]
@@ -475,7 +422,7 @@ mod tests {
target: None,
writable: false,
};
- assert!(mount.resolve().is_none());
+ assert!(mount.resolve(WS).is_none());
}
#[test]
@@ -485,9 +432,9 @@ mod tests {
target: Some(".envrc".into()),
writable: false,
};
- let resolved = mount.resolve().unwrap();
+ let resolved = mount.resolve(WS).unwrap();
assert_eq!(resolved.source, PathBuf::from("/dev/null"));
- assert_eq!(resolved.target, "/workspace/.envrc");
+ assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
}
#[test]
@@ -497,7 +444,7 @@ mod tests {
target: Some("/container/tmp".into()),
writable: false,
};
- let resolved = mount.resolve().unwrap();
+ let resolved = mount.resolve(WS).unwrap();
assert_eq!(resolved.source, PathBuf::from("/tmp"));
assert_eq!(resolved.target, "/container/tmp");
assert!(!resolved.writable);
@@ -510,7 +457,7 @@ mod tests {
target: Some("~/downloads".into()),
writable: true,
};
- let resolved = mount.resolve().unwrap();
+ let resolved = mount.resolve(WS).unwrap();
assert_eq!(resolved.target, "/root/downloads");
}
@@ -521,56 +468,10 @@ mod tests {
target: None,
writable: true,
};
- let resolved = mount.resolve().unwrap();
+ let resolved = mount.resolve(WS).unwrap();
assert_eq!(resolved.target, "/tmp");
}
- #[test]
- fn volume_string_read_only() {
- let m = ResolvedMount {
- source: PathBuf::from("/home/user/.config/git"),
- target: "/root/.config/git".into(),
- writable: false,
- };
- assert_eq!(
- m.to_volume_string(),
- "/home/user/.config/git:/root/.config/git:ro"
- );
- }
-
- #[test]
- fn volume_string_read_write() {
- let m = ResolvedMount {
- source: PathBuf::from("/home/user/.local/share/ranger"),
- target: "/root/.local/share/ranger".into(),
- writable: true,
- };
- assert_eq!(
- m.to_volume_string(),
- "/home/user/.local/share/ranger:/root/.local/share/ranger"
- );
- }
-
- #[test]
- fn display_target_read_only() {
- let m = ResolvedMount {
- source: PathBuf::from("/x"),
- target: "/root/.config/git".into(),
- writable: false,
- };
- assert_eq!(m.display_target(), "/root/.config/git (ro)");
- }
-
- #[test]
- fn display_target_read_write() {
- let m = ResolvedMount {
- source: PathBuf::from("/x"),
- target: "/root/.local/share/ranger".into(),
- writable: true,
- };
- assert_eq!(m.display_target(), "/root/.local/share/ranger");
- }
-
#[test]
fn resolve_mounts_filters_nonexistent() {
let config = Config {
@@ -586,160 +487,72 @@ mod tests {
writable: false,
},
],
- pi: vec![],
env: HashMap::new(),
};
- let resolved = config.resolve_mounts();
+ let resolved = config.resolve_mounts(WS);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].target, "/container/tmp");
}
#[test]
fn scope_display() {
+ assert_eq!(Scope::Binary.to_string(), "binary");
assert_eq!(Scope::User.to_string(), "user");
assert_eq!(Scope::Project.to_string(), "project");
- assert_eq!(Scope::Builtin.to_string(), "builtin");
}
- #[test]
- fn merged_mounts_accumulates_across_layers() {
- let config = ScopedConfig {
- layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: Some(PathBuf::from("/user/config.kdl")),
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/a"),
- target: "/a".into(),
- writable: false,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: Some(PathBuf::from("/project/config.kdl")),
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/b"),
- target: "/b".into(),
- writable: true,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
- ],
- };
- let merged = config.merged_mounts();
- assert_eq!(merged.len(), 2);
- let a = merged.iter().find(|sv| sv.value.target == "/a").unwrap();
- assert_eq!(a.scope, Scope::User);
- let b = merged.iter().find(|sv| sv.value.target == "/b").unwrap();
- assert_eq!(b.scope, Scope::Project);
+ fn layer(scope: Scope, mounts: Vec<ResolvedMount>) -> ConfigLayer {
+ ConfigLayer {
+ scope,
+ path: None,
+ mounts,
+ env: HashMap::new(),
+ }
}
- #[test]
- fn merged_mounts_single_layer() {
- let config = ScopedConfig {
- layers: vec![ConfigLayer {
- scope: Scope::User,
- path: Some(PathBuf::from("/user/config.kdl")),
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/a"),
- target: "/a".into(),
- writable: false,
- }],
- pi: vec![],
- env: HashMap::new(),
- }],
- };
- let merged = config.merged_mounts();
- assert_eq!(merged.len(), 1);
- assert_eq!(merged[0].value.target, "/a");
+ fn mount(source: &str, target: &str, writable: bool) -> ResolvedMount {
+ ResolvedMount {
+ source: PathBuf::from(source),
+ target: target.into(),
+ writable,
+ }
}
#[test]
- fn merged_mounts_all_three_layers() {
+ fn merged_mounts_accumulates_across_layers() {
let config = ScopedConfig {
layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: Some(PathBuf::from("/user/config.kdl")),
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/a"),
- target: "/a".into(),
- writable: false,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: Some(PathBuf::from("/project/config.kdl")),
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/b"),
- target: "/b".into(),
- writable: true,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Builtin,
- path: None,
- mounts: vec![ResolvedMount {
- source: PathBuf::from("/c"),
- target: "/c".into(),
- writable: true,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
+ layer(Scope::Binary, vec![mount("/a", "/a", false)]),
+ layer(Scope::User, vec![mount("/b", "/b", true)]),
+ layer(Scope::Project, vec![mount("/c", "/c", true)]),
],
};
let merged = config.merged_mounts();
assert_eq!(merged.len(), 3);
let a = merged.iter().find(|sv| sv.value.target == "/a").unwrap();
- assert_eq!(a.scope, Scope::User);
+ assert_eq!(a.scope, Scope::Binary);
let b = merged.iter().find(|sv| sv.value.target == "/b").unwrap();
- assert_eq!(b.scope, Scope::Project);
+ assert_eq!(b.scope, Scope::User);
let c = merged.iter().find(|sv| sv.value.target == "/c").unwrap();
- assert_eq!(c.scope, Scope::Builtin);
+ assert_eq!(c.scope, Scope::Project);
}
#[test]
fn merged_mounts_deduplicates_by_target() {
let config = ScopedConfig {
layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: Some(PathBuf::from("/user/config.kdl")),
- mounts: vec![
- ResolvedMount {
- source: PathBuf::from("/user/git"),
- target: "/root/.config/git".into(),
- writable: false,
- },
- ResolvedMount {
- source: PathBuf::from("/user/jj"),
- target: "/root/.config/jj".into(),
- writable: false,
- },
+ layer(
+ Scope::User,
+ vec![
+ mount("/user/git", "/root/.config/git", false),
+ mount("/user/jj", "/root/.config/jj", false),
],
- pi: vec![],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: Some(PathBuf::from("/project/config.kdl")),
- mounts: vec![ResolvedMount {
- // Override the user git mount with a different source
- source: PathBuf::from("/project/git"),
- target: "/root/.config/git".into(),
- writable: true,
- }],
- pi: vec![],
- env: HashMap::new(),
- },
+ ),
+ layer(
+ Scope::Project,
+ // Override the user git mount with a different source
+ vec![mount("/project/git", "/root/.config/git", true)],
+ ),
],
};
let merged = config.merged_mounts();
@@ -759,12 +572,80 @@ mod tests {
assert!(git.value.writable);
}
+ #[test]
+ fn merged_mounts_orders_parents_before_children() {
+ let config = ScopedConfig {
+ layers: vec![
+ layer(
+ Scope::Binary,
+ vec![mount("/host/agents-md", "/root/.pi/agent/AGENTS.md", false)],
+ ),
+ layer(
+ Scope::User,
+ vec![mount("/host/agent-dir", "/root/.pi/agent", true)],
+ ),
+ ],
+ };
+ let merged = config.merged_mounts();
+ let targets: Vec<&str> = merged.iter().map(|sv| sv.value.target.as_str()).collect();
+ assert_eq!(
+ targets,
+ vec!["/root/.pi/agent", "/root/.pi/agent/AGENTS.md"]
+ );
+ }
+
+ #[test]
+ fn dev_null_mask_removes_inherited_mount() {
+ let config = ScopedConfig {
+ layers: vec![
+ layer(
+ Scope::Binary,
+ vec![mount("/host/skills", "/root/.pi/agent/skills", false)],
+ ),
+ layer(
+ Scope::Project,
+ vec![mount("/dev/null", "/root/.pi/agent/skills", false)],
+ ),
+ ],
+ };
+ let merged = config.merged_mounts();
+ assert!(merged.is_empty(), "mask should remove the inherited mount");
+ }
+
+ #[test]
+ fn dev_null_without_inherited_mount_stays_a_bind() {
+ let config = ScopedConfig {
+ layers: vec![layer(
+ Scope::Project,
+ vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+ )],
+ };
+ let merged = config.merged_mounts();
+ assert_eq!(merged.len(), 1);
+ assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+ }
+
+ #[test]
+ fn mount_overriding_mask_survives() {
+ let config = ScopedConfig {
+ layers: vec![
+ layer(Scope::Binary, vec![mount("/host/skills", "/s", false)]),
+ layer(Scope::User, vec![mount("/dev/null", "/s", false)]),
+ layer(Scope::Project, vec![mount("/project/skills", "/s", false)]),
+ ],
+ };
+ let merged = config.merged_mounts();
+ assert_eq!(merged.len(), 1);
+ assert_eq!(merged[0].value.source, PathBuf::from("/project/skills"));
+ assert_eq!(merged[0].scope, Scope::Project);
+ }
+
#[test]
fn load_with_no_project_config() {
// Use a workspace with no .ramekin/config.kdl
- let config = Config::load(Path::new("/tmp"), vec![]).unwrap();
- // Builtin layer is always last
- assert_eq!(config.layers.last().unwrap().scope, Scope::Builtin);
+ let config = Config::load(Path::new("/tmp"), WS).unwrap();
+ // Binary layer is always first (lowest precedence)
+ assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
// No project layer
assert!(!config.layers.iter().any(|l| l.scope == Scope::Project));
}
@@ -782,9 +663,9 @@ mod tests {
)
.unwrap();
- let config = Config::load(dir.path(), vec![]).unwrap();
+ let config = Config::load(dir.path(), WS).unwrap();
- // Should have project + builtin layers
+ // Should have binary + project layers
assert!(config.layers.len() >= 2);
let project_layer = config
.layers
@@ -793,381 +674,8 @@ mod tests {
.expect("project layer missing");
assert_eq!(project_layer.mounts.len(), 2);
assert_eq!(project_layer.mounts[0].target, "/container/tmp");
- // Builtin is always last (highest precedence)
- assert_eq!(config.layers.last().unwrap().scope, Scope::Builtin);
- }
-
- #[test]
- fn kdl_pi_entries() {
- let kdl = r#"
- pi {
- source "~/.dotfiles/ai/AGENTS.md"
- }
- pi {
- source "~/.dotfiles/ai/skills"
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert!(parsed.mounts.is_empty());
- assert_eq!(parsed.pi.len(), 2);
- assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/AGENTS.md");
- assert_eq!(parsed.pi[0].target, None);
- assert_eq!(parsed.pi[1].source, "~/.dotfiles/ai/skills");
- }
-
- #[test]
- fn kdl_pi_with_explicit_target() {
- let kdl = r#"
- pi {
- source "~/.dotfiles/ai/my-project-skills"
- target "skills"
- }
- pi {
- source "~/.dotfiles/ai/AGENTS.md"
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert_eq!(parsed.pi.len(), 2);
- assert_eq!(parsed.pi[0].target, Some("skills".into()));
- assert_eq!(parsed.pi[1].target, None);
- }
-
- #[test]
- fn kdl_dash_notation_pi() {
- let kdl = r#"
- pi {
- - { source "~/.dotfiles/ai/_AGENTS.md"; target "AGENTS.md" }
- - { source "~/.dotfiles/ai/skills" }
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert_eq!(parsed.pi.len(), 2);
- assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/_AGENTS.md");
- assert_eq!(parsed.pi[0].target, Some("AGENTS.md".into()));
- assert_eq!(parsed.pi[1].source, "~/.dotfiles/ai/skills");
- assert_eq!(parsed.pi[1].target, None);
- }
-
- #[test]
- fn kdl_dash_notation_mounts_writable() {
- let kdl = r#"
- mounts {
- - { source "~/.local/share/ranger"; writable }
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert_eq!(parsed.mounts.len(), 1);
- assert!(parsed.mounts[0].writable);
- }
-
- #[test]
- fn kdl_no_pi_section() {
- let kdl = r#"
- mounts {
- source "/tmp"
- target "/container/tmp"
- }
- mounts {
- source "/tmp"
- target "/container/tmp2"
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert!(parsed.pi.is_empty());
- assert_eq!(parsed.mounts.len(), 2);
- }
-
- #[test]
- fn kdl_pi_and_mounts_together() {
- let kdl = r#"
- mounts {
- source "~/.config/git"
- }
- mounts {
- source "~/.config/jj"
- }
- pi {
- source "~/.dotfiles/ai/AGENTS.md"
- }
- "#;
- let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
- assert_eq!(parsed.mounts.len(), 2);
- assert_eq!(parsed.pi.len(), 1);
- assert_eq!(parsed.pi[0].source, "~/.dotfiles/ai/AGENTS.md");
- }
-
- #[test]
- fn clear_agent_dir_preserves_auth_json() {
- let agent_dir = tempfile::tempdir().unwrap();
-
- fs_err::write(agent_dir.path().join("auth.json"), "secret").unwrap();
- fs_err::write(agent_dir.path().join("AGENTS.md"), "old").unwrap();
- fs_err::write(agent_dir.path().join("settings.json"), "{}").unwrap();
- fs_err::create_dir_all(agent_dir.path().join("skills")).unwrap();
- fs_err::write(agent_dir.path().join("skills/x.md"), "x").unwrap();
-
- clear_agent_dir(agent_dir.path()).unwrap();
-
- assert!(agent_dir.path().join("auth.json").exists());
- assert_eq!(
- fs_err::read_to_string(agent_dir.path().join("auth.json")).unwrap(),
- "secret"
- );
- assert!(!agent_dir.path().join("AGENTS.md").exists());
- assert!(!agent_dir.path().join("settings.json").exists());
- assert!(!agent_dir.path().join("skills").exists());
- }
-
- #[test]
- fn clear_agent_dir_handles_empty_dir() {
- let agent_dir = tempfile::tempdir().unwrap();
- clear_agent_dir(agent_dir.path()).unwrap();
- }
-
- #[test]
- fn clear_agent_dir_handles_nonexistent_dir() {
- clear_agent_dir(Path::new("/nonexistent/path")).unwrap();
- }
-
- #[test]
- fn assemble_pi_copies_file() {
- let src_dir = tempfile::tempdir().unwrap();
- let agent_dir = tempfile::tempdir().unwrap();
-
- let prompt = src_dir.path().join("AGENTS.md");
- fs_err::write(&prompt, "# my prompt").unwrap();
-
- let entries = vec![ResolvedPiEntry {
- source: prompt,
- target: "AGENTS.md".into(),
- }];
-
- assemble_pi(agent_dir.path(), &entries).unwrap();
-
- assert_eq!(
- fs_err::read_to_string(agent_dir.path().join("AGENTS.md")).unwrap(),
- "# my prompt"
- );
- }
-
- #[test]
- fn assemble_pi_copies_directory() {
- let src_dir = tempfile::tempdir().unwrap();
- let agent_dir = tempfile::tempdir().unwrap();
-
- let skills = src_dir.path().join("skills");
- fs_err::create_dir_all(skills.join("my-skill")).unwrap();
- fs_err::write(skills.join("my-skill/SKILL.md"), "# skill").unwrap();
-
- let entries = vec![ResolvedPiEntry {
- source: skills,
- target: "skills".into(),
- }];
-
- assemble_pi(agent_dir.path(), &entries).unwrap();
-
- assert_eq!(
- fs_err::read_to_string(agent_dir.path().join("skills/my-skill/SKILL.md")).unwrap(),
- "# skill"
- );
- }
-
- #[test]
- fn assemble_pi_skips_missing_source() {
- let agent_dir = tempfile::tempdir().unwrap();
-
- let entries = vec![ResolvedPiEntry {
- source: PathBuf::from("/nonexistent/file"),
- target: "file".into(),
- }];
-
- assemble_pi(agent_dir.path(), &entries).unwrap();
- assert!(!agent_dir.path().join("file").exists());
- }
-
- #[test]
- fn pi_resolve_defaults_target_to_basename() {
- let entry = PiEntry {
- source: "~/.dotfiles/ai/AGENTS.md".into(),
- target: None,
- };
- let resolved = entry.resolve();
- assert_eq!(resolved.target, "AGENTS.md");
- }
-
- #[test]
- fn pi_resolve_defaults_target_to_directory_basename() {
- let entry = PiEntry {
- source: "~/.dotfiles/ai/skills".into(),
- target: None,
- };
- let resolved = entry.resolve();
- assert_eq!(resolved.target, "skills");
- }
-
- #[test]
- fn pi_resolve_uses_explicit_target() {
- let entry = PiEntry {
- source: "~/.dotfiles/ai/my-project-skills".into(),
- target: Some("skills".into()),
- };
- let resolved = entry.resolve();
- assert_eq!(resolved.target, "skills");
- }
-
- #[test]
- fn clear_then_assemble_full_cycle() {
- let src_dir = tempfile::tempdir().unwrap();
- let agent_dir = tempfile::tempdir().unwrap();
-
- // Simulate previous run state.
- fs_err::write(agent_dir.path().join("auth.json"), "secret").unwrap();
- fs_err::write(agent_dir.path().join("AGENTS.md"), "old prompt").unwrap();
- fs_err::create_dir_all(agent_dir.path().join("old-skills")).unwrap();
-
- // New config only has a prompt.
- let prompt = src_dir.path().join("AGENTS.md");
- fs_err::write(&prompt, "new prompt").unwrap();
-
- clear_agent_dir(agent_dir.path()).unwrap();
-
- let entries = vec![ResolvedPiEntry {
- source: prompt,
- target: "AGENTS.md".into(),
- }];
- assemble_pi(agent_dir.path(), &entries).unwrap();
-
- // auth.json preserved.
- assert_eq!(
- fs_err::read_to_string(agent_dir.path().join("auth.json")).unwrap(),
- "secret"
- );
- // New prompt copied.
- assert_eq!(
- fs_err::read_to_string(agent_dir.path().join("AGENTS.md")).unwrap(),
- "new prompt"
- );
- // Old stale dir gone.
- assert!(!agent_dir.path().join("old-skills").exists());
- }
-
- #[test]
- fn merged_pi_accumulates() {
- let config = ScopedConfig {
- layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "~/.dotfiles/AGENTS.md".into(),
- target: None,
- }],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "/project/skills".into(),
- target: None,
- }],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Builtin,
- path: None,
- mounts: vec![],
- pi: vec![],
- env: HashMap::new(),
- },
- ],
- };
- let merged = config.merged_pi();
- assert_eq!(merged.len(), 2);
- let agents = merged
- .iter()
- .find(|sv| sv.value.source == "~/.dotfiles/AGENTS.md")
- .unwrap();
- assert_eq!(agents.scope, Scope::User);
- let skills = merged
- .iter()
- .find(|sv| sv.value.source == "/project/skills")
- .unwrap();
- assert_eq!(skills.scope, Scope::Project);
- }
-
- #[test]
- fn merged_pi_deduplicates_by_resolved_target() {
- let config = ScopedConfig {
- layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "~/.dotfiles/AGENTS.md".into(),
- target: None,
- }],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "/project/AGENTS.md".into(),
- target: None,
- }],
- env: HashMap::new(),
- },
- ],
- };
- let merged = config.merged_pi();
- assert_eq!(merged.len(), 1);
- // Project wins.
- let entry = merged
- .iter()
- .find(|sv| sv.value.source == "/project/AGENTS.md")
- .unwrap();
- assert_eq!(entry.scope, Scope::Project);
- }
-
- #[test]
- fn merged_pi_explicit_target_deduplicates_with_basename() {
- let config = ScopedConfig {
- layers: vec![
- ConfigLayer {
- scope: Scope::User,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "~/.dotfiles/ai/skills".into(),
- target: None,
- }],
- env: HashMap::new(),
- },
- ConfigLayer {
- scope: Scope::Project,
- path: None,
- mounts: vec![],
- pi: vec![PiEntry {
- source: "/project/my-custom-skills".into(),
- target: Some("skills".into()),
- }],
- env: HashMap::new(),
- },
- ],
- };
- let merged = config.merged_pi();
- assert_eq!(merged.len(), 1);
- // Project wins — explicit target "skills" matches user's basename "skills".
- let entry = merged
- .iter()
- .find(|sv| sv.value.source == "/project/my-custom-skills")
- .unwrap();
- assert_eq!(entry.scope, Scope::Project);
+ // Binary is always first (lowest precedence)
+ assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
}
#[test]
@@ -1208,14 +716,12 @@ mod tests {
scope: Scope::User,
path: None,
mounts: vec![],
- pi: vec![],
env: user_env,
},
ConfigLayer {
scope: Scope::Project,
path: None,
mounts: vec![],
- pi: vec![],
env: project_env,
},
],
diff --git a/src/main.rs b/src/main.rs
index d302ab4..9f94d21 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,14 @@
mod config;
+use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
-use miette::{Context, IntoDiagnostic, Result, bail};
+use miette::{Context, IntoDiagnostic, Result, bail, miette};
use serde::Serialize;
-use tracing::{error, info};
+use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
@@ -85,9 +86,16 @@ fn main() -> Result<()> {
struct Ramekin {
workspace: PathBuf,
+ /// Container path of the workspace mount: `/workspace/<slug>`. Per-repo
+ /// so anything the agent keys by cwd (pi's session grouping) gets a
+ /// distinct path per repo instead of every repo looking like the same
+ /// `/workspace` project.
+ workspace_target: String,
xdg: xdg::BaseDirectories,
- agent_dir: PathBuf,
- pi_data_dir: PathBuf,
+ /// Persistent pi state: `$XDG_DATA_HOME/ramekin/agents/pi/`. Holds
+ /// `auth.json`, the one global file that survives across sessions.
+ pi_state_dir: PathBuf,
+ /// Per-repo session history: `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
repo_sessions_dir: PathBuf,
cache_dir: PathBuf,
custom_dockerfile: Option<PathBuf>,
@@ -95,8 +103,9 @@ struct Ramekin {
}
impl Ramekin {
- /// Resolve all paths, create XDG directories, assemble pi config, and
- /// resolve mounts.
+ /// Resolve all paths and load config layers. Side-effect free: nothing
+ /// is created or written until `run` calls `prepare`, so `ramekin
+ /// config` can inspect state without mutating it.
fn resolve(workspace_arg: PathBuf) -> Result<Self> {
let workspace = workspace_arg
.canonicalize()
@@ -106,72 +115,31 @@ impl Ramekin {
})?;
let xdg = xdg::BaseDirectories::with_prefix("ramekin");
-
- // Agent directory mirrors /root/.pi/agent in the container.
- let agent_dir = xdg
- .create_config_directory("agent")
- .into_diagnostic()
- .wrap_err("failed to create agent config directory")?;
-
- let pi_data_dir = xdg
- .create_data_directory("")
- .into_diagnostic()
- .wrap_err("failed to create pi data directory")?;
+ let data_home = xdg
+ .get_data_home()
+ .ok_or_else(|| miette!("could not determine XDG data home"))?;
+ let cache_dir = xdg
+ .get_cache_home()
+ .ok_or_else(|| miette!("could not determine XDG cache home"))?;
let repo_slug = repo_slug(&workspace);
- let repo_sessions_dir = xdg
- .create_data_directory(format!("repos/{repo_slug}/sessions"))
- .into_diagnostic()
- .wrap_err("failed to create repo sessions directory")?;
-
- let cache_dir = xdg
- .create_cache_directory("")
- .into_diagnostic()
- .wrap_err("failed to create cache directory")?;
+ let workspace_target = format!("/workspace/{repo_slug}");
+ let pi_state_dir = data_home.join("agents/pi");
+ let repo_sessions_dir = data_home.join(format!("repos/{repo_slug}/sessions"));
let custom_dockerfile_path = workspace.join(".ramekin/Dockerfile");
let custom_dockerfile = custom_dockerfile_path
.is_file()
.then_some(custom_dockerfile_path);
- // Builtin mounts (always present, not overridable)
- let builtin_entries = [
- (&pi_data_dir, "/root/.pi"),
- (&agent_dir, "/root/.pi/agent"),
- (&repo_sessions_dir, "/root/.pi/agent/sessions"),
- (&workspace, config::CONTAINER_WORKSPACE),
- ];
- let builtin_mounts: Vec<config::ResolvedMount> = builtin_entries
- .into_iter()
- .map(|(source, target)| config::ResolvedMount {
- source: source.clone(),
- target: target.into(),
- writable: true,
- })
- .collect();
-
- let config = config::Config::load(&workspace, builtin_mounts)
+ let config = config::Config::load(&workspace, &workspace_target)
.wrap_err("failed to load ramekin configuration")?;
- // Clear and reassemble the agent dir from pi config.
- config::clear_agent_dir(&agent_dir).wrap_err("failed to clear agent directory")?;
-
- let resolved_pi: Vec<config::ResolvedPiEntry> = config
- .merged_pi()
- .iter()
- .map(|sv| sv.value.resolve())
- .collect();
- config::assemble_pi(&agent_dir, &resolved_pi).wrap_err("failed to assemble pi config")?;
-
- // Write the system prompt file so pi can read it via --append-system-prompt.
- let prompt_path = agent_dir.join("ramekin-prompt.md");
- fs_err::write(&prompt_path, RAMEKIN_PROMPT).into_diagnostic()?;
-
Ok(Self {
workspace,
+ workspace_target,
xdg,
- agent_dir,
- pi_data_dir,
+ pi_state_dir,
repo_sessions_dir,
cache_dir,
custom_dockerfile,
@@ -179,19 +147,114 @@ impl Ramekin {
})
}
+ /// Materialize host-side state for a run: XDG directories and the
+ /// persistent pi auth file.
+ fn prepare(&self) -> Result<()> {
+ fs_err::create_dir_all(&self.pi_state_dir).into_diagnostic()?;
+ fs_err::create_dir_all(&self.repo_sessions_dir).into_diagnostic()?;
+ fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
+
+ // auth.json bind-mounts as a file, so it has to exist before the
+ // container starts. Migrate from the pre-redesign location
+ // (~/.config/ramekin/agent/auth.json) or start it empty.
+ let auth_file = self.pi_state_dir.join("auth.json");
+ if !auth_file.exists() {
+ let old_auth = self
+ .xdg
+ .get_config_home()
+ .map(|config_home| config_home.join("agent/auth.json"));
+ match old_auth.filter(|p| p.exists()) {
+ Some(old) => {
+ info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
+ fs_err::copy(&old, &auth_file).into_diagnostic()?;
+ }
+ None => {
+ // create_new so two concurrent first runs can't clobber
+ // each other; losing the race is fine, the file exists.
+ use std::io::Write;
+ match fs_err::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&auth_file)
+ {
+ Ok(mut f) => f.write_all(b"{}").into_diagnostic()?,
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(e) => return Err(e).into_diagnostic(),
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Session plumbing and agent-state mounts. Not a config layer: these
+ /// are forced, overriding any config mount at the same target.
+ ///
+ /// Pi state is ephemeral by default — a fresh empty writable dir per
+ /// session at the agent dir, with the allowlisted persistent pieces
+ /// (`auth.json`, per-repo `sessions/`) bind-mounted on top. Read-only
+ /// host-config mounts from the binary layer sit above that.
+ fn session_mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+ let agent = |rest: &str| format!("{}/{rest}", config::PI_AGENT_DIR);
+ vec![
+ config::ResolvedMount {
+ source: session_dir.join("agent"),
+ target: config::PI_AGENT_DIR.into(),
+ writable: true,
+ },
+ config::ResolvedMount {
+ source: self.pi_state_dir.join("auth.json"),
+ target: agent("auth.json"),
+ writable: true,
+ },
+ config::ResolvedMount {
+ source: self.repo_sessions_dir.clone(),
+ target: agent("sessions"),
+ writable: true,
+ },
+ config::ResolvedMount {
+ source: session_dir.join("ramekin-prompt.md"),
+ target: agent("ramekin-prompt.md"),
+ writable: false,
+ },
+ config::ResolvedMount {
+ source: self.workspace.clone(),
+ target: self.workspace_target.clone(),
+ writable: true,
+ },
+ ]
+ }
+
+ /// Merge config mounts with the forced session mounts, ordered
+ /// lexicographically by target so parents precede children.
+ fn final_mounts<'a>(
+ &'a self,
+ session_mounts: &'a [config::ResolvedMount],
+ ) -> Vec<&'a config::ResolvedMount> {
+ let mut by_target: BTreeMap<&str, &config::ResolvedMount> = self
+ .config
+ .merged_mounts()
+ .into_iter()
+ .map(|sv| (sv.value.target.as_str(), sv.value))
+ .collect();
+ for mount in session_mounts {
+ by_target.insert(mount.target.as_str(), mount);
+ }
+ by_target.into_values().collect()
+ }
+
fn config(&self) -> Result<()> {
println!("Workspace");
- println!(" {}", self.workspace.display());
+ println!(" {} → {}", self.workspace.display(), self.workspace_target);
println!();
println!("Ramekin directories");
- println!(" agent {}", self.agent_dir.display());
- println!(" data {}", self.pi_data_dir.display());
+ println!(" pi state {}", self.pi_state_dir.display());
println!(" sessions {}", self.repo_sessions_dir.display());
println!(" cache {}", self.cache_dir.display());
let merged_mounts = self.config.merged_mounts();
- let merged_pi = self.config.merged_pi();
let merged_env = self.config.merged_env();
let scope_label = |scope: config::Scope| -> String {
@@ -208,8 +271,7 @@ impl Ramekin {
if !merged_mounts.is_empty() {
println!();
println!("Mounts");
- let scopes: std::collections::BTreeSet<_> =
- merged_mounts.iter().map(|sv| sv.scope).collect();
+ let scopes: BTreeSet<_> = merged_mounts.iter().map(|sv| sv.scope).collect();
for scope in scopes {
println!(" {}", scope_label(scope));
for sv in merged_mounts.iter().filter(|sv| sv.scope == scope) {
@@ -222,12 +284,23 @@ impl Ramekin {
}
}
+ // Session mounts (sources materialize per run; shown with a placeholder)
+ let placeholder = self.cache_dir.join("sessions/<session>");
+ println!();
+ println!("Session mounts");
+ for mount in self.session_mounts(&placeholder) {
+ println!(
+ " {} → {}",
+ mount.source.display(),
+ mount.display_target()
+ );
+ }
+
// Environment
if !merged_env.is_empty() {
println!();
println!("Environment");
- let scopes: std::collections::BTreeSet<_> =
- merged_env.iter().map(|sv| sv.scope).collect();
+ let scopes: BTreeSet<_> = merged_env.iter().map(|sv| sv.scope).collect();
for scope in scopes {
println!(" {}", scope_label(scope));
for sv in merged_env.iter().filter(|sv| sv.scope == scope) {
@@ -236,37 +309,6 @@ impl Ramekin {
}
}
- // Pi config
- if !merged_pi.is_empty() {
- println!();
- println!("Pi config");
- let scopes: std::collections::BTreeSet<_> =
- merged_pi.iter().map(|sv| sv.scope).collect();
- for scope in scopes {
- println!(" {}", scope_label(scope));
- for sv in merged_pi.iter().filter(|sv| sv.scope == scope) {
- let resolved = sv.value.resolve();
- let kind = if resolved.source.is_dir() {
- "dir"
- } else if resolved.source.is_file() {
- "file"
- } else {
- "missing"
- };
- let marker = if resolved.source.exists() {
- "✓"
- } else {
- "✗"
- };
- println!(
- " {marker} {} → {} ({kind})",
- resolved.source.display(),
- resolved.target
- );
- }
- }
- }
-
println!();
println!("Dockerfile");
match &self.custom_dockerfile {
@@ -284,8 +326,9 @@ impl Ramekin {
}
fn run(&self, rebuild: bool, pi_args: &[String]) -> Result<()> {
- info!(agent = %self.agent_dir.display(), repo = %self.repo_sessions_dir.display(), "directories");
- info!(workspace = %self.workspace.display(), "starting agent");
+ info!(workspace = %self.workspace.display(), target = %self.workspace_target, "starting agent");
+
+ self.prepare()?;
// Write the embedded Dockerfile to the cache directory
let base_dockerfile = self.cache_dir.join("Dockerfile");
@@ -333,20 +376,23 @@ impl Ramekin {
),
};
- // Session-scoped: unique compose file and project name
+ // Session-scoped: compose file, rendered prompt, and a fresh empty
+ // agent dir, all under a random session id so concurrent runs don't
+ // interfere.
let session_id = session_id();
let session_dir = self
.xdg
.create_cache_directory(format!("sessions/{session_id}"))
.into_diagnostic()
.wrap_err("failed to create session directory")?;
+ let session_agent_dir = session_dir.join("agent");
+ fs_err::create_dir_all(&session_agent_dir).into_diagnostic()?;
- let all_mounts: Vec<_> = self
- .config
- .merged_mounts()
- .into_iter()
- .map(|sv| sv.value)
- .collect();
+ let prompt = RAMEKIN_PROMPT.replace("{{WORKSPACE_PATH}}", &self.workspace_target);
+ fs_err::write(session_dir.join("ramekin-prompt.md"), prompt).into_diagnostic()?;
+
+ let session_mounts = self.session_mounts(&session_dir);
+ let all_mounts = self.final_mounts(&session_mounts);
let env_vars = self.config.merged_env();
let compose = generate_compose(
&dockerfile,
@@ -354,11 +400,24 @@ impl Ramekin {
&all_mounts,
&env_vars,
&image,
+ &self.workspace_target,
pi_args,
);
let compose_file = session_dir.join("compose.yml");
fs_err::write(&compose_file, &compose).into_diagnostic()?;
+ // Mount targets inside the agent dir show up on the host as empty
+ // artifacts Docker creates to serve as mount points; the teardown
+ // report has to know to skip them.
+ let agent_dir_mountpoints: BTreeSet<PathBuf> = all_mounts
+ .iter()
+ .filter_map(|m| {
+ m.target
+ .strip_prefix(&format!("{}/", config::PI_AGENT_DIR))
+ .map(PathBuf::from)
+ })
+ .collect();
+
let project_name = format!("ramekin-{session_id}");
let docker_compose = |args: &[&str]| -> Result<Command> {
let mut cmd = Command::new("docker");
@@ -412,6 +471,18 @@ impl Ramekin {
error!("docker compose down failed ({})", down_status);
}
+ // Anything the agent wrote to its session-scoped dir is about to be
+ // discarded; log it so a path that deserves persistence gets noticed
+ // instead of silently vanishing.
+ match discarded_writes(&session_agent_dir, &agent_dir_mountpoints) {
+ Ok(paths) => {
+ for path in paths {
+ warn!(path = %path.display(), "discarding session-scoped agent write");
+ }
+ }
+ Err(e) => error!("failed to inspect session agent dir: {e}"),
+ }
+
if let Err(e) = fs_err::remove_dir_all(&session_dir) {
error!("failed to clean up session dir: {e}");
}
@@ -428,6 +499,47 @@ impl Ramekin {
// Helpers
// ---------------------------------------------------------------------------
+/// Collect every file the agent wrote into its session-scoped agent dir.
+///
+/// The dir starts empty, so anything found here (other than the empty
+/// artifacts Docker created as mount points, listed in `mountpoints` as
+/// agent-dir-relative paths) is durable-looking state the agent produced
+/// that ramekin is about to throw away. Logging these is the learning loop
+/// for promoting a path into the persistent set — or confirming it's junk.
+fn discarded_writes(agent_dir: &Path, mountpoints: &BTreeSet<PathBuf>) -> Result<Vec<PathBuf>> {
+ fn walk(
+ dir: &Path,
+ root: &Path,
+ skip: &BTreeSet<PathBuf>,
+ found: &mut Vec<PathBuf>,
+ ) -> Result<()> {
+ for entry in fs_err::read_dir(dir).into_diagnostic()? {
+ let entry = entry.into_diagnostic()?;
+ let path = entry.path();
+ let rel = path
+ .strip_prefix(root)
+ .expect("walk stays under root")
+ .to_path_buf();
+ // A mount point (and everything a mount put under it) is not an
+ // agent write; skip the whole subtree.
+ if skip.contains(&rel) {
+ continue;
+ }
+ if entry.file_type().into_diagnostic()?.is_dir() {
+ walk(&path, root, skip, found)?;
+ } else {
+ found.push(rel);
+ }
+ }
+ Ok(())
+ }
+
+ let mut found = Vec::new();
+ walk(agent_dir, agent_dir, mountpoints, &mut found)?;
+ found.sort();
+ Ok(found)
+}
+
/// Generate a random session ID for scoping the compose project and cache dir.
fn session_id() -> String {
format!("{:08x}", fastrand::u32(..))
@@ -480,8 +592,9 @@ struct AgentService {
image: String,
stdin_open: bool,
tty: bool,
+ working_dir: String,
environment: Vec<String>,
- volumes: Vec<String>,
+ volumes: Vec<VolumeBind>,
command: Vec<String>,
}
@@ -491,6 +604,17 @@ struct BuildConfig {
dockerfile: String,
}
+/// Long-form compose bind mount. Avoids the `source:target[:ro]` short form,
+/// which can't represent paths containing colons.
+#[derive(Serialize)]
+struct VolumeBind {
+ #[serde(rename = "type")]
+ kind: &'static str,
+ source: String,
+ target: String,
+ read_only: bool,
+}
+
/// Generate a Docker Compose config with all volume mounts.
fn generate_compose(
dockerfile: &Path,
@@ -498,9 +622,18 @@ fn generate_compose(
mounts: &[&config::ResolvedMount],
env_vars: &[config::ScopedValue<(&str, &str)>],
image: &str,
+ working_dir: &str,
pi_args: &[String],
) -> String {
- let volumes: Vec<String> = mounts.iter().map(|m| m.to_volume_string()).collect();
+ let volumes: Vec<VolumeBind> = mounts
+ .iter()
+ .map(|m| VolumeBind {
+ kind: "bind",
+ source: m.source.display().to_string(),
+ target: m.target.clone(),
+ read_only: !m.writable,
+ })
+ .collect();
let environment: Vec<String> = env_vars
.iter()
@@ -508,15 +641,12 @@ fn generate_compose(
.collect();
// Always pass --append-system-prompt for the ramekin container context.
- // The prompt file is written into the agent dir which is mounted at /root/.pi/agent.
- let prompt_path = "/root/.pi/agent/ramekin-prompt.md";
- let command: Vec<String> = [
- "--append-system-prompt".to_string(),
- prompt_path.to_string(),
- ]
- .into_iter()
- .chain(pi_args.iter().cloned())
- .collect();
+ // The rendered prompt is mounted read-only into the agent dir.
+ let prompt_path = format!("{}/ramekin-prompt.md", config::PI_AGENT_DIR);
+ let command: Vec<String> = ["--append-system-prompt".to_string(), prompt_path]
+ .into_iter()
+ .chain(pi_args.iter().cloned())
+ .collect();
let config = ComposeConfig {
services: Services {
@@ -528,6 +658,7 @@ fn generate_compose(
image: image.to_string(),
stdin_open: true,
tty: true,
+ working_dir: working_dir.to_string(),
environment,
volumes,
command,
@@ -568,11 +699,67 @@ mod tests {
&[],
&[],
"ramekin-agent-lit-rs-deadbeef",
+ "/workspace/lit-rs-deadbeef",
&[],
);
assert!(
yaml.contains("image: ramekin-agent-lit-rs-deadbeef"),
"compose did not carry the supplied image tag:\n{yaml}"
);
+ assert!(
+ yaml.contains("working_dir: /workspace/lit-rs-deadbeef"),
+ "compose did not set working_dir:\n{yaml}"
+ );
+ }
+
+ #[test]
+ fn generate_compose_long_form_binds() {
+ let mount = config::ResolvedMount {
+ source: PathBuf::from("/host/.config/git"),
+ target: "/root/.config/git".into(),
+ writable: false,
+ };
+ let yaml = generate_compose(
+ Path::new("/cache/Dockerfile"),
+ Path::new("/cache"),
+ &[&mount],
+ &[],
+ "ramekin-agent",
+ "/workspace/x-1",
+ &[],
+ );
+ assert!(yaml.contains("type: bind"), "{yaml}");
+ assert!(yaml.contains("source: /host/.config/git"), "{yaml}");
+ assert!(yaml.contains("target: /root/.config/git"), "{yaml}");
+ assert!(yaml.contains("read_only: true"), "{yaml}");
+ }
+
+ #[test]
+ fn discarded_writes_skips_mountpoint_artifacts() {
+ let dir = tempfile::tempdir().unwrap();
+ // Mount point artifacts docker would leave behind.
+ fs_err::write(dir.path().join("auth.json"), "").unwrap();
+ fs_err::create_dir_all(dir.path().join("sessions")).unwrap();
+ // Genuine agent writes.
+ fs_err::write(dir.path().join("scratch.txt"), "x").unwrap();
+ fs_err::create_dir_all(dir.path().join("cache")).unwrap();
+ fs_err::write(dir.path().join("cache/blob"), "y").unwrap();
+
+ let mountpoints: BTreeSet<PathBuf> =
+ [PathBuf::from("auth.json"), PathBuf::from("sessions")]
+ .into_iter()
+ .collect();
+ let found = discarded_writes(dir.path(), &mountpoints).unwrap();
+ assert_eq!(
+ found,
+ vec![PathBuf::from("cache/blob"), PathBuf::from("scratch.txt")]
+ );
+ }
+
+ #[test]
+ fn discarded_writes_empty_dir_reports_nothing() {
+ let dir = tempfile::tempdir().unwrap();
+ let found = discarded_writes(dir.path(), &BTreeSet::new()).unwrap();
+ assert!(found.is_empty());
}
}