Add the outbox: the reviewed write path for shared config
Config being read-only inside the container needs a counterpart: a
deliberate channel for agents to propose config changes instead of
silently losing them. Each session mounts a fresh, empty outbox dir
(host: $XDG_DATA_HOME/ramekin/repos/<slug>/outbox/<session-id>/,
container: /root/.ramekin/outbox) — the only agent-writable path
outside the workspace and agent state mounts — and the system prompt
tells the agent to write complete updated files there, mirroring the
agent config layout.
Host side, `ramekin outbox` reviews proposals across all repos and
sessions: `list`, `diff` (difftastic, falling back to diff -u),
`apply` (after showing the diff and confirming; writes through
dotfiles symlinks so the change lands in the working copy), and
`discard`. A proposal maps back to a host source via the agent's
config allowlist plus an agent sidecar recorded outside the mount, so
a confused or malicious proposal can't redirect where apply writes;
anything unmapped needs an explicit --to. Empty outboxes vanish at
teardown; non-empty ones survive with a pointer to `ramekin outbox
list`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
diff --git a/assets/ramekin-prompt.md b/assets/ramekin-prompt.md
index 8c8efde..0fd9986 100644
--- a/assets/ramekin-prompt.md
+++ b/assets/ramekin-prompt.md
@@ -10,7 +10,9 @@ The project workspace is bind-mounted at `{{WORKSPACE_PATH}}` (the container sta
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.
+## Proposing configuration changes
+
+Agent configuration (memory files like `AGENTS.md`/`CLAUDE.md`, `skills/`, settings) is mounted read-only by design; editing it in place fails. To propose a change, write the complete updated file into `/root/.ramekin/outbox/`, mirroring its layout relative to your config directory (for example, a change to `skills/foo/SKILL.md` goes to `/root/.ramekin/outbox/skills/foo/SKILL.md`), and tell the user what you proposed and why. The user reviews and applies proposals on the host with `ramekin outbox`.
## Networking
diff --git a/src/main.rs b/src/main.rs
index ef2f679..ef32e2b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
mod config;
+mod outbox;
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
@@ -56,6 +57,11 @@ enum Cmd {
},
/// Show resolved paths and mount configuration
Config,
+ /// Review config changes proposed by agents
+ Outbox {
+ #[command(subcommand)]
+ command: OutboxCmd,
+ },
/// Generate shell completions
Completions {
/// Shell to generate completions for
@@ -63,6 +69,33 @@ enum Cmd {
},
}
+#[derive(Subcommand)]
+enum OutboxCmd {
+ /// List pending proposals across all repos and sessions
+ List,
+ /// Diff proposals against the host config they were mounted from
+ Diff {
+ /// A single proposal (`<slug>/<session>/<path>`) or session
+ /// (`<slug>/<session>`); all proposals when omitted
+ entry: Option<String>,
+ },
+ /// Copy a proposal over its host source, after confirmation
+ Apply {
+ /// The proposal to apply (`<slug>/<session>/<path>`)
+ entry: String,
+ /// Destination for proposals that don't map back to an allowlisted
+ /// agent-config entry
+ #[arg(long)]
+ to: Option<PathBuf>,
+ },
+ /// Drop proposals without applying them
+ Discard {
+ /// A single proposal (`<slug>/<session>/<path>`) or a whole session
+ /// (`<slug>/<session>`)
+ entry: String,
+ },
+}
+
fn main() -> Result<()> {
miette::set_hook(Box::new(|_| {
Box::new(miette::MietteHandlerOpts::new().build())
@@ -86,15 +119,132 @@ fn main() -> Result<()> {
return Ok(());
}
+ // Outbox review is host-global: it spans repos and needs no workspace,
+ // profile, or agent resolution.
+ if let Cmd::Outbox { command } = command {
+ return run_outbox(command);
+ }
+
let ramekin = Ramekin::resolve(cli.workspace, cli.profile.as_deref())?;
match command {
Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.agent_args),
Cmd::Config => ramekin.config(),
- Cmd::Completions { .. } => unreachable!(),
+ Cmd::Completions { .. } | Cmd::Outbox { .. } => unreachable!(),
}
}
+// ---------------------------------------------------------------------------
+// Outbox commands
+// ---------------------------------------------------------------------------
+
+fn run_outbox(command: OutboxCmd) -> Result<()> {
+ let data_home = xdg::BaseDirectories::with_prefix("ramekin")
+ .get_data_home()
+ .ok_or_else(|| miette!("could not determine XDG data home"))?;
+
+ match command {
+ OutboxCmd::List => {
+ let proposals = outbox::scan(&data_home)?;
+ if proposals.is_empty() {
+ println!("no pending proposals");
+ return Ok(());
+ }
+ for p in proposals {
+ match p.host_target() {
+ Some(target) => println!("{} → {}", p.entry(), target.display()),
+ None => println!("{} (no mapped target; apply needs --to)", p.entry()),
+ }
+ }
+ }
+ OutboxCmd::Diff { entry } => {
+ let proposals = match entry {
+ Some(entry) => outbox::find(&data_home, &entry)?,
+ None => outbox::scan(&data_home)?,
+ };
+ for p in proposals {
+ diff_proposal(&p, p.host_target().as_deref())?;
+ }
+ }
+ OutboxCmd::Apply { entry, to } => {
+ let proposals = outbox::find(&data_home, &entry)?;
+ let [proposal] = proposals.as_slice() else {
+ bail!(
+ "`{entry}` matches {} proposals; apply one file at a time",
+ proposals.len()
+ );
+ };
+ let target = to.or_else(|| proposal.host_target()).ok_or_else(|| {
+ miette!(
+ "`{entry}` doesn't map back to an allowlisted agent-config entry; \
+ pass an explicit destination with --to"
+ )
+ })?;
+
+ diff_proposal(proposal, Some(&target))?;
+ if !confirm(&format!("apply to {}?", target.display()))? {
+ println!("not applied");
+ return Ok(());
+ }
+
+ // Write through a symlinked host source (dotfiles), so the
+ // change lands in the dotfiles working copy, not over the link.
+ let dest = if target.exists() {
+ target.canonicalize().into_diagnostic()?
+ } else {
+ if let Some(parent) = target.parent() {
+ fs_err::create_dir_all(parent).into_diagnostic()?;
+ }
+ target
+ };
+ fs_err::copy(&proposal.file, &dest).into_diagnostic()?;
+ outbox::remove(&data_home, proposal)?;
+ println!("applied to {}", dest.display());
+ }
+ OutboxCmd::Discard { entry } => {
+ for proposal in outbox::find(&data_home, &entry)? {
+ outbox::remove(&data_home, &proposal)?;
+ println!("discarded {}", proposal.entry());
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Show a proposal's diff against its host source (difftastic when
+/// available, `diff -u` otherwise). A missing host source diffs against
+/// /dev/null, i.e. shows the whole proposal as new.
+fn diff_proposal(proposal: &outbox::Proposal, target: Option<&Path>) -> Result<()> {
+ println!("--- {}", proposal.entry());
+ let host: &Path = match target {
+ Some(t) if t.exists() => t,
+ _ => Path::new("/dev/null"),
+ };
+ let difft = Command::new("difft").arg(host).arg(&proposal.file).status();
+ if difft.is_err() {
+ // difftastic not installed; plain diff. Exit code 1 just means the
+ // files differ.
+ Command::new("diff")
+ .arg("-u")
+ .arg(host)
+ .arg(&proposal.file)
+ .status()
+ .into_diagnostic()
+ .wrap_err("failed to run diff")?;
+ }
+ Ok(())
+}
+
+/// Ask the user to confirm on stdin. Anything but `y`/`yes` is a no.
+fn confirm(prompt: &str) -> Result<bool> {
+ print!("{prompt} [y/N] ");
+ std::io::stdout().flush().into_diagnostic()?;
+ let mut answer = String::new();
+ std::io::stdin().read_line(&mut answer).into_diagnostic()?;
+ let answer = answer.trim().to_ascii_lowercase();
+ Ok(answer == "y" || answer == "yes")
+}
+
// ---------------------------------------------------------------------------
// AgentState
// ---------------------------------------------------------------------------
@@ -299,6 +449,7 @@ struct Ramekin {
workspace_target: String,
repo_slug: String,
xdg: xdg::BaseDirectories,
+ data_home: PathBuf,
cache_dir: PathBuf,
custom_dockerfile: Option<PathBuf>,
config: config::ScopedConfig,
@@ -343,6 +494,7 @@ impl Ramekin {
workspace_target,
repo_slug,
xdg,
+ data_home,
cache_dir,
custom_dockerfile,
config,
@@ -350,15 +502,20 @@ impl Ramekin {
})
}
- /// Session plumbing mounts shared by both agents: the rendered prompt
- /// and the workspace.
- fn session_mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+ /// Session plumbing mounts shared by both agents: the rendered prompt,
+ /// the outbox, and the workspace.
+ fn session_mounts(&self, session_dir: &Path, outbox_dir: &Path) -> Vec<config::ResolvedMount> {
let mut mounts = self.agent_state.mounts(session_dir);
mounts.push(config::ResolvedMount {
source: session_dir.join("ramekin-prompt.md"),
target: PROMPT_TARGET.into(),
writable: false,
});
+ mounts.push(config::ResolvedMount {
+ source: outbox_dir.to_path_buf(),
+ target: outbox::OUTBOX_TARGET.into(),
+ writable: true,
+ });
mounts.push(config::ResolvedMount {
source: self.workspace.clone(),
target: self.workspace_target.clone(),
@@ -453,9 +610,12 @@ impl Ramekin {
// Session mounts (sources materialize per run; shown with a placeholder)
let placeholder = self.cache_dir.join("sessions/<session>");
+ let outbox_placeholder = self
+ .data_home
+ .join(format!("repos/{}/outbox/<session>", self.repo_slug));
println!();
println!("Session mounts");
- for mount in self.session_mounts(&placeholder) {
+ for mount in self.session_mounts(&placeholder, &outbox_placeholder) {
println!(
" {} → {}",
mount.source.display(),
@@ -587,7 +747,11 @@ impl Ramekin {
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 outbox_dir =
+ outbox::create_session(&self.data_home, &self.repo_slug, &session_id, agent)
+ .wrap_err("failed to create session outbox")?;
+
+ let session_mounts = self.session_mounts(&session_dir, &outbox_dir);
let all_mounts = self.final_mounts(&session_mounts);
let env_vars = self.config.merged_env();
let compose = generate_compose(ComposeParams {
@@ -689,6 +853,15 @@ impl Ramekin {
}
}
+ // A non-empty outbox survives teardown as pending proposals.
+ match outbox::finish_session(&self.data_home, &self.repo_slug, &session_id) {
+ Ok(0) => {}
+ Ok(pending) => {
+ info!("{pending} config proposal(s) pending — review with `ramekin outbox list`");
+ }
+ Err(e) => error!("failed to finalize session outbox: {e}"),
+ }
+
if let Err(e) = fs_err::remove_dir_all(&session_dir) {
error!("failed to clean up session dir: {e}");
}
diff --git a/src/outbox.rs b/src/outbox.rs
new file mode 100644
index 0000000..cbac932
--- /dev/null
+++ b/src/outbox.rs
@@ -0,0 +1,336 @@
+//! The outbox: the one reviewed path for stateful modification of shared
+//! config.
+//!
+//! Config is read-only inside the container by design. When an agent wants
+//! a config change, it writes the changed file into its session's outbox
+//! dir (mounted writable at [`OUTBOX_TARGET`]), mirroring the agent config
+//! layout. Host side, `ramekin outbox` lists pending proposals, diffs them
+//! against the host source each entry was mounted from, and applies or
+//! discards them. Nothing reaches host config without an explicit apply.
+
+use std::path::{Component, Path, PathBuf};
+
+use miette::{IntoDiagnostic, Result, bail};
+
+use crate::config::Agent;
+
+/// Container path of the session's writable outbox dir — the only
+/// agent-writable path outside the workspace and the agent state mounts.
+pub const OUTBOX_TARGET: &str = "/root/.ramekin/outbox";
+
+/// Host paths for one session's outbox: the mounted dir and its sidecar
+/// metadata file recording which agent the session ran (the same relative
+/// path maps to different host config dirs per agent).
+fn session_paths(data_home: &Path, slug: &str, session_id: &str) -> (PathBuf, PathBuf) {
+ let outbox = data_home.join(format!("repos/{slug}/outbox"));
+ (
+ outbox.join(session_id),
+ outbox.join(format!("{session_id}.agent")),
+ )
+}
+
+/// Create a fresh, empty outbox dir for a session, plus its agent sidecar.
+/// The sidecar sits *beside* the mounted dir, out of the agent's reach, so
+/// a confused or malicious proposal can't redirect where apply maps it.
+pub fn create_session(
+ data_home: &Path,
+ slug: &str,
+ session_id: &str,
+ agent: Agent,
+) -> Result<PathBuf> {
+ let (dir, meta) = session_paths(data_home, slug, session_id);
+ fs_err::create_dir_all(&dir).into_diagnostic()?;
+ fs_err::write(&meta, agent.name()).into_diagnostic()?;
+ Ok(dir)
+}
+
+/// Session teardown: drop the outbox if the agent left nothing in it,
+/// keep it (returning the pending count) otherwise.
+pub fn finish_session(data_home: &Path, slug: &str, session_id: &str) -> Result<usize> {
+ let (dir, meta) = session_paths(data_home, slug, session_id);
+ let mut files = Vec::new();
+ collect_files(&dir, &dir, &mut files)?;
+ if files.is_empty() {
+ fs_err::remove_dir_all(&dir).into_diagnostic()?;
+ fs_err::remove_file(&meta).into_diagnostic()?;
+ }
+ Ok(files.len())
+}
+
+/// One proposed file in some session's outbox.
+#[derive(Debug)]
+pub struct Proposal {
+ pub slug: String,
+ pub session: String,
+ /// Path relative to the session outbox dir, mirroring the agent config
+ /// layout.
+ pub rel: PathBuf,
+ /// The agent the session ran, from the sidecar. `None` if the sidecar
+ /// is missing or unparseable.
+ pub agent: Option<Agent>,
+ /// Absolute host path of the proposal file.
+ pub file: PathBuf,
+}
+
+impl Proposal {
+ /// The address `ramekin outbox` commands take: `<slug>/<session>/<rel>`.
+ pub fn entry(&self) -> String {
+ format!("{}/{}/{}", self.slug, self.session, self.rel.display())
+ }
+
+ /// The host config file this proposal maps back to: the agent's host
+ /// config dir plus the relative path — but only when the path's first
+ /// component is an allowlisted entry, i.e. something that was actually
+ /// mounted. Anything else needs an explicit destination to apply.
+ pub fn host_target(&self) -> Option<PathBuf> {
+ let agent = self.agent?;
+ let first = match self.rel.components().next()? {
+ Component::Normal(name) => name.to_str()?.to_string(),
+ _ => return None,
+ };
+ if !agent.config_allowlist().contains(&first.as_str()) {
+ return None;
+ }
+ let base = PathBuf::from(shellexpand::tilde(agent.host_config_dir()).as_ref());
+ Some(base.join(&self.rel))
+ }
+}
+
+/// All pending proposals across every repo and session, oldest path first.
+pub fn scan(data_home: &Path) -> Result<Vec<Proposal>> {
+ let mut proposals = Vec::new();
+ let repos = data_home.join("repos");
+ if !repos.is_dir() {
+ return Ok(proposals);
+ }
+ for repo in sorted_dir(&repos)? {
+ let Some(slug) = dir_name(&repo) else {
+ continue;
+ };
+ let outbox = repo.join("outbox");
+ if !outbox.is_dir() {
+ continue;
+ }
+ for session_dir in sorted_dir(&outbox)? {
+ if !session_dir.is_dir() {
+ continue; // .agent sidecars
+ }
+ let Some(session) = dir_name(&session_dir) else {
+ continue;
+ };
+ let agent = fs_err::read_to_string(outbox.join(format!("{session}.agent")))
+ .ok()
+ .and_then(|s| Agent::parse(s.trim()).ok());
+ let mut files = Vec::new();
+ collect_files(&session_dir, &session_dir, &mut files)?;
+ for rel in files {
+ proposals.push(Proposal {
+ slug: slug.clone(),
+ session: session.clone(),
+ file: session_dir.join(&rel),
+ rel,
+ agent,
+ });
+ }
+ }
+ }
+ Ok(proposals)
+}
+
+/// Proposals matching an entry: either one file (`<slug>/<session>/<rel>`)
+/// or a whole session (`<slug>/<session>`).
+pub fn find(data_home: &Path, entry: &str) -> Result<Vec<Proposal>> {
+ let matches: Vec<Proposal> = scan(data_home)?
+ .into_iter()
+ .filter(|p| {
+ let session_prefix = format!("{}/{}", p.slug, p.session);
+ p.entry() == entry || session_prefix == entry
+ })
+ .collect();
+ if matches.is_empty() {
+ bail!("no outbox entry matches `{entry}` (see `ramekin outbox list`)");
+ }
+ Ok(matches)
+}
+
+/// Remove a proposal file and prune its session outbox if now empty.
+pub fn remove(data_home: &Path, proposal: &Proposal) -> Result<()> {
+ fs_err::remove_file(&proposal.file).into_diagnostic()?;
+ // Prune now-empty parent dirs up to (and including, via finish) the
+ // session dir.
+ let (session_dir, _) = session_paths(data_home, &proposal.slug, &proposal.session);
+ let mut dir = proposal.file.parent().map(Path::to_path_buf);
+ while let Some(d) = dir {
+ if d == session_dir || fs_err::remove_dir(&d).is_err() {
+ break;
+ }
+ dir = d.parent().map(Path::to_path_buf);
+ }
+ finish_session(data_home, &proposal.slug, &proposal.session)?;
+ Ok(())
+}
+
+/// Recursively collect files under `dir` as paths relative to `root`.
+fn collect_files(dir: &Path, root: &Path, found: &mut Vec<PathBuf>) -> Result<()> {
+ for entry in fs_err::read_dir(dir).into_diagnostic()? {
+ let entry = entry.into_diagnostic()?;
+ let path = entry.path();
+ if entry.file_type().into_diagnostic()?.is_dir() {
+ collect_files(&path, root, found)?;
+ } else {
+ found.push(
+ path.strip_prefix(root)
+ .expect("walk stays under root")
+ .to_path_buf(),
+ );
+ }
+ }
+ found.sort();
+ Ok(())
+}
+
+fn sorted_dir(dir: &Path) -> Result<Vec<PathBuf>> {
+ let mut entries: Vec<PathBuf> = fs_err::read_dir(dir)
+ .into_diagnostic()?
+ .filter_map(|e| e.ok().map(|e| e.path()))
+ .collect();
+ entries.sort();
+ Ok(entries)
+}
+
+fn dir_name(path: &Path) -> Option<String> {
+ path.file_name().map(|n| n.to_string_lossy().into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write_proposal(data_home: &Path, slug: &str, session: &str, agent: &str, rel: &str) {
+ let (dir, meta) = session_paths(data_home, slug, session);
+ let file = dir.join(rel);
+ fs_err::create_dir_all(file.parent().unwrap()).unwrap();
+ fs_err::write(&file, "proposed").unwrap();
+ fs_err::write(&meta, agent).unwrap();
+ }
+
+ #[test]
+ fn create_then_finish_empty_session_leaves_nothing() {
+ let data_home = tempfile::tempdir().unwrap();
+ let dir = create_session(data_home.path(), "repo-1", "abc", Agent::Pi).unwrap();
+ assert!(dir.is_dir());
+
+ let pending = finish_session(data_home.path(), "repo-1", "abc").unwrap();
+ assert_eq!(pending, 0);
+ assert!(!dir.exists());
+ assert!(scan(data_home.path()).unwrap().is_empty());
+ }
+
+ #[test]
+ fn finish_keeps_nonempty_session() {
+ let data_home = tempfile::tempdir().unwrap();
+ let dir = create_session(data_home.path(), "repo-1", "abc", Agent::Pi).unwrap();
+ fs_err::write(dir.join("AGENTS.md"), "new").unwrap();
+
+ let pending = finish_session(data_home.path(), "repo-1", "abc").unwrap();
+ assert_eq!(pending, 1);
+ assert!(dir.exists());
+ }
+
+ #[test]
+ fn scan_finds_proposals_with_agent() {
+ let data_home = tempfile::tempdir().unwrap();
+ write_proposal(
+ data_home.path(),
+ "repo-1",
+ "abc",
+ "pi",
+ "skills/foo/SKILL.md",
+ );
+
+ let proposals = scan(data_home.path()).unwrap();
+ assert_eq!(proposals.len(), 1);
+ let p = &proposals[0];
+ assert_eq!(p.slug, "repo-1");
+ assert_eq!(p.session, "abc");
+ assert_eq!(p.agent, Some(Agent::Pi));
+ assert_eq!(p.entry(), "repo-1/abc/skills/foo/SKILL.md");
+ let target = p.host_target().unwrap();
+ assert!(
+ target.ends_with(".pi/agent/skills/foo/SKILL.md"),
+ "got {}",
+ target.display()
+ );
+ }
+
+ #[test]
+ fn claude_proposal_maps_to_claude_dir() {
+ let data_home = tempfile::tempdir().unwrap();
+ write_proposal(data_home.path(), "repo-1", "abc", "claude", "CLAUDE.md");
+
+ let proposals = scan(data_home.path()).unwrap();
+ let target = proposals[0].host_target().unwrap();
+ assert!(
+ target.ends_with(".claude/CLAUDE.md"),
+ "got {}",
+ target.display()
+ );
+ }
+
+ #[test]
+ fn unallowlisted_proposal_has_no_host_target() {
+ let data_home = tempfile::tempdir().unwrap();
+ write_proposal(data_home.path(), "repo-1", "abc", "pi", "settings.json");
+
+ let proposals = scan(data_home.path()).unwrap();
+ // settings.json is claude-shaped, not in pi's allowlist.
+ assert_eq!(proposals[0].host_target(), None);
+ }
+
+ #[test]
+ fn missing_sidecar_means_no_target() {
+ let data_home = tempfile::tempdir().unwrap();
+ let (dir, _) = session_paths(data_home.path(), "repo-1", "abc");
+ fs_err::create_dir_all(&dir).unwrap();
+ fs_err::write(dir.join("AGENTS.md"), "x").unwrap();
+
+ let proposals = scan(data_home.path()).unwrap();
+ assert_eq!(proposals.len(), 1);
+ assert_eq!(proposals[0].agent, None);
+ assert_eq!(proposals[0].host_target(), None);
+ }
+
+ #[test]
+ fn find_matches_file_and_session() {
+ let data_home = tempfile::tempdir().unwrap();
+ write_proposal(data_home.path(), "repo-1", "abc", "pi", "AGENTS.md");
+ write_proposal(data_home.path(), "repo-1", "abc", "pi", "skills/x.md");
+
+ let by_file = find(data_home.path(), "repo-1/abc/AGENTS.md").unwrap();
+ assert_eq!(by_file.len(), 1);
+
+ let by_session = find(data_home.path(), "repo-1/abc").unwrap();
+ assert_eq!(by_session.len(), 2);
+
+ assert!(find(data_home.path(), "repo-1/nope").is_err());
+ }
+
+ #[test]
+ fn remove_prunes_empty_dirs_and_session() {
+ let data_home = tempfile::tempdir().unwrap();
+ write_proposal(
+ data_home.path(),
+ "repo-1",
+ "abc",
+ "pi",
+ "skills/foo/SKILL.md",
+ );
+
+ let proposals = scan(data_home.path()).unwrap();
+ remove(data_home.path(), &proposals[0]).unwrap();
+
+ let (dir, meta) = session_paths(data_home.path(), "repo-1", "abc");
+ assert!(!dir.exists());
+ assert!(!meta.exists());
+ }
+}