Run Claude Code or pi, selected through KDL profiles
A profile is a named bundle of agent + provider plumbing (env vars,
extra mounts), defined in KDL and merged by name across layers with
last-writer-takes-the-definition. The binary ships only the trivial
`pi` and `claude` profiles so ramekin runs with zero config; selection
is a bare `profile "name"` node, with precedence binary default < user
< project < project-local < -p. Profile selection subsumes agent
selection — there is no separate --agent. The active profile's env and
mounts form a layer below all file layers, so any layer can adjust one
variable without redefining the profile.
Claude gets the opposite persistence policy from pi, chosen by failure
mode: ~/.claude and ~/.claude.json persist by default from
$XDG_DATA_HOME/ramekin/agents/ (shared across repos; the per-slug
workspace mount partitions the cwd-keyed projects map), with fresh
session-scoped dirs bound over the known ephemeral junk (statsig,
todos, shell-snapshots, debug). Host ~/.claude config (CLAUDE.md,
settings.json, skills/, agents/, commands/) mounts read-only via the
same binary-layer allowlist mechanism as pi's.
Base images build to per-agent tags (ramekin-pi, ramekin-claude); a
project .ramekin/Dockerfile declares ARG BASE / FROM ${BASE} and gets
the active agent's tag, so one project Dockerfile serves both, with
repo- and agent-specific project tags.
Harvested from the claude-code branch: Dockerfile.claude with yolo
mode expressed through managed settings + IS_SANDBOX, the
--append-system-prompt-file distinction (Claude's plain flag would
append the literal path string), and the GitHub-token BuildKit secret
for rate-limited API calls during image builds. The rendered prompt
moves to a neutral /root/.ramekin/ path serving both agents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
diff --git a/.ramekin/Dockerfile b/.ramekin/Dockerfile
index 2e13613..2cc7e82 100644
--- a/.ramekin/Dockerfile
+++ b/.ramekin/Dockerfile
@@ -1,4 +1,5 @@
-FROM ramekin-agent
+ARG BASE
+FROM ${BASE}
ENV RANGER_DEFAULT_BACKLOG="ramekin"
diff --git a/assets/Dockerfile b/assets/Dockerfile
index 745167f..cce9b4f 100644
--- a/assets/Dockerfile
+++ b/assets/Dockerfile
@@ -22,7 +22,13 @@ RUN --mount=type=cache,target=/tmp/downloads \
&& curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
| tar xz -C /usr/local/bin ranger
-RUN JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name) && \
+RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
+ if [ -s /run/secrets/github-token ]; then \
+ JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
+ https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+ else \
+ JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+ fi && \
curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
| tar xz --strip-components=0 -C /usr/local/bin ./jj
diff --git a/assets/Dockerfile.claude b/assets/Dockerfile.claude
new file mode 100644
index 0000000..f1303b0
--- /dev/null
+++ b/assets/Dockerfile.claude
@@ -0,0 +1,57 @@
+FROM node:24-trixie-slim
+
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ rm -f /etc/apt/apt.conf.d/docker-clean \
+ && apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ ca-certificates \
+ curl \
+ fd-find \
+ git \
+ jq \
+ just \
+ ripgrep \
+ && ln -sf /usr/bin/fdfind /usr/bin/fd
+
+RUN --mount=type=cache,target=/tmp/downloads \
+ curl -fsSL "https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz" \
+ | tar xz -C /usr/local/bin difft \
+ && curl -fsSL "https://github.com/facebook/dotslash/releases/latest/download/dotslash-linux-musl.aarch64.tar.gz" \
+ | tar xz -C /usr/local/bin dotslash \
+ && curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
+ | tar xz -C /usr/local/bin ranger
+
+RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
+ if [ -s /run/secrets/github-token ]; then \
+ JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
+ https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+ else \
+ JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+ fi && \
+ curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
+ | tar xz --strip-components=0 -C /usr/local/bin ./jj
+
+RUN --mount=type=cache,target=/root/.npm \
+ npm install -g @anthropic-ai/claude-code
+
+# Claude Code refuses --dangerously-skip-permissions when running as root
+# unless IS_SANDBOX=1 acknowledges the sandbox. The container is the sandbox.
+ENV IS_SANDBOX=1
+
+# Run Claude in bypass-permissions mode and skip the bypass dialog. Managed
+# settings is the highest tier, so this overrides whatever's in the user's
+# mounted ~/.claude. defaultMode replaces the --dangerously-skip-permissions
+# CLI flag; skipDangerousModePermissionPrompt suppresses the once-per-machine
+# acceptance dialog. The container is already a sandbox (see IS_SANDBOX), so
+# prompting is redundant.
+RUN mkdir -p /etc/claude-code && cat > /etc/claude-code/managed-settings.json <<'EOF'
+{
+ "permissions": { "defaultMode": "bypassPermissions" },
+ "skipDangerousModePermissionPrompt": true
+}
+EOF
+
+WORKDIR /workspace
+
+ENTRYPOINT ["claude"]
diff --git a/src/config.rs b/src/config.rs
index 6acb2b2..f6c70cc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -8,29 +8,125 @@ use miette::{Context, IntoDiagnostic, Result, bail, miette};
/// 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.
+ /// Compiled into the binary: staples, host agent-config mounts, and the
+ /// trivial profiles.
Binary,
+ /// The active profile's own env and mounts, overlaid by every file layer.
+ Profile,
/// The user layer: every `*.kdl` in `~/.config/ramekin/`, merged.
User,
/// Project-level `<workspace>/.ramekin/config.kdl`, committed.
Project,
/// Project-local `<workspace>/.ramekin/config.local.kdl`, gitignored.
ProjectLocal,
+ /// Command-line arguments (currently just `-p`).
+ Cli,
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Binary => write!(f, "binary"),
+ Self::Profile => write!(f, "profile"),
Self::User => write!(f, "user"),
Self::Project => write!(f, "project"),
Self::ProjectLocal => write!(f, "project-local"),
+ Self::Cli => write!(f, "cli"),
}
}
}
+/// The coding agent a session runs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Agent {
+ Pi,
+ Claude,
+}
+
+impl Agent {
+ pub fn parse(s: &str) -> Result<Self> {
+ match s {
+ "pi" => Ok(Self::Pi),
+ "claude" => Ok(Self::Claude),
+ other => bail!("unknown agent `{other}` (expected `pi` or `claude`)"),
+ }
+ }
+
+ pub fn name(&self) -> &'static str {
+ match self {
+ Self::Pi => "pi",
+ Self::Claude => "claude",
+ }
+ }
+
+ /// Host directory where this agent keeps its config (and, mixed in with
+ /// it, runtime state that must not enter the container).
+ pub fn host_config_dir(&self) -> &'static str {
+ match self {
+ Self::Pi => "~/.pi/agent",
+ Self::Claude => "~/.claude",
+ }
+ }
+
+ /// The config-shaped entries of the host agent dir. Only these mount
+ /// into the container (read-only); the rest is host runtime state —
+ /// credentials, transcripts, caches. Skip-if-missing makes
+ /// over-inclusion cheap.
+ pub fn config_allowlist(&self) -> &'static [&'static str] {
+ match self {
+ Self::Pi => &["AGENTS.md", "skills"],
+ Self::Claude => &["CLAUDE.md", "settings.json", "skills", "agents", "commands"],
+ }
+ }
+
+ /// The agent's config dir inside the container.
+ pub fn container_config_dir(&self) -> &'static str {
+ match self {
+ Self::Pi => PI_AGENT_DIR,
+ Self::Claude => "/root/.claude",
+ }
+ }
+}
+
+impl fmt::Display for Agent {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.name())
+ }
+}
+
+/// A named bundle of agent + provider plumbing: env vars and extra mounts.
+/// Profiles merge by name across layers, last writer takes the whole
+/// definition; fine-grained tweaks go through the ordinary layered `env`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Profile {
+ pub name: String,
+ pub agent: Agent,
+ pub env: Vec<EnvVar>,
+ pub mounts: Vec<Mount>,
+}
+
+impl Profile {
+ /// The trivial profiles shipped in the binary: bare agents with no
+ /// provider plumbing, so ramekin runs with zero config. Everything
+ /// richer is defined in KDL.
+ fn builtin() -> Vec<Self> {
+ [Agent::Pi, Agent::Claude]
+ .into_iter()
+ .map(|agent| Self {
+ name: agent.name().to_string(),
+ agent,
+ env: Vec::new(),
+ mounts: Vec::new(),
+ })
+ .collect()
+ }
+}
+
+/// The profile selected in the binary when no layer or flag picks one.
+const DEFAULT_PROFILE: &str = "pi";
+
/// A mount as written in config: unexpanded paths, optional target.
-#[derive(Debug, PartialEq)]
+#[derive(Debug, PartialEq, Clone)]
pub struct Mount {
pub source: String,
pub target: Option<String>,
@@ -82,10 +178,24 @@ pub struct ScopedValue<T> {
pub value: T,
}
-/// All configuration layers, ordered from lowest to highest precedence.
+/// All configuration layers, ordered from lowest to highest precedence,
+/// plus the resolved profile.
#[derive(Debug)]
pub struct ScopedConfig {
pub layers: Vec<ConfigLayer>,
+ /// Every known profile (builtin trivial ones plus KDL definitions),
+ /// merged by name — later layers take the whole definition.
+ pub profiles: BTreeMap<String, ScopedValue<Profile>>,
+ /// The active profile's name and the scope that selected it.
+ pub selection: ScopedValue<String>,
+ /// The active profile.
+ pub profile: Profile,
+}
+
+impl ScopedConfig {
+ pub fn agent(&self) -> Agent {
+ self.profile.agent
+ }
}
/// Mask source: a mount whose source is `/dev/null` *removes* an inherited
@@ -101,20 +211,23 @@ impl ScopedConfig {
/// relative mount targets resolve against it.
///
/// Layers are returned in precedence order (lowest first):
- /// 1. Binary (staples and host agent-config mounts)
- /// 2. User (every `*.kdl` in `~/.config/ramekin/`, merged as one layer)
- /// 3. Project (`<workspace>/.ramekin/config.kdl`)
- /// 4. Project-local (`<workspace>/.ramekin/config.local.kdl`)
+ /// 1. Binary (staples, host agent-config mounts, trivial profiles)
+ /// 2. Profile (the active profile's env and mounts)
+ /// 3. User (every `*.kdl` in `~/.config/ramekin/`, merged as one layer)
+ /// 4. Project (`<workspace>/.ramekin/config.kdl`)
+ /// 5. Project-local (`<workspace>/.ramekin/config.local.kdl`)
///
- /// Returns an error if a config file can't be parsed, or if two files
- /// within the user layer define the same key.
- pub fn load(workspace: &Path, workspace_target: &str) -> Result<Self> {
- let mut layers = vec![ConfigLayer {
- scope: Scope::Binary,
- path: None,
- mounts: binary_mounts(),
- env: Vec::new(),
- }];
+ /// `cli_profile` is the `-p` selection, which beats any layer's.
+ ///
+ /// Returns an error if a config file can't be parsed, if two files
+ /// within the user layer define the same key, or if the selected
+ /// profile isn't defined anywhere.
+ pub fn load(
+ workspace: &Path,
+ workspace_target: &str,
+ cli_profile: Option<&str>,
+ ) -> Result<Self> {
+ let mut builders = Vec::new();
// User layer: every *.kdl in the config dir, sorted by name for
// deterministic merging. Which files exist (and which are symlinks
@@ -132,7 +245,7 @@ impl ScopedConfig {
for file in &files {
builder.add_file(file, workspace_target)?;
}
- layers.push(builder.build());
+ builders.push(builder);
}
}
@@ -145,11 +258,91 @@ impl ScopedConfig {
if path.exists() {
let mut builder = LayerBuilder::new(scope, Some(path.clone()));
builder.add_file(&path, workspace_target)?;
- layers.push(builder.build());
+ builders.push(builder);
}
}
- Ok(Self { layers })
+ // Profiles merge by name across layers, last writer takes the whole
+ // definition. Selection: highest layer wins, CLI beats all, binary
+ // default when nothing selects.
+ let mut profiles: BTreeMap<String, ScopedValue<Profile>> = Profile::builtin()
+ .into_iter()
+ .map(|p| {
+ (
+ p.name.clone(),
+ ScopedValue {
+ scope: Scope::Binary,
+ value: p,
+ },
+ )
+ })
+ .collect();
+ let mut selection = ScopedValue {
+ scope: Scope::Binary,
+ value: DEFAULT_PROFILE.to_string(),
+ };
+ for builder in &builders {
+ for profile in &builder.profiles {
+ profiles.insert(
+ profile.name.clone(),
+ ScopedValue {
+ scope: builder.scope,
+ value: profile.clone(),
+ },
+ );
+ }
+ if let Some(name) = &builder.selection {
+ selection = ScopedValue {
+ scope: builder.scope,
+ value: name.clone(),
+ };
+ }
+ }
+ if let Some(name) = cli_profile {
+ selection = ScopedValue {
+ scope: Scope::Cli,
+ value: name.to_string(),
+ };
+ }
+
+ let profile = profiles
+ .get(&selection.value)
+ .map(|sv| sv.value.clone())
+ .ok_or_else(|| {
+ let known = profiles.keys().cloned().collect::<Vec<_>>().join(", ");
+ miette!(
+ "profile `{}` (selected by {}) is not defined; known profiles: {known}",
+ selection.value,
+ selection.scope,
+ )
+ })?;
+
+ // The binary layer's agent-config mounts depend on the resolved
+ // agent, so the layer is assembled only now.
+ let mut layers = vec![ConfigLayer {
+ scope: Scope::Binary,
+ path: None,
+ mounts: binary_mounts(profile.agent),
+ env: Vec::new(),
+ }];
+ layers.push(ConfigLayer {
+ scope: Scope::Profile,
+ path: None,
+ mounts: profile
+ .mounts
+ .iter()
+ .filter_map(|m| m.resolve(workspace_target))
+ .collect(),
+ env: profile.env.clone(),
+ });
+ layers.extend(builders.into_iter().map(LayerBuilder::build));
+
+ Ok(Self {
+ layers,
+ profiles,
+ selection,
+ profile,
+ })
}
/// Return merged mounts from all layers, de-duplicated by container target.
@@ -220,6 +413,9 @@ struct LayerBuilder {
mount_targets: BTreeSet<String>,
env: Vec<EnvVar>,
env_names: BTreeSet<String>,
+ profiles: Vec<Profile>,
+ profile_names: BTreeSet<String>,
+ selection: Option<String>,
}
impl LayerBuilder {
@@ -231,6 +427,9 @@ impl LayerBuilder {
mount_targets: BTreeSet::new(),
env: Vec::new(),
env_names: BTreeSet::new(),
+ profiles: Vec::new(),
+ profile_names: BTreeSet::new(),
+ selection: None,
}
}
@@ -267,6 +466,27 @@ impl LayerBuilder {
}
self.env.push(var);
}
+ for profile in raw.profiles {
+ if !self.profile_names.insert(profile.name.clone()) {
+ bail!(
+ "{}: profile `{}` is defined twice in the {} layer",
+ file.display(),
+ profile.name,
+ self.scope,
+ );
+ }
+ self.profiles.push(profile);
+ }
+ for name in raw.selections {
+ if self.selection.is_some() {
+ bail!(
+ "{}: the {} layer selects a profile twice",
+ file.display(),
+ self.scope,
+ );
+ }
+ self.selection = Some(name);
+ }
Ok(())
}
@@ -289,6 +509,8 @@ impl LayerBuilder {
struct RawConfig {
mounts: Vec<Mount>,
env: Vec<EnvVar>,
+ profiles: Vec<Profile>,
+ selections: Vec<String>,
}
fn parse_file(path: &Path) -> Result<RawConfig> {
@@ -305,12 +527,57 @@ fn parse_config(content: &str) -> Result<RawConfig> {
match node.name().value() {
"mounts" => raw.mounts.push(parse_mount(node)?),
"env" => raw.env.extend(parse_env(node)?),
+ // `profile "name" { ... }` defines; `profile "name"` selects.
+ "profile" => match parse_profile(node)? {
+ ProfileNode::Definition(profile) => raw.profiles.push(profile),
+ ProfileNode::Selection(name) => raw.selections.push(name),
+ },
other => bail!("unknown config node `{other}`"),
}
}
Ok(raw)
}
+enum ProfileNode {
+ Definition(Profile),
+ Selection(String),
+}
+
+fn parse_profile(node: &KdlNode) -> Result<ProfileNode> {
+ let name = match node.entries() {
+ [entry] if entry.name().is_none() => entry
+ .value()
+ .as_string()
+ .ok_or_else(|| miette!("`profile` takes a string name, got {}", entry.value()))?
+ .to_string(),
+ _ => bail!("`profile` takes exactly one string name"),
+ };
+
+ let Some(children) = node.children() else {
+ return Ok(ProfileNode::Selection(name));
+ };
+
+ let mut agent = None;
+ let mut env = Vec::new();
+ let mut mounts = Vec::new();
+ for child in children.nodes() {
+ match child.name().value() {
+ "agent" => agent = Some(Agent::parse(&single_string_arg(child)?)?),
+ "env" => env.extend(parse_env(child)?),
+ "mounts" => mounts.push(parse_mount(child)?),
+ other => bail!("unknown `profile` field `{other}`"),
+ }
+ }
+
+ Ok(ProfileNode::Definition(Profile {
+ agent: agent
+ .ok_or_else(|| miette!("profile `{name}` is missing `agent` (`pi` or `claude`)"))?,
+ name,
+ env,
+ mounts,
+ }))
+}
+
fn parse_mount(node: &KdlNode) -> Result<Mount> {
if !node.entries().is_empty() {
bail!("`mounts` takes a block of child nodes, not inline values");
@@ -409,30 +676,23 @@ fn bool_flag(node: &KdlNode) -> Result<bool> {
/// 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.
+/// Mounts compiled into the binary: staples plus the host's agent config for
+/// the active agent.
///
/// 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> {
+fn binary_mounts(agent: Agent) -> Vec<ResolvedMount> {
let staples = STAPLES
.iter()
.map(|source| ((*source).to_string(), resolve_container_target(source, "")));
- let agent_config = PI_AGENT_CONFIG.iter().map(|entry| {
+ let agent_config = agent.config_allowlist().iter().map(move |entry| {
(
- format!("{HOST_PI_AGENT_DIR}/{entry}"),
- format!("{PI_AGENT_DIR}/{entry}"),
+ format!("{}/{entry}", agent.host_config_dir()),
+ format!("{}/{entry}", agent.container_config_dir()),
)
});
@@ -502,6 +762,25 @@ mod tests {
const WS: &str = "/workspace/test-slug";
+ /// A ScopedConfig with the given layers and an inert trivial profile,
+ /// for tests that only exercise merging.
+ fn scoped(layers: Vec<ConfigLayer>) -> ScopedConfig {
+ ScopedConfig {
+ layers,
+ profiles: BTreeMap::new(),
+ selection: ScopedValue {
+ scope: Scope::Binary,
+ value: DEFAULT_PROFILE.into(),
+ },
+ profile: Profile {
+ name: DEFAULT_PROFILE.into(),
+ agent: Agent::Pi,
+ env: Vec::new(),
+ mounts: Vec::new(),
+ },
+ }
+ }
+
#[test]
fn parse_mounts() {
let raw = parse_config(
@@ -720,13 +999,11 @@ mod tests {
#[test]
fn merged_mounts_accumulates_across_layers() {
- let config = ScopedConfig {
- layers: vec![
- 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 config = scoped(vec![
+ 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();
@@ -739,22 +1016,20 @@ mod tests {
#[test]
fn merged_mounts_deduplicates_by_target() {
- let config = ScopedConfig {
- layers: vec![
- layer(
- Scope::User,
- vec![
- mount("/user/git", "/root/.config/git", false),
- mount("/user/jj", "/root/.config/jj", false),
- ],
- ),
- layer(
- Scope::Project,
- // Override the user git mount with a different source
- vec![mount("/project/git", "/root/.config/git", true)],
- ),
- ],
- };
+ let config = scoped(vec![
+ layer(
+ Scope::User,
+ vec![
+ mount("/user/git", "/root/.config/git", false),
+ mount("/user/jj", "/root/.config/jj", false),
+ ],
+ ),
+ 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();
// /root/.config/git appears in both layers; project layer wins
assert_eq!(merged.len(), 2);
@@ -774,18 +1049,16 @@ mod tests {
#[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 config = scoped(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!(
@@ -796,30 +1069,26 @@ mod tests {
#[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 config = scoped(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 config = scoped(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"));
@@ -827,13 +1096,11 @@ mod tests {
#[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 config = scoped(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"));
@@ -843,7 +1110,7 @@ mod tests {
#[test]
fn load_with_no_project_config() {
// Use a workspace with no .ramekin/ config files
- let config = ScopedConfig::load(Path::new("/tmp"), WS).unwrap();
+ let config = ScopedConfig::load(Path::new("/tmp"), WS, None).unwrap();
// Binary layer is always first (lowest precedence)
assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
// No project layers
@@ -867,7 +1134,7 @@ mod tests {
)
.unwrap();
- let config = ScopedConfig::load(dir.path(), WS).unwrap();
+ let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
let project = config
.layers
@@ -912,9 +1179,7 @@ mod tests {
value: Some("project".into()),
}],
};
- let config = ScopedConfig {
- layers: vec![user, project],
- };
+ let config = scoped(vec![user, project]);
let merged = config.merged_env();
assert_eq!(merged.len(), 2);
let foo = merged.iter().find(|sv| sv.value.name == "FOO").unwrap();
@@ -923,4 +1188,188 @@ mod tests {
let bar = merged.iter().find(|sv| sv.value.name == "BAR").unwrap();
assert_eq!(bar.scope, Scope::User);
}
+
+ #[test]
+ fn parse_profile_definition() {
+ let raw = parse_config(
+ r#"
+ profile "claude-bedrock" {
+ agent "claude"
+ env {
+ CLAUDE_CODE_USE_BEDROCK "1"
+ AWS_PROFILE
+ }
+ mounts { source "~/.aws" }
+ }
+ "#,
+ )
+ .unwrap();
+ assert!(raw.selections.is_empty());
+ assert_eq!(raw.profiles.len(), 1);
+ let p = &raw.profiles[0];
+ assert_eq!(p.name, "claude-bedrock");
+ assert_eq!(p.agent, Agent::Claude);
+ assert_eq!(p.env.len(), 2);
+ assert_eq!(p.env[1].name, "AWS_PROFILE");
+ assert_eq!(p.env[1].value, None);
+ assert_eq!(p.mounts.len(), 1);
+ assert_eq!(p.mounts[0].source, "~/.aws");
+ }
+
+ #[test]
+ fn parse_profile_selection() {
+ let raw = parse_config(r#"profile "pi-glm""#).unwrap();
+ assert!(raw.profiles.is_empty());
+ assert_eq!(raw.selections, vec!["pi-glm".to_string()]);
+ }
+
+ #[test]
+ fn profile_without_agent_is_rejected() {
+ let result = parse_config(r#"profile "x" { env { FOO "1" } }"#);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn profile_with_unknown_agent_is_rejected() {
+ let result = parse_config(r#"profile "x" { agent "glm" }"#);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn builtin_trivial_profile_is_the_default() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+ assert_eq!(config.selection.value, "pi");
+ assert_eq!(config.selection.scope, Scope::Binary);
+ assert_eq!(config.agent(), Agent::Pi);
+ }
+
+ #[test]
+ fn project_selection_beats_binary_default() {
+ let dir = tempfile::tempdir().unwrap();
+ let ramekin_dir = dir.path().join(".ramekin");
+ fs_err::create_dir_all(&ramekin_dir).unwrap();
+ fs_err::write(ramekin_dir.join("config.kdl"), "profile \"claude\"\n").unwrap();
+
+ let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+ assert_eq!(config.selection.value, "claude");
+ assert_eq!(config.selection.scope, Scope::Project);
+ assert_eq!(config.agent(), Agent::Claude);
+ }
+
+ #[test]
+ fn cli_selection_beats_layers() {
+ let dir = tempfile::tempdir().unwrap();
+ let ramekin_dir = dir.path().join(".ramekin");
+ fs_err::create_dir_all(&ramekin_dir).unwrap();
+ fs_err::write(ramekin_dir.join("config.kdl"), "profile \"claude\"\n").unwrap();
+
+ let config = ScopedConfig::load(dir.path(), WS, Some("pi")).unwrap();
+ assert_eq!(config.selection.value, "pi");
+ assert_eq!(config.selection.scope, Scope::Cli);
+ assert_eq!(config.agent(), Agent::Pi);
+ }
+
+ #[test]
+ fn unknown_profile_selection_is_an_error() {
+ let dir = tempfile::tempdir().unwrap();
+ let err = ScopedConfig::load(dir.path(), WS, Some("bogus")).unwrap_err();
+ assert!(err.to_string().contains("bogus"), "{err}");
+ }
+
+ #[test]
+ fn profile_env_and_mounts_form_a_layer_below_project() {
+ let dir = tempfile::tempdir().unwrap();
+ let ramekin_dir = dir.path().join(".ramekin");
+ fs_err::create_dir_all(&ramekin_dir).unwrap();
+ fs_err::write(
+ ramekin_dir.join("config.kdl"),
+ r#"
+ profile "pi-glm" {
+ agent "pi"
+ env {
+ ANTHROPIC_BASE_URL "https://open.bigmodel.cn/api/anthropic"
+ ZHIPU_API_KEY
+ }
+ mounts {
+ source "/tmp"
+ target "/root/extra"
+ }
+ }
+ profile "pi-glm"
+ env {
+ ANTHROPIC_BASE_URL "https://elsewhere.example"
+ }
+ "#,
+ )
+ .unwrap();
+
+ let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+ assert_eq!(config.profile.name, "pi-glm");
+
+ // Layered env overlays the profile's env per variable.
+ let merged = config.merged_env();
+ let base_url = merged
+ .iter()
+ .find(|sv| sv.value.name == "ANTHROPIC_BASE_URL")
+ .unwrap();
+ assert_eq!(base_url.scope, Scope::Project);
+ assert_eq!(
+ base_url.value.value.as_deref(),
+ Some("https://elsewhere.example")
+ );
+ let key = merged
+ .iter()
+ .find(|sv| sv.value.name == "ZHIPU_API_KEY")
+ .unwrap();
+ assert_eq!(key.scope, Scope::Profile);
+ assert_eq!(key.value.value, None);
+
+ // Profile mounts join the merge at profile scope.
+ let mounts = config.merged_mounts();
+ let extra = mounts
+ .iter()
+ .find(|sv| sv.value.target == "/root/extra")
+ .unwrap();
+ assert_eq!(extra.scope, Scope::Profile);
+ }
+
+ #[test]
+ fn later_layer_redefines_profile_wholesale() {
+ let dir = tempfile::tempdir().unwrap();
+ let ramekin_dir = dir.path().join(".ramekin");
+ fs_err::create_dir_all(&ramekin_dir).unwrap();
+ fs_err::write(
+ ramekin_dir.join("config.kdl"),
+ r#"
+ profile "custom" {
+ agent "pi"
+ env { FOO "project" }
+ }
+ profile "custom"
+ "#,
+ )
+ .unwrap();
+ fs_err::write(
+ ramekin_dir.join("config.local.kdl"),
+ r#"
+ profile "custom" {
+ agent "claude"
+ }
+ "#,
+ )
+ .unwrap();
+
+ let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+ // Local layer's definition wins wholesale: agent changes, env gone.
+ assert_eq!(config.agent(), Agent::Claude);
+ assert!(config.profile.env.is_empty());
+ }
+
+ #[test]
+ fn agent_allowlists() {
+ assert!(Agent::Pi.config_allowlist().contains(&"AGENTS.md"));
+ assert!(Agent::Claude.config_allowlist().contains(&"CLAUDE.md"));
+ assert_eq!(Agent::Claude.container_config_dir(), "/root/.claude");
+ }
}
diff --git a/src/main.rs b/src/main.rs
index 4717d73..ef2f679 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod config;
use std::collections::{BTreeMap, BTreeSet};
+use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -11,29 +12,43 @@ use serde::Serialize;
use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
-const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
+const PI_DOCKERFILE: &str = include_str!("../assets/Dockerfile");
+const CLAUDE_DOCKERFILE: &str = include_str!("../assets/Dockerfile.claude");
const RAMEKIN_PROMPT: &str = include_str!("../assets/ramekin-prompt.md");
const VERSION: &str = env!("RAMEKIN_VERSION");
+/// Container path of the rendered per-session system prompt.
+const PROMPT_TARGET: &str = "/root/.ramekin/ramekin-prompt.md";
+
+/// The `~/.claude` subdirectories that are caches and scratch, bound to
+/// fresh session-scoped dirs so they don't accumulate in the persistent
+/// claude state. A best-current-guess denylist; everything else persists
+/// (worst case: rot) rather than vanishing (worst case: lost auth).
+const CLAUDE_EPHEMERAL: &[&str] = &["statsig", "todos", "shell-snapshots", "debug"];
+
#[derive(Parser)]
-#[command(about = "Run a pi coding agent in a containerized environment", version = VERSION)]
+#[command(about = "Run a coding agent (pi or Claude Code) in a containerized environment", version = VERSION)]
struct Cli {
/// Workspace directory to mount (defaults to current directory)
#[arg(global = true, default_value = ".")]
workspace: PathBuf,
+ /// Profile to run (a named agent + provider bundle)
+ #[arg(short, long, global = true)]
+ profile: Option<String>,
+
#[command(subcommand)]
command: Option<Cmd>,
- /// Extra arguments forwarded to pi inside the container (after --)
+ /// Extra arguments forwarded to the agent inside the container (after --)
#[arg(last = true, global = true)]
- pi_args: Vec<String>,
+ agent_args: Vec<String>,
}
#[derive(Subcommand)]
enum Cmd {
- /// Start a containerized pi agent session
+ /// Start a containerized agent session
Run {
/// Force a full image rebuild (ignores Docker layer cache)
#[arg(long)]
@@ -71,15 +86,206 @@ fn main() -> Result<()> {
return Ok(());
}
- let ramekin = Ramekin::resolve(cli.workspace)?;
+ let ramekin = Ramekin::resolve(cli.workspace, cli.profile.as_deref())?;
match command {
- Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.pi_args),
+ Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.agent_args),
Cmd::Config => ramekin.config(),
Cmd::Completions { .. } => unreachable!(),
}
}
+// ---------------------------------------------------------------------------
+// AgentState
+// ---------------------------------------------------------------------------
+
+/// Host-side persistent state for the active agent, and how it mounts.
+///
+/// The two agents get opposite persistence policies, chosen by failure mode:
+/// pi is ephemeral by default with an allowlist of what persists (its
+/// persistent surface is small and stable); claude persists by default with
+/// a denylist of known junk (an unclassified new state file should rot, not
+/// 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.
+ state_dir: PathBuf,
+ /// `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
+ repo_sessions_dir: PathBuf,
+ },
+ Claude {
+ /// `$XDG_DATA_HOME/ramekin/agents/claude/` → `/root/.claude`.
+ /// Global across repos so OAuth tokens, account identity, and
+ /// onboarding state survive switching workspaces.
+ data_dir: PathBuf,
+ /// `$XDG_DATA_HOME/ramekin/agents/claude.json` → `/root/.claude.json`
+ /// (sibling to `~/.claude/`, not inside it). Its cwd-keyed `projects`
+ /// map partitions per repo via the `/workspace/<slug>` mount, so the
+ /// file itself stays global.
+ state_file: PathBuf,
+ },
+}
+
+impl AgentState {
+ fn for_agent(agent: config::Agent, data_home: &Path, repo_slug: &str) -> Self {
+ match agent {
+ config::Agent::Pi => Self::Pi {
+ state_dir: data_home.join("agents/pi"),
+ repo_sessions_dir: data_home.join(format!("repos/{repo_slug}/sessions")),
+ },
+ config::Agent::Claude => Self::Claude {
+ data_dir: data_home.join("agents/claude"),
+ state_file: data_home.join("agents/claude.json"),
+ },
+ }
+ }
+
+ /// Materialize persistent host-side state. Idempotent.
+ fn prepare(&self, xdg: &xdg::BaseDirectories) -> Result<()> {
+ match self {
+ Self::Pi {
+ state_dir,
+ repo_sessions_dir,
+ } => {
+ fs_err::create_dir_all(state_dir).into_diagnostic()?;
+ fs_err::create_dir_all(repo_sessions_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 = state_dir.join("auth.json");
+ if !auth_file.exists() {
+ let old_auth = 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)?,
+ }
+ }
+ }
+ Self::Claude {
+ data_dir,
+ state_file,
+ } => {
+ fs_err::create_dir_all(data_dir).into_diagnostic()?;
+ init_json_file(state_file)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Create the session-scoped directories this agent's mounts need.
+ fn prepare_session(&self, session_dir: &Path) -> Result<()> {
+ match self {
+ Self::Pi { .. } => {
+ fs_err::create_dir_all(session_dir.join("agent")).into_diagnostic()?;
+ }
+ Self::Claude { .. } => {
+ for name in CLAUDE_EPHEMERAL {
+ fs_err::create_dir_all(session_dir.join("claude").join(name))
+ .into_diagnostic()?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Agent-state mounts for one session. Read-only host-config mounts
+ /// from the binary layer sit above these.
+ fn mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+ let rw = |source: PathBuf, target: String| config::ResolvedMount {
+ source,
+ target,
+ writable: true,
+ };
+ match self {
+ // Fresh empty writable dir per session, with the allowlisted
+ // persistent pieces (auth.json, per-repo sessions/) 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(
+ repo_sessions_dir.clone(),
+ format!("{}/sessions", config::PI_AGENT_DIR),
+ ),
+ ],
+ // Persistent state dir and state file, with fresh session-scoped
+ // dirs bound over the known ephemeral subdirs.
+ Self::Claude {
+ data_dir,
+ state_file,
+ } => {
+ let mut mounts = vec![
+ rw(data_dir.clone(), "/root/.claude".into()),
+ rw(state_file.clone(), "/root/.claude.json".into()),
+ ];
+ mounts.extend(CLAUDE_EPHEMERAL.iter().map(|name| {
+ rw(
+ session_dir.join("claude").join(name),
+ format!("/root/.claude/{name}"),
+ )
+ }));
+ mounts
+ }
+ }
+ }
+
+ /// The session-scoped dir whose discarded writes the teardown report
+ /// inspects. Only pi has one: its whole agent dir is ephemeral, so a
+ /// novel write there is a candidate for the persistent allowlist.
+ /// Claude's session-scoped dirs are the ephemeral denylist — already
+ /// classified junk, not worth reporting every run.
+ fn report_dir(&self, session_dir: &Path) -> Option<PathBuf> {
+ match self {
+ Self::Pi { .. } => Some(session_dir.join("agent")),
+ Self::Claude { .. } => None,
+ }
+ }
+
+ /// Labelled host paths for `ramekin config` output.
+ fn state_labels(&self) -> Vec<(&'static str, &Path)> {
+ match self {
+ Self::Pi {
+ state_dir,
+ repo_sessions_dir,
+ } => vec![("pi state", state_dir), ("sessions", repo_sessions_dir)],
+ Self::Claude {
+ data_dir,
+ state_file,
+ } => vec![("claude ", data_dir), ("state ", state_file)],
+ }
+ }
+}
+
+/// Create a file containing `{}\n` unless it already exists. `create_new`
+/// is race-safe: two concurrent first runs can't clobber each other, and
+/// losing the race is fine — the file exists.
+fn init_json_file(file: &Path) -> Result<()> {
+ match fs_err::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(file)
+ {
+ Ok(mut f) => f.write_all(b"{}\n").into_diagnostic()?,
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(e) => return Err(e).into_diagnostic(),
+ }
+ Ok(())
+}
+
// ---------------------------------------------------------------------------
// Ramekin
// ---------------------------------------------------------------------------
@@ -87,26 +293,23 @@ 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.
+ /// so anything the agent keys by cwd (pi's session grouping, claude's
+ /// `projects` map and transcripts) gets a distinct path per repo instead
+ /// of every repo looking like the same `/workspace` project.
workspace_target: String,
+ repo_slug: String,
xdg: xdg::BaseDirectories,
- /// 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>,
config: config::ScopedConfig,
+ agent_state: AgentState,
}
impl Ramekin {
/// 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> {
+ fn resolve(workspace_arg: PathBuf, cli_profile: Option<&str>) -> Result<Self> {
let workspace = workspace_arg
.canonicalize()
.into_diagnostic()
@@ -124,106 +327,44 @@ impl Ramekin {
let repo_slug = repo_slug(&workspace);
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);
- let config = config::ScopedConfig::load(&workspace, &workspace_target)
+ let config = config::ScopedConfig::load(&workspace, &workspace_target, cli_profile)
.wrap_err("failed to load ramekin configuration")?;
+ let agent_state = AgentState::for_agent(config.agent(), &data_home, &repo_slug);
+
Ok(Self {
workspace,
workspace_target,
+ repo_slug,
xdg,
- pi_state_dir,
- repo_sessions_dir,
cache_dir,
custom_dockerfile,
config,
+ agent_state,
})
}
- /// 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.
+ /// Session plumbing mounts shared by both agents: the rendered prompt
+ /// and the workspace.
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,
- },
- ]
+ 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: self.workspace.clone(),
+ target: self.workspace_target.clone(),
+ writable: true,
+ });
+ mounts
}
/// Merge config mounts with the forced session mounts, ordered
@@ -244,20 +385,46 @@ impl Ramekin {
by_target.into_values().collect()
}
+ /// Tag of the active agent's base image.
+ fn base_image(&self) -> String {
+ format!("ramekin-{}", self.config.agent())
+ }
+
fn config(&self) -> Result<()> {
println!("Workspace");
println!(" {} → {}", self.workspace.display(), self.workspace_target);
+ println!();
+ println!("Profile");
+ println!(
+ " {} (agent {}, selected by {})",
+ self.config.profile.name,
+ self.config.agent(),
+ self.config.selection.scope,
+ );
+ for (name, sv) in &self.config.profiles {
+ let marker = if *name == self.config.profile.name {
+ "*"
+ } else {
+ " "
+ };
+ println!(" {marker} {name} ({}, agent {})", sv.scope, sv.value.agent);
+ }
+
println!();
println!("Ramekin directories");
- println!(" pi state {}", self.pi_state_dir.display());
- println!(" sessions {}", self.repo_sessions_dir.display());
+ for (label, path) in self.agent_state.state_labels() {
+ println!(" {label} {}", path.display());
+ }
println!(" cache {}", self.cache_dir.display());
let merged_mounts = self.config.merged_mounts();
let merged_env = self.config.merged_env();
let scope_label = |scope: config::Scope| -> String {
+ if scope == config::Scope::Profile {
+ return format!("profile ({})", self.config.profile.name);
+ }
self.config
.layers
.iter()
@@ -315,9 +482,9 @@ impl Ramekin {
println!();
println!("Dockerfile");
match &self.custom_dockerfile {
- Some(path) => println!(" ✓ {}", path.display()),
+ Some(path) => println!(" ✓ {} (BASE={})", path.display(), self.base_image()),
None => {
- println!(" embedded (default)");
+ println!(" embedded ({})", self.base_image());
println!(
" ✗ {} (not found)",
self.workspace.join(".ramekin/Dockerfile").display()
@@ -328,16 +495,36 @@ impl Ramekin {
Ok(())
}
- fn run(&self, rebuild: bool, pi_args: &[String]) -> Result<()> {
- info!(workspace = %self.workspace.display(), target = %self.workspace_target, "starting agent");
+ fn run(&self, rebuild: bool, agent_args: &[String]) -> Result<()> {
+ let agent = self.config.agent();
+ info!(
+ profile = %self.config.profile.name,
+ agent = %agent,
+ workspace = %self.workspace.display(),
+ target = %self.workspace_target,
+ "starting agent"
+ );
- self.prepare()?;
+ fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
+ self.agent_state.prepare(&self.xdg)?;
- // Write the embedded Dockerfile to the cache directory
- let base_dockerfile = self.cache_dir.join("Dockerfile");
- fs_err::write(&base_dockerfile, DOCKERFILE).into_diagnostic()?;
+ // Write the embedded Dockerfile to the cache directory, one file per
+ // agent so concurrent sessions of different agents don't race.
+ let dockerfile_source = match agent {
+ config::Agent::Pi => PI_DOCKERFILE,
+ config::Agent::Claude => CLAUDE_DOCKERFILE,
+ };
+ let base_dockerfile = self.cache_dir.join(format!("Dockerfile.{agent}"));
+ fs_err::write(&base_dockerfile, dockerfile_source).into_diagnostic()?;
+
+ // The base image fetches release metadata from the GitHub API at
+ // build time. Pass a host token so the build doesn't get rate-limited.
+ let gh_token = host_github_token();
+ if gh_token.is_some() {
+ info!("authenticated GitHub API for image build");
+ }
- // Build the base image
+ let base_image = self.base_image();
if rebuild {
info!("rebuilding base image (no cache)");
} else {
@@ -345,8 +532,13 @@ impl Ramekin {
}
let mut build_cmd = Command::new("docker");
build_cmd
- .args(["build", "-t", "ramekin-agent", "-f"])
+ .args(["build", "-t", &base_image, "-f"])
.arg(&base_dockerfile);
+ if let Some(token) = &gh_token {
+ build_cmd
+ .env("RAMEKIN_GH_TOKEN", token)
+ .args(["--secret", "id=github-token,env=RAMEKIN_GH_TOKEN"]);
+ }
if rebuild {
build_cmd.args(["--no-cache", "--pull"]);
}
@@ -360,27 +552,29 @@ impl Ramekin {
}
// Determine the final dockerfile, build context, and image tag. A
- // custom Dockerfile gets a repo-specific tag so it doesn't collide with
- // the base `ramekin-agent` image it builds `FROM`; sharing the tag would
- // make `docker compose up` reuse the base instead of the project layer.
- let (dockerfile, build_context, image) = match &self.custom_dockerfile {
+ // custom Dockerfile declares `ARG BASE` / `FROM ${BASE}` and gets the
+ // active agent's base tag passed in, plus a repo- and agent-specific
+ // image tag so the two never collide.
+ let (dockerfile, build_context, image, build_args) = match &self.custom_dockerfile {
Some(custom) => {
info!("building project image from .ramekin/Dockerfile");
(
custom.clone(),
self.workspace.clone(),
- project_image_name(&self.workspace),
+ project_image_name(&self.repo_slug, agent),
+ BTreeMap::from([("BASE", base_image.clone())]),
)
}
None => (
base_dockerfile,
self.cache_dir.clone(),
- "ramekin-agent".to_string(),
+ base_image.clone(),
+ BTreeMap::new(),
),
};
- // Session-scoped: compose file, rendered prompt, and a fresh empty
- // agent dir, all under a random session id so concurrent runs don't
+ // Session-scoped: compose file, rendered prompt, and fresh agent
+ // dirs, all under a random session id so concurrent runs don't
// interfere.
let session_id = session_id();
let session_dir = self
@@ -388,8 +582,7 @@ impl Ramekin {
.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()?;
+ self.agent_state.prepare_session(&session_dir)?;
let prompt = RAMEKIN_PROMPT.replace("{{WORKSPACE_PATH}}", &self.workspace_target);
fs_err::write(session_dir.join("ramekin-prompt.md"), prompt).into_diagnostic()?;
@@ -397,15 +590,23 @@ impl Ramekin {
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,
- &build_context,
- &all_mounts,
- &env_vars,
- &image,
- &self.workspace_target,
- pi_args,
- );
+ let compose = generate_compose(ComposeParams {
+ dockerfile: &dockerfile,
+ build_context: &build_context,
+ build_args,
+ mounts: &all_mounts,
+ env_vars: &env_vars,
+ image: &image,
+ working_dir: &self.workspace_target,
+ prompt_flag: match agent {
+ // Pi's --append-system-prompt accepts a file path; Claude's
+ // takes a literal string, so it needs the -file variant to
+ // read the file rather than append the literal path.
+ config::Agent::Pi => "--append-system-prompt",
+ config::Agent::Claude => "--append-system-prompt-file",
+ },
+ agent_args,
+ });
let compose_file = session_dir.join("compose.yml");
fs_err::write(&compose_file, &compose).into_diagnostic()?;
@@ -477,13 +678,15 @@ impl Ramekin {
// 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");
+ if let Some(report_dir) = self.agent_state.report_dir(&session_dir) {
+ match discarded_writes(&report_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}"),
}
- Err(e) => error!("failed to inspect session agent dir: {e}"),
}
if let Err(e) = fs_err::remove_dir_all(&session_dir) {
@@ -543,6 +746,26 @@ fn discarded_writes(agent_dir: &Path, mountpoints: &BTreeSet<PathBuf>) -> Result
Ok(found)
}
+/// Look up a GitHub token from the host environment for build-time API calls.
+///
+/// Tries env vars first, then falls back to `gh auth token`. Returns `None`
+/// if no token is available; the build degrades to anonymous API access.
+fn host_github_token() -> Option<String> {
+ for var in ["RAMEKIN_GH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"] {
+ if let Ok(v) = std::env::var(var)
+ && !v.is_empty()
+ {
+ return Some(v);
+ }
+ }
+ let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
+ (!token.is_empty()).then_some(token)
+}
+
/// Generate a random session ID for scoping the compose project and cache dir.
fn session_id() -> String {
format!("{:08x}", fastrand::u32(..))
@@ -571,12 +794,11 @@ fn repo_slug(workspace: &Path) -> String {
}
/// Docker image tag for a workspace's project image, built from its
-/// `.ramekin/Dockerfile`. Kept distinct from the base `ramekin-agent` tag so
-/// `docker compose up` builds the project layer instead of reusing the base
-/// image that shares the tag. Lowercased because Docker repository names must
-/// be lowercase.
-fn project_image_name(workspace: &Path) -> String {
- format!("ramekin-{}", repo_slug(workspace)).to_lowercase()
+/// `.ramekin/Dockerfile`. Repo- and agent-specific so it neither collides
+/// with the per-agent base tags nor lets one agent's project layer shadow
+/// the other's. Lowercased because Docker repository names must be lowercase.
+fn project_image_name(repo_slug: &str, agent: config::Agent) -> String {
+ format!("ramekin-{repo_slug}-{agent}").to_lowercase()
}
#[derive(Serialize)]
@@ -605,6 +827,8 @@ struct AgentService {
struct BuildConfig {
context: String,
dockerfile: String,
+ #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+ args: BTreeMap<&'static str, String>,
}
/// Long-form compose bind mount. Avoids the `source:target[:ro]` short form,
@@ -618,16 +842,35 @@ struct VolumeBind {
read_only: bool,
}
+/// Inputs for [`generate_compose`], grouped so the container command,
+/// mounts, environment, and build context travel together instead of as a
+/// long positional argument list.
+struct ComposeParams<'a> {
+ dockerfile: &'a Path,
+ build_context: &'a Path,
+ build_args: BTreeMap<&'static str, String>,
+ mounts: &'a [&'a config::ResolvedMount],
+ env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
+ image: &'a str,
+ working_dir: &'a str,
+ prompt_flag: &'a str,
+ agent_args: &'a [String],
+}
+
/// Generate a Docker Compose config with all volume mounts.
-fn generate_compose(
- dockerfile: &Path,
- build_context: &Path,
- mounts: &[&config::ResolvedMount],
- env_vars: &[config::ScopedValue<&config::EnvVar>],
- image: &str,
- working_dir: &str,
- pi_args: &[String],
-) -> String {
+fn generate_compose(params: ComposeParams) -> String {
+ let ComposeParams {
+ dockerfile,
+ build_context,
+ build_args,
+ mounts,
+ env_vars,
+ image,
+ working_dir,
+ prompt_flag,
+ agent_args,
+ } = params;
+
let volumes: Vec<VolumeBind> = mounts
.iter()
.map(|m| VolumeBind {
@@ -649,12 +892,11 @@ fn generate_compose(
})
.collect();
- // Always pass --append-system-prompt for the ramekin container context.
- // 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]
+ // Always pass the prompt flag for the ramekin container context.
+ // User-supplied agent args come last so they can override.
+ let command: Vec<String> = [prompt_flag.to_string(), PROMPT_TARGET.to_string()]
.into_iter()
- .chain(pi_args.iter().cloned())
+ .chain(agent_args.iter().cloned())
.collect();
let config = ComposeConfig {
@@ -663,6 +905,7 @@ fn generate_compose(
build: BuildConfig {
context: build_context.display().to_string(),
dockerfile: dockerfile.display().to_string(),
+ args: build_args,
},
image: image.to_string(),
stdin_open: true,
@@ -683,42 +926,35 @@ mod tests {
use super::*;
#[test]
- fn project_image_name_is_repo_specific_and_distinct_from_base() {
- let name = project_image_name(Path::new("/Users/alpha/src/lit-rs"));
- // Must not collide with the base image tag, or `docker compose up`
- // reuses the base instead of building the project Dockerfile.
- assert_ne!(name, "ramekin-agent");
- assert!(name.contains("lit-rs"), "got: {name}");
+ fn project_image_name_is_repo_and_agent_specific() {
+ let pi = project_image_name("lit-rs-deadbeef", config::Agent::Pi);
+ let claude = project_image_name("lit-rs-deadbeef", config::Agent::Claude);
+ // Must not collide with the per-agent base tags, or `docker compose
+ // up` reuses the base instead of building the project Dockerfile.
+ assert_ne!(pi, "ramekin-pi");
+ assert_ne!(claude, "ramekin-claude");
+ // One project Dockerfile serves both agents; the tags must differ or
+ // one agent's project layer shadows the other's.
+ assert_ne!(pi, claude);
// Docker repository names must be lowercase.
- assert_eq!(name, name.to_lowercase(), "got: {name}");
- }
-
- #[test]
- fn project_image_name_differs_per_workspace() {
- let a = project_image_name(Path::new("/Users/alpha/src/lit-rs"));
- let b = project_image_name(Path::new("/Users/alpha/src/ramekin"));
- assert_ne!(a, b);
+ assert_eq!(pi, pi.to_lowercase(), "got: {pi}");
}
- #[test]
- fn generate_compose_uses_supplied_image_tag() {
- let yaml = generate_compose(
- Path::new("/ws/.ramekin/Dockerfile"),
- Path::new("/ws"),
- &[],
- &[],
- "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}"
- );
+ fn compose_params<'a>(
+ mounts: &'a [&'a config::ResolvedMount],
+ env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
+ ) -> ComposeParams<'a> {
+ ComposeParams {
+ dockerfile: Path::new("/cache/Dockerfile.pi"),
+ build_context: Path::new("/cache"),
+ build_args: BTreeMap::new(),
+ mounts,
+ env_vars,
+ image: "ramekin-pi",
+ working_dir: "/workspace/x-1",
+ prompt_flag: "--append-system-prompt",
+ agent_args: &[],
+ }
}
#[test]
@@ -728,19 +964,53 @@ mod tests {
target: "/root/.config/git".into(),
writable: false,
};
- let yaml = generate_compose(
- Path::new("/cache/Dockerfile"),
- Path::new("/cache"),
- &[&mount],
- &[],
- "ramekin-agent",
- "/workspace/x-1",
- &[],
- );
+ let yaml = generate_compose(compose_params(&[&mount], &[]));
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}");
+ assert!(yaml.contains("working_dir: /workspace/x-1"), "{yaml}");
+ // No build args → no args key at all.
+ assert!(!yaml.contains("args:"), "{yaml}");
+ }
+
+ #[test]
+ fn generate_compose_env_passthrough_is_a_bare_name() {
+ let with_value = config::EnvVar {
+ name: "FOO".into(),
+ value: Some("bar".into()),
+ };
+ let passthrough = config::EnvVar {
+ name: "GITHUB_TOKEN".into(),
+ value: None,
+ };
+ let env = [
+ config::ScopedValue {
+ scope: config::Scope::User,
+ value: &with_value,
+ },
+ config::ScopedValue {
+ scope: config::Scope::Profile,
+ value: &passthrough,
+ },
+ ];
+ let yaml = generate_compose(compose_params(&[], &env));
+ assert!(yaml.contains("- FOO=bar"), "{yaml}");
+ assert!(yaml.contains("- GITHUB_TOKEN\n"), "{yaml}");
+ assert!(!yaml.contains("GITHUB_TOKEN="), "{yaml}");
+ }
+
+ #[test]
+ fn generate_compose_carries_base_build_arg() {
+ let mut params = compose_params(&[], &[]);
+ params.build_args = BTreeMap::from([("BASE", "ramekin-claude".to_string())]);
+ params.prompt_flag = "--append-system-prompt-file";
+ let yaml = generate_compose(params);
+ assert!(yaml.contains("BASE: ramekin-claude"), "{yaml}");
+ // Claude needs the -file variant: the plain flag would append the
+ // literal path string instead of the prompt contents.
+ assert!(yaml.contains("--append-system-prompt-file"), "{yaml}");
+ assert!(yaml.contains(PROMPT_TARGET), "{yaml}");
}
#[test]
@@ -771,4 +1041,48 @@ mod tests {
let found = discarded_writes(dir.path(), &BTreeSet::new()).unwrap();
assert!(found.is_empty());
}
+
+ #[test]
+ fn claude_state_mounts_partition_persistent_and_ephemeral() {
+ let state = AgentState::Claude {
+ data_dir: PathBuf::from("/data/agents/claude"),
+ state_file: PathBuf::from("/data/agents/claude.json"),
+ };
+ let mounts = state.mounts(Path::new("/cache/sessions/abc"));
+ let target = |t: &str| mounts.iter().find(|m| m.target == t);
+
+ let data = target("/root/.claude").expect("claude data dir mount");
+ assert_eq!(data.source, PathBuf::from("/data/agents/claude"));
+ assert!(data.writable);
+
+ let state_file = target("/root/.claude.json").expect("claude state file mount");
+ assert_eq!(state_file.source, PathBuf::from("/data/agents/claude.json"));
+
+ // Ephemeral denylist dirs bind session-scoped dirs over the junk.
+ for name in CLAUDE_EPHEMERAL {
+ let m = target(&format!("/root/.claude/{name}"))
+ .unwrap_or_else(|| panic!("missing ephemeral mount for {name}"));
+ assert_eq!(m.source, Path::new("/cache/sessions/abc/claude").join(name));
+ assert!(m.writable);
+ }
+ }
+
+ #[test]
+ fn pi_state_mounts_allowlist_persistence() {
+ let state = AgentState::Pi {
+ state_dir: PathBuf::from("/data/agents/pi"),
+ repo_sessions_dir: PathBuf::from("/data/repos/x-1/sessions"),
+ };
+ let mounts = state.mounts(Path::new("/cache/sessions/abc"));
+ let target = |t: &str| mounts.iter().find(|m| m.target == t);
+
+ 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"));
+
+ let sessions = target("/root/.pi/agent/sessions").expect("sessions mount");
+ assert_eq!(sessions.source, PathBuf::from("/data/repos/x-1/sessions"));
+ }
}