pi: widen the config allowlist and persist packages
The allowlist carried only AGENTS.md and skills, so pi ran on defaults
inside the container — and because its skills path lives in
settings.json, a skills mount configured in KDL had nothing pointing at
it. Persisting git/ and models-store.json keeps the packages those
settings now declare from being re-cloned every session.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index 5f49c56..a2958bb 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ Provider credentials never appear in config: passthrough env forwards host value
Agent config comes from the host's own dirs — the agents are also used locally, so their config already exists where they look for it. The config-shaped entries mount read-only at their normal paths inside the container:
-- pi: `~/.pi/agent/` — `AGENTS.md`, `skills/`
+- pi: `~/.pi/agent/` — `AGENTS.md`, `settings.json`, `models.json`, `keybindings.json`, `extensions/`, `skills/`
- claude: `~/.claude/` — `CLAUDE.md`, `settings.json`, `skills/`, `agents/`, `commands/`, `hooks/`
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 each host dir is runtime state (credentials, transcripts) and never enters the container. Project-level agent config (`.claude/`, `CLAUDE.md`, `AGENTS.md` in the repo) rides the workspace mount; the agents layer it themselves.
@@ -89,6 +89,8 @@ The two agents get opposite persistence policies, chosen by failure mode.
**Pi: ephemeral by default, allowlist what persists.** Each session gets a fresh, empty writable dir at `/root/.pi/agent`, discarded on teardown, with the persistent pieces bound on top:
- `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)
+- `models-store.json` — global, alongside `auth.json`; pi's model catalog cache, rebuilt from scratch every session otherwise
+- `git/` — global, alongside `auth.json`; the clones of the git packages named in pi's `settings.json`, so package fetching isn't paid on every start
- `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.
diff --git a/src/config.rs b/src/config.rs
index 3f2919d..7df9ac4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -70,7 +70,14 @@ impl Agent {
/// over-inclusion cheap.
pub fn config_allowlist(&self) -> &'static [&'static str] {
match self {
- Self::Pi => &["AGENTS.md", "skills"],
+ Self::Pi => &[
+ "AGENTS.md",
+ "settings.json",
+ "models.json",
+ "keybindings.json",
+ "extensions",
+ "skills",
+ ],
Self::Claude => &[
"CLAUDE.md",
"settings.json",
@@ -1487,6 +1494,8 @@ mod tests {
#[test]
fn agent_allowlists() {
assert!(Agent::Pi.config_allowlist().contains(&"AGENTS.md"));
+ assert!(Agent::Pi.config_allowlist().contains(&"settings.json"));
+ assert!(Agent::Pi.config_allowlist().contains(&"extensions"));
assert!(Agent::Claude.config_allowlist().contains(&"CLAUDE.md"));
assert!(Agent::Claude.config_allowlist().contains(&"hooks"));
assert_eq!(Agent::Claude.container_config_dir(), "/root/.claude");
diff --git a/src/main.rs b/src/main.rs
index ade424b..b80c258 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -32,6 +32,17 @@ const PROMPT_TARGET: &str = "/root/.ramekin/ramekin-prompt.md";
/// (worst case: rot) rather than vanishing (worst case: lost auth).
const CLAUDE_EPHEMERAL: &[&str] = &["statsig", "todos", "shell-snapshots", "debug"];
+/// The files in pi's agent dir that are runtime state rather than config,
+/// bound individually from `$XDG_DATA_HOME/ramekin/agents/pi/` so they
+/// survive the otherwise-ephemeral agent dir: credentials and the model
+/// catalog cache. Kept to files pi is known to write — anything else it
+/// writes shows up in the teardown report as a candidate for this list.
+const PI_PERSISTENT_FILES: &[&str] = &["auth.json", "models-store.json"];
+
+/// Where pi clones the git packages named in its `settings.json`. Persisted
+/// because re-cloning every package on every session start is pure latency.
+const PI_PACKAGES_DIR: &str = "git";
+
#[derive(Parser)]
#[command(about = "Run a coding agent (pi or Claude Code) in a containerized environment", version = VERSION)]
struct Cli {
@@ -262,8 +273,9 @@ fn confirm(prompt: &str) -> Result<bool> {
/// vanish along with auth or onboarding state).
enum AgentState {
Pi {
- /// `$XDG_DATA_HOME/ramekin/agents/pi/`; holds `auth.json`, the one
- /// global file that survives across sessions.
+ /// `$XDG_DATA_HOME/ramekin/agents/pi/`; holds the global state that
+ /// survives across sessions — `PI_PERSISTENT_FILES` and the package
+ /// checkouts under `PI_PACKAGES_DIR`.
state_dir: PathBuf,
/// `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
repo_sessions_dir: PathBuf,
@@ -304,24 +316,26 @@ impl AgentState {
} => {
fs_err::create_dir_all(state_dir).into_diagnostic()?;
fs_err::create_dir_all(repo_sessions_dir).into_diagnostic()?;
+ fs_err::create_dir_all(state_dir.join(PI_PACKAGES_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.
+ // Migrate auth from the pre-redesign location
+ // (~/.config/ramekin/agent/auth.json) before the init below
+ // would claim the path with an empty file.
let auth_file = state_dir.join("auth.json");
- if !auth_file.exists() {
- let old_auth = xdg
+ if !auth_file.exists()
+ && let Some(old) = xdg
.get_config_home()
.map(|config_home| config_home.join("agent/auth.json"))
- .filter(|p| p.exists());
- match old_auth {
- Some(old) => {
- info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
- fs_err::copy(&old, &auth_file).into_diagnostic()?;
- }
- None => init_json_file(&auth_file)?,
- }
+ .filter(|p| p.exists())
+ {
+ info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
+ fs_err::copy(&old, &auth_file).into_diagnostic()?;
+ }
+
+ // These bind-mount as files, so they have to exist before the
+ // container starts.
+ for name in PI_PERSISTENT_FILES {
+ init_json_file(&state_dir.join(name))?;
}
}
Self::Claude {
@@ -361,21 +375,28 @@ impl AgentState {
};
match self {
// Fresh empty writable dir per session, with the allowlisted
- // persistent pieces (auth.json, per-repo sessions/) bound on top.
+ // persistent pieces bound on top.
Self::Pi {
state_dir,
repo_sessions_dir,
- } => vec![
- rw(session_dir.join("agent"), config::PI_AGENT_DIR.into()),
- rw(
- state_dir.join("auth.json"),
- format!("{}/auth.json", config::PI_AGENT_DIR),
- ),
- rw(
+ } => {
+ let mut mounts = vec![rw(session_dir.join("agent"), config::PI_AGENT_DIR.into())];
+ mounts.extend(PI_PERSISTENT_FILES.iter().map(|name| {
+ rw(
+ state_dir.join(name),
+ format!("{}/{name}", config::PI_AGENT_DIR),
+ )
+ }));
+ mounts.push(rw(
+ state_dir.join(PI_PACKAGES_DIR),
+ format!("{}/{PI_PACKAGES_DIR}", config::PI_AGENT_DIR),
+ ));
+ mounts.push(rw(
repo_sessions_dir.clone(),
format!("{}/sessions", config::PI_AGENT_DIR),
- ),
- ],
+ ));
+ mounts
+ }
// Persistent state dir and state file, with fresh session-scoped
// dirs bound over the known ephemeral subdirs.
Self::Claude {
@@ -1268,8 +1289,15 @@ mod tests {
let agent_dir = target("/root/.pi/agent").expect("session agent dir mount");
assert_eq!(agent_dir.source, PathBuf::from("/cache/sessions/abc/agent"));
- let auth = target("/root/.pi/agent/auth.json").expect("auth mount");
- assert_eq!(auth.source, PathBuf::from("/data/agents/pi/auth.json"));
+ for name in PI_PERSISTENT_FILES {
+ let m = target(&format!("/root/.pi/agent/{name}"))
+ .unwrap_or_else(|| panic!("{name} mount"));
+ assert_eq!(m.source, PathBuf::from(format!("/data/agents/pi/{name}")));
+ assert!(m.writable);
+ }
+
+ let packages = target("/root/.pi/agent/git").expect("packages mount");
+ assert_eq!(packages.source, PathBuf::from("/data/agents/pi/git"));
let sessions = target("/root/.pi/agent/sessions").expect("sessions mount");
assert_eq!(sessions.source, PathBuf::from("/data/repos/x-1/sessions"));
diff --git a/src/outbox.rs b/src/outbox.rs
index cbac932..60bb3a9 100644
--- a/src/outbox.rs
+++ b/src/outbox.rs
@@ -280,10 +280,10 @@ mod tests {
#[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");
+ write_proposal(data_home.path(), "repo-1", "abc", "pi", "auth.json");
let proposals = scan(data_home.path()).unwrap();
- // settings.json is claude-shaped, not in pi's allowlist.
+ // auth.json is pi runtime state, never config, so it's unallowlisted.
assert_eq!(proposals[0].host_target(), None);
}