🥇 export from upstream (3b6ef04)

This commit is contained in:
2026-05-23 17:11:31 +02:00
parent 1e5cdf2476
commit 047fd9512d
9 changed files with 665 additions and 90 deletions
+7 -7
View File
@@ -3,15 +3,15 @@
# Detected ecosystem: rust
env:
# EXAMPLE_VAR: value
toolchains:
rust: "stable"
packages:
- ripgrep
- fd-find
# EXAMPLE_VAR: value
toolchains:
rust: stable
mounts:
- ~/.ssh:/home/agent/.ssh:ro
# mounts:
# - ~/.ssh:/home/agent/.ssh:ro
@@ -19,7 +19,7 @@ packages:
agent_args:
claude:
- "--dangerously-skip-permissions"
- --dangerously-skip-permissions
# shell: zsh
+71 -46
View File
@@ -1,19 +1,41 @@
# Sandcage
<p align="center">
<img src="sandcage_logo.png" width="120" alt="Sandcage">
</p>
Sandcage runs AI coding agents (Claude Code, Codex) in isolated Docker containers. Each agent gets a full development environment with your project mounted as a workspace, while your host session and credentials stay private.
<h1 align="center">Sandcage</h1>
## Why
<p align="center">
Run AI coding agents in isolated Docker containers — your machine stays yours.
</p>
Running AI agents directly on your machine means they share your shell, your credentials, and your session history. Sandcage gives each agent its own container with the tools it needs, while keeping your host environment untouched.
---
Agents in different containers can still see each other's work through shared sandbox state (~/.sandcage/), enabling session handoffs between agents working on different branches or worktrees.
## Why Sandcage?
AI coding agents need broad access to do their work: shell, filesystem, network. Letting them run directly on your machine means they share your credentials, your session history, and your entire environment.
Sandcage gives each agent its own container with the tools it needs. Your project is mounted in, changes are visible on the host, but the agent never touches your shell config, your SSH agent, or anything else outside the sandbox.
Multiple agents can run side by side. A persistent home directory means config and credentials survive between sessions, so you are not re-authenticating every time.
## How It Works
<p align="center">
<img src="topology.svg" alt="Sandcage topology — host, Docker, container, volume mounts" width="720">
</p>
1. You run `sandcage claude` (or `codex`, or `shell`) from your project directory
2. Sandcage resolves your workspace to the git root and builds Docker compose arguments
3. Your project, persistent home, and (optionally) SSH keys are mounted into the container
4. The agent runs as the container entrypoint, working in the mounted workspace
5. All file changes are immediately visible on your host
## Quick Start
### Prerequisites
- Docker (daemon must be running)
- Rust toolchain (cargo) — or download a prebuilt binary from [Releases](https://github.com/dirigence/sandcage/releases)
- **Docker** (daemon must be running)
- **Rust toolchain** (cargo) — or download a prebuilt binary from [Releases](https://github.com/dirigence/sandcage/releases)
### Install
@@ -21,70 +43,73 @@ Agents in different containers can still see each other's work through shared sa
cargo install --git https://github.com/dirigence/sandcage
```
Or from a local checkout:
### Build images and run
```bash
cargo install --path crates/sandcage
sandcage build # build base and codex images
sandcage claude # start Claude Code in the current project
```
### Build the images
That is it. Sandcage resolves your project to its git root, mounts it into the container, and drops you into the agent.
### More commands
```bash
sandcage build
```
This builds three images: `sandcage-base`, `sandcage-claude`, and `sandcage-codex`. Images whose Dockerfile hasn't changed are skipped automatically. Use `--force` to rebuild unconditionally.
### Run an agent
```bash
sandcage claude # Claude Code in current directory
sandcage claude -p ~/project # Claude Code in a specific project
sandcage claude -- --resume # forward --resume to Claude Code
sandcage codex -p ~/project # Codex in a specific project
sandcage claude -p ~/project # run in a specific project
sandcage claude -- --resume # forward args to the agent
sandcage codex -p ~/project # run Codex instead
sandcage shell # interactive shell, same environment
sandcage claude --shell # shell in the Claude image (for debugging)
sandcage claude --shell # shell in the Claude image (debugging)
sandcage init # detect ecosystem, generate .sandcage.yml
sandcage setup ssh # configure SSH key mounting
sandcage setup ssh --global # configure SSH globally
```
The workspace is resolved to the git repo root automatically. Inside a git worktree, the worktree root is used instead. Arguments after `--` are forwarded to the agent inside the container.
### Initialize a project
```bash
sandcage init
```
Detects the language ecosystem (Rust, Node, Python, Go) and generates a `.sandcage.yml` with suggested configuration.
## Configuration
Configuration is layered: compiled defaults → `~/.sandcage/config.toml``.sandcage.yml` → CLI flags
<p align="center">
<img src="config-layers.svg" alt="Configuration layering — defaults, global, project, CLI flags" width="720">
</p>
### Project configuration (.sandcage.yml)
Configuration is layered. Each level overrides the one below it, so you only set what you need:
**Compiled defaults** — sensible out of the box
**Global config** (`~/.sandcage/config.toml`) — user-wide preferences
**Project config** (`.sandcage.yml`) — per-project setup, checked into version control
**CLI flags** — per-invocation overrides
### .sandcage.yml example
```yaml
env:
DATABASE_URL: "postgres://localhost:5432/dev"
packages:
- ripgrep
- fd-find
toolchains:
rust: "stable"
rust: stable
node: "20"
env:
DATABASE_URL: "postgres://localhost:5432/dev"
mounts:
- /data/models:/models:ro
- ~/.ssh:/home/agent/.ssh:ro
agent_args:
claude:
- --dangerously-skip-permissions
shell: zsh
```
## Architecture
Run `sandcage init` to generate a starter config — it detects your project ecosystem (Rust, Node, Python, Go) and suggests appropriate toolchains and packages.
### Images (3-tier)
## Docker Image
| Image | Base | Adds |
|-------|------|------|
| sandcage-base | Debian bookworm-slim | git, ripgrep, fd, jq, curl, zsh, bash, sudo, just, uv |
| sandcage-claude | sandcage-base | Claude Code CLI |
| sandcage-codex | sandcage-base | Codex binary (multi-arch) |
Sandcage uses a single image (`sandcage`) based on Debian bookworm-slim, packed with dev tools: git, openssh-client, ripgrep, fd, jq, curl, zsh, bash, sudo, just, and uv.
AI agents (Claude Code, Codex) are installed on first run into the persistent home directory and auto-update themselves — no agent binaries baked into the image.
Build with `sandcage build`. Use `--force` to rebuild unconditionally.
## Cross-Platform
Sandcage works on **Linux**, **macOS**, and **Windows** (PowerShell, cmd, and Git Bash). On Windows with WSL, it works from both the Windows and Linux sides.
## License
+5 -3
View File
@@ -1,6 +1,7 @@
services:
claude:
image: sandcage-claude:latest
image: sandcage:latest
entrypoint: ["sandcage-claude-entrypoint"]
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
@@ -12,7 +13,8 @@ services:
stdin_open: true
codex:
image: sandcage-codex:latest
image: sandcage:latest
entrypoint: ["sandcage-codex-entrypoint"]
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
@@ -24,7 +26,7 @@ services:
stdin_open: true
shell:
image: sandcage-base:latest
image: sandcage:latest
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
+70
View File
@@ -0,0 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 360" width="800" height="360">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; }
.title { font-size: 15px; font-weight: 600; fill: #24292f; }
.layer-label { font-size: 13px; font-weight: 600; fill: #ffffff; }
.layer-desc { font-size: 11px; fill: #ffffff; opacity: 0.9; }
.side-label { font-size: 11px; fill: #57606a; }
.side-detail { font-size: 10px; fill: #8b949e; }
.arrow-text { font-size: 11px; fill: #57606a; font-weight: 500; }
</style>
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#c9d1d9"/>
<stop offset="100%" stop-color="#b1bac4"/>
</linearGradient>
<linearGradient id="grad2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#8b949e"/>
<stop offset="100%" stop-color="#6e7681"/>
</linearGradient>
<linearGradient id="grad3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#D4A574"/>
<stop offset="100%" stop-color="#c0895a"/>
</linearGradient>
<linearGradient id="grad4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#8a5c2a"/>
<stop offset="100%" stop-color="#6b4720"/>
</linearGradient>
<marker id="arr" markerWidth="8" markerHeight="6" refX="4" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#8b949e"/>
</marker>
</defs>
<!-- Title -->
<text class="title" x="400" y="30" text-anchor="middle">Configuration Layering</text>
<!-- Priority arrow on the left -->
<line x1="70" y1="60" x2="70" y2="310" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
<text class="arrow-text" x="66" y="190" text-anchor="middle" transform="rotate(-90, 66, 190)">Higher priority overrides</text>
<!-- Layer 1 - Compiled Defaults (bottom, lightest) -->
<rect x="120" y="250" width="540" height="56" rx="6" fill="url(#grad1)"/>
<text class="layer-label" x="390" y="275" text-anchor="middle" fill="#24292f">Compiled Defaults</text>
<text class="layer-desc" x="390" y="293" text-anchor="middle" fill="#57606a">Built into the sandcage binary</text>
<text class="side-label" x="680" y="275">Base values</text>
<text class="side-detail" x="680" y="291">always present</text>
<!-- Layer 2 - Global Config -->
<rect x="140" y="185" width="540" height="56" rx="6" fill="url(#grad2)"/>
<text class="layer-label" x="410" y="210" text-anchor="middle">Global Config</text>
<text class="layer-desc" x="410" y="228" text-anchor="middle">~/.sandcage/config.toml</text>
<text class="side-label" x="700" y="210">User-wide</text>
<text class="side-detail" x="700" y="226">SSH, shell, mounts</text>
<!-- Layer 3 - Project Config -->
<rect x="160" y="120" width="540" height="56" rx="6" fill="url(#grad3)"/>
<text class="layer-label" x="430" y="145" text-anchor="middle">Project Config</text>
<text class="layer-desc" x="430" y="163" text-anchor="middle">.sandcage.yml</text>
<text class="side-label" x="720" y="145">Per-project</text>
<text class="side-detail" x="720" y="161">packages, toolchains, env</text>
<!-- Layer 4 - CLI Flags (top, darkest) -->
<rect x="180" y="55" width="540" height="56" rx="6" fill="url(#grad4)"/>
<text class="layer-label" x="450" y="80" text-anchor="middle">CLI Flags</text>
<text class="layer-desc" x="450" y="98" text-anchor="middle">sandcage claude -p ~/project -- --resume</text>
<text class="side-label" x="740" y="80">Per-invocation</text>
<text class="side-detail" x="740" y="96">highest priority</text>
<!-- Bottom note -->
<text x="400" y="340" text-anchor="middle" font-size="11" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">Each layer overrides the one below it. Only set what you need — sensible defaults handle the rest.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+90 -33
View File
@@ -14,9 +14,7 @@ use crate::config::SandcageConfig;
// Bundled Dockerfiles
// ---------------------------------------------------------------------------
pub const DOCKERFILE_BASE: &str = include_str!("../../../images/base/Dockerfile");
pub const DOCKERFILE_CLAUDE: &str = include_str!("../../../images/claude/Dockerfile");
pub const DOCKERFILE_CODEX: &str = include_str!("../../../images/codex/Dockerfile");
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml");
@@ -179,12 +177,8 @@ fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
Ok(tmp)
}
fn image_for_service(service: &str) -> &'static str {
match service {
"claude" => "sandcage-claude",
"codex" => "sandcage-codex",
_ => "sandcage-base",
}
fn image_for_service(_service: &str) -> &'static str {
"sandcage"
}
fn require_image(docker: &Path, image: &str) -> Result<()> {
@@ -202,6 +196,32 @@ fn require_image(docker: &Path, image: &str) -> Result<()> {
}
}
fn expand_mount_path(mount: &str) -> String {
let Some(colon_pos) = mount.find(':') else {
return mount.to_string();
};
let host_path = &mount[..colon_pos];
let rest = &mount[colon_pos..];
let expanded = if host_path == "~" {
match dirs::home_dir() {
Some(home) => home.to_string_lossy().into_owned(),
None => return mount.to_string(),
}
} else if let Some(suffix) = host_path.strip_prefix("~/") {
match dirs::home_dir() {
Some(home) => format!("{}/{suffix}", home.to_string_lossy()),
None => return mount.to_string(),
}
} else {
return mount.to_string();
};
let normalized = expanded.replace('\\', "/");
format!("{normalized}{rest}")
}
pub fn build_run_args(
service: &str,
compose_path: &str,
@@ -228,7 +248,7 @@ pub fn build_run_args(
for mount in mount_list {
if mount.contains(':') {
args.push("-v".to_string());
args.push(mount.clone());
args.push(expand_mount_path(mount));
}
}
}
@@ -484,9 +504,7 @@ pub fn build_images(force: bool) -> Result<()> {
let images: &[(&str, &str)] = &[
("sandcage-base", DOCKERFILE_BASE),
("sandcage-claude", DOCKERFILE_CLAUDE),
("sandcage-codex", DOCKERFILE_CODEX),
("sandcage", DOCKERFILE),
];
let hashes_path = build_hashes_path()?;
@@ -630,9 +648,7 @@ mod tests {
#[test]
fn dockerfiles_are_bundled() {
assert!(!DOCKERFILE_BASE.is_empty(), "base Dockerfile should not be empty");
assert!(!DOCKERFILE_CLAUDE.is_empty(), "claude Dockerfile should not be empty");
assert!(!DOCKERFILE_CODEX.is_empty(), "codex Dockerfile should not be empty");
assert!(!DOCKERFILE.is_empty(), "Dockerfile should not be empty");
}
#[test]
@@ -658,13 +674,13 @@ mod tests {
let path = tmp.path().join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "aabbcc".to_string());
hashes.insert("sandcage".to_string(), "aabbcc".to_string());
hashes.insert("sandcage-claude".to_string(), "ddeeff".to_string());
write_stored_hashes(&path, &hashes).expect("write hashes");
let loaded = read_stored_hashes(&path).expect("read hashes");
assert_eq!(loaded.get("sandcage-base").map(String::as_str), Some("aabbcc"));
assert_eq!(loaded.get("sandcage").map(String::as_str), Some("aabbcc"));
assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff"));
}
@@ -683,9 +699,9 @@ mod tests {
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
stored.insert("sandcage".to_string(), hash.clone());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &hash);
assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)");
}
@@ -695,9 +711,9 @@ mod tests {
let current_hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), "old_stale_hash".to_string());
stored.insert("sandcage".to_string(), "old_stale_hash".to_string());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &current_hash);
assert!(needs_build, "different hash should be a cache miss (rebuild needed)");
}
@@ -707,7 +723,7 @@ mod tests {
let current_hash = sha256_hex(dockerfile);
let stored: HashMap<String, String> = HashMap::new();
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &current_hash);
assert!(needs_build, "missing entry should be treated as a cache miss");
}
@@ -718,10 +734,10 @@ mod tests {
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
stored.insert("sandcage".to_string(), hash.clone());
let force = true;
let needs_build = force || stored.get("sandcage-base").map_or(true, |h| h != &hash);
let needs_build = force || stored.get("sandcage").map_or(true, |h| h != &hash);
assert!(needs_build, "force flag should always trigger a rebuild");
}
@@ -732,7 +748,7 @@ mod tests {
let path = tmp.path().join("a").join("b").join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "abc123".to_string());
hashes.insert("sandcage".to_string(), "abc123".to_string());
write_stored_hashes(&path, &hashes).expect("should create parent dirs and write");
assert!(path.exists(), "hash file should have been created");
@@ -740,10 +756,10 @@ mod tests {
#[test]
fn image_for_service_maps_correctly() {
assert_eq!(image_for_service("claude"), "sandcage-claude");
assert_eq!(image_for_service("codex"), "sandcage-codex");
assert_eq!(image_for_service("shell"), "sandcage-base");
assert_eq!(image_for_service("anything-else"), "sandcage-base");
assert_eq!(image_for_service("claude"), "sandcage");
assert_eq!(image_for_service("codex"), "sandcage");
assert_eq!(image_for_service("shell"), "sandcage");
assert_eq!(image_for_service("anything-else"), "sandcage");
}
#[test]
@@ -864,6 +880,8 @@ mod tests {
#[test]
fn build_run_args_with_mounts() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let config = SandcageConfig {
mounts: Some(vec![
"~/.ssh:/home/agent/.ssh:ro".to_string(),
@@ -874,8 +892,10 @@ mod tests {
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"-v".to_string()), "should contain -v flag");
assert!(args.contains(&"~/.ssh:/home/agent/.ssh:ro".to_string()));
assert!(args.contains(&"~/.gitconfig:/home/agent/.gitconfig:ro".to_string()));
let expected_ssh = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let expected_git = format!("{home_str}/.gitconfig:/home/agent/.gitconfig:ro");
assert!(args.contains(&expected_ssh), "SSH mount should be expanded");
assert!(args.contains(&expected_git), "gitconfig mount should be expanded");
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let first_v = args.iter().position(|a| a == "-v").unwrap();
@@ -901,6 +921,8 @@ mod tests {
#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let config = SandcageConfig {
@@ -912,7 +934,8 @@ mod tests {
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
let mount_pos = args.iter().position(|a| a == "~/.ssh:/home/agent/.ssh:ro").unwrap();
let expected_mount = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let mount_pos = args.iter().position(|a| *a == expected_mount).unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(env_pos < service_pos, "env before service");
@@ -968,4 +991,38 @@ mod tests {
let env = build_compose_env(&workspace, &config).expect("build_compose_env");
assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace");
}
#[test]
fn expand_mount_path_expands_tilde_slash() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
assert_eq!(result, format!("{home_str}/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn expand_mount_path_expands_bare_tilde() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~:/container");
assert_eq!(result, format!("{home_str}:/container"));
}
#[test]
fn expand_mount_path_ignores_absolute() {
let result = expand_mount_path("/absolute:/container:ro");
assert_eq!(result, "/absolute:/container:ro");
}
#[test]
fn expand_mount_path_ignores_relative() {
let result = expand_mount_path("./relative:/container");
assert_eq!(result, "./relative:/container");
}
#[test]
fn expand_mount_path_ignores_no_colon() {
let result = expand_mount_path("no-colon");
assert_eq!(result, "no-colon");
}
}
@@ -0,0 +1,234 @@
# Mount Tilde Expansion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expand `~` in mount paths to the user's home directory at Docker invocation time, so mounts work on all platforms without relying on shell expansion.
**Architecture:** A single pure function `expand_mount_path` in `docker.rs` replaces `~` with `dirs::home_dir()` and normalizes Windows separators. Called from `build_run_args` where mounts become `-v` flags. Config files keep the portable `~` form.
**Tech Stack:** Rust, `dirs` crate (already a dependency)
---
### Task 1: Add `expand_mount_path` function with TDD
**Files:**
- Modify: `crates/sandcage/src/docker.rs:227-233` (call site)
- Modify: `crates/sandcage/src/docker.rs` (add function + tests at end of file)
- [ ] **Step 1: Write the failing tests for `expand_mount_path`**
Add these tests inside the existing `#[cfg(test)] mod tests` block in `docker.rs`:
```rust
#[test]
fn expand_mount_path_expands_tilde_slash() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
assert_eq!(result, format!("{home_str}/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn expand_mount_path_expands_bare_tilde() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~:/container");
assert_eq!(result, format!("{home_str}:/container"));
}
#[test]
fn expand_mount_path_ignores_absolute() {
let result = expand_mount_path("/absolute:/container:ro");
assert_eq!(result, "/absolute:/container:ro");
}
#[test]
fn expand_mount_path_ignores_relative() {
let result = expand_mount_path("./relative:/container");
assert_eq!(result, "./relative:/container");
}
#[test]
fn expand_mount_path_ignores_no_colon() {
let result = expand_mount_path("no-colon");
assert_eq!(result, "no-colon");
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p sandcage expand_mount_path`
Expected: compilation error — `expand_mount_path` is not defined.
- [ ] **Step 3: Implement `expand_mount_path`**
Add this function in `docker.rs`, just above `build_run_args`:
```rust
fn expand_mount_path(mount: &str) -> String {
let Some(colon_pos) = mount.find(':') else {
return mount.to_string();
};
let host_path = &mount[..colon_pos];
let rest = &mount[colon_pos..];
let expanded = if host_path == "~" {
match dirs::home_dir() {
Some(home) => home.to_string_lossy().into_owned(),
None => return mount.to_string(),
}
} else if let Some(suffix) = host_path.strip_prefix("~/") {
match dirs::home_dir() {
Some(home) => format!("{}/{suffix}", home.to_string_lossy()),
None => return mount.to_string(),
}
} else {
return mount.to_string();
};
let normalized = expanded.replace('\\', "/");
format!("{normalized}{rest}")
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p sandcage expand_mount_path`
Expected: all 5 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add crates/sandcage/src/docker.rs
git commit -m "✨ Add expand_mount_path for cross-platform tilde expansion"
```
---
### Task 2: Wire `expand_mount_path` into `build_run_args` and update existing tests
**Files:**
- Modify: `crates/sandcage/src/docker.rs:229-231` (call site)
- Modify: `crates/sandcage/src/docker.rs` (existing tests at lines 866-921)
- [ ] **Step 1: Replace `mount.clone()` with `expand_mount_path(mount)` in `build_run_args`**
Change lines 229-231 from:
```rust
if mount.contains(':') {
args.push("-v".to_string());
args.push(mount.clone());
}
```
To:
```rust
if mount.contains(':') {
args.push("-v".to_string());
args.push(expand_mount_path(mount));
}
```
- [ ] **Step 2: Update `build_run_args_with_mounts` test**
The test at line 866 currently asserts literal `~/.ssh` strings. Replace the assertions:
```rust
#[test]
fn build_run_args_with_mounts() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let config = SandcageConfig {
mounts: Some(vec![
"~/.ssh:/home/agent/.ssh:ro".to_string(),
"~/.gitconfig:/home/agent/.gitconfig:ro".to_string(),
]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"-v".to_string()), "should contain -v flag");
let expected_ssh = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let expected_git = format!("{home_str}/.gitconfig:/home/agent/.gitconfig:ro");
assert!(args.contains(&expected_ssh), "SSH mount should be expanded");
assert!(args.contains(&expected_git), "gitconfig mount should be expanded");
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let first_v = args.iter().position(|a| a == "-v").unwrap();
assert!(first_v < service_pos, "-v flags must come before the service name");
}
```
- [ ] **Step 3: Update `build_run_args_mounts_with_env_and_extra_args` test**
The test at line 902 asserts literal `~/.ssh`. Replace the mount assertion:
```rust
#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let config = SandcageConfig {
env: Some(env),
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
let expected_mount = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let mount_pos = args.iter().position(|a| *a == expected_mount).unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(env_pos < service_pos, "env before service");
assert!(mount_pos < service_pos, "mounts before service");
assert!(resume_pos > service_pos, "extra args after service");
}
```
- [ ] **Step 4: Run the full test suite**
Run: `cargo test -p sandcage`
Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
git add crates/sandcage/src/docker.rs
git commit -m "🐛 Expand tilde in mount paths before passing to Docker"
```
---
### Task 3: Clean up the literal `~` directory artifact
- [ ] **Step 1: Remove the literal `~` directory from the project root**
```bash
rm -rf "~"
```
Verify it's gone:
```bash
ls -la "~" 2>&1
```
Expected: "No such file or directory"
- [ ] **Step 2: Verify `.gitignore` doesn't need updating**
Check if the `~` directory was tracked by git:
```bash
git status
```
If it shows as untracked (it should, since git wouldn't track a `~` directory), no `.gitignore` change is needed.
@@ -0,0 +1,74 @@
# Mount Tilde Expansion
**Date:** 2026-05-23
**Status:** Approved
## Problem
Mount paths in `.sandcage.yml` use `~` for portability (e.g. `~/.ssh:/home/agent/.ssh:ro`).
Sandcage passes these strings verbatim as `-v` flags to `docker compose run` via Rust's
`Command::new()`, which does not invoke a shell. Since `~` is a shell expansion feature,
Docker receives the literal string `~/.ssh` and either:
- **Windows:** Errors with "invalid characters for a local volume name"
- **Linux/macOS:** Creates a literal `~` directory relative to CWD
## Platform Matrix
| Environment | `dirs::home_dir()` | Docker path format | Notes |
|---|---|---|---|
| Native Windows (PowerShell/cmd) | `C:\Users\<user>` | `C:/Users/<user>/...` | Forward slashes required |
| MINGW / Git Bash | `C:\Users\<user>` (via Rust) | `C:/Users/<user>/...` | Rust bypasses MSYS path mangling |
| WSL (Linux binary) | `/home/<user>` | `/home/<user>/...` | Native Linux paths |
| Native Linux | `/home/<user>` | `/home/<user>/...` | Straightforward |
| macOS | `/Users/<user>` | `/Users/<user>/...` | Straightforward |
Key insight: Rust's `Command::new()` spawns Docker directly — no shell involved — so
the environment (PowerShell, Git Bash, etc.) is irrelevant. `dirs::home_dir()` returns
the correct native path on every platform.
## Design
### Approach: Expand at mount-pass-through time
Config files keep the portable `~` form. Expansion happens only when building Docker
CLI args.
### Implementation
Add `expand_mount_path(mount: &str) -> String` in `docker.rs`:
1. Split mount on `:` to extract host path, container path, and optional mode.
2. If host path starts with `~/` or equals `~`, replace the `~` prefix with
`dirs::home_dir()`.
3. On Windows (`cfg!(windows)`), normalize host path separators to forward slashes.
4. Rejoin parts with `:` and return.
Call site: `build_run_args()` in `docker.rs`, replacing the current
`args.push(mount.clone())` with `args.push(expand_mount_path(mount))`.
### Edge Cases
| Input | Output (Linux) | Output (Windows) |
|---|---|---|
| `~/.ssh:/home/agent/.ssh:ro` | `/home/user/.ssh:/home/agent/.ssh:ro` | `C:/Users/user/.ssh:/home/agent/.ssh:ro` |
| `~:/container` | `/home/user:/container` | `C:/Users/user:/container` |
| `/absolute:/container:ro` | `/absolute:/container:ro` (unchanged) | `/absolute:/container:ro` (unchanged) |
| `./relative:/container` | `./relative:/container` (unchanged) | `./relative:/container` (unchanged) |
### Not in Scope
- `$HOME` / `${HOME}` expansion
- `~otheruser/` expansion
- Environment variable substitution in mount paths
### Testing
- Unit tests for `expand_mount_path` covering all edge cases above.
- Update existing `build_run_args` tests that assert literal `~/.ssh` to assert the
expanded absolute path.
### Files Changed
- `crates/sandcage/src/docker.rs` — add `expand_mount_path`, call it in `build_run_args`
- Existing tests in `docker.rs` — update mount assertions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

+113
View File
@@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 520" width="800" height="520">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; }
.title { font-size: 14px; font-weight: 600; fill: #24292f; }
.label { font-size: 12px; fill: #24292f; }
.sublabel { font-size: 11px; fill: #57606a; }
.arrow-label { font-size: 10px; fill: #57606a; font-style: italic; }
.box-host { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1.5; rx: 8; }
.box-docker { fill: #fff8f0; stroke: #D4A574; stroke-width: 1.5; rx: 8; }
.box-container { fill: #ffffff; stroke: #D4A574; stroke-width: 2; rx: 6; }
.box-component { rx: 4; stroke-width: 1; }
.comp-cli { fill: #dafbe1; stroke: #56d364; }
.comp-project { fill: #ddf4ff; stroke: #54aeff; }
.comp-home { fill: #fff1e5; stroke: #D4A574; }
.comp-agent { fill: #fbefff; stroke: #bc8cff; }
.comp-workspace { fill: #ddf4ff; stroke: #54aeff; }
.comp-ssh { fill: #f6f8fa; stroke: #8b949e; }
.arrow { stroke: #57606a; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
.arrow-mount { stroke: #D4A574; stroke-width: 1.5; stroke-dasharray: 6,3; fill: none; marker-end: url(#arrowhead-mount); }
</style>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#57606a"/>
</marker>
<marker id="arrowhead-mount" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#D4A574"/>
</marker>
</defs>
<!-- Host Machine -->
<rect class="box-host" x="20" y="20" width="760" height="480"/>
<text class="title" x="40" y="48">Host Machine</text>
<!-- Sandcage CLI -->
<rect class="box-component comp-cli" x="50" y="70" width="180" height="60"/>
<text class="label" x="140" y="96" text-anchor="middle">sandcage CLI</text>
<text class="sublabel" x="140" y="114" text-anchor="middle">sandcage claude</text>
<!-- Project Files -->
<rect class="box-component comp-project" x="50" y="160" width="180" height="70"/>
<text class="label" x="140" y="186" text-anchor="middle">Project Files</text>
<text class="sublabel" x="140" y="204" text-anchor="middle">~/projects/my-app/</text>
<text class="sublabel" x="140" y="218" text-anchor="middle">(resolved to git root)</text>
<!-- Persistent Home -->
<rect class="box-component comp-home" x="50" y="260" width="180" height="60"/>
<text class="label" x="140" y="286" text-anchor="middle">Persistent Home</text>
<text class="sublabel" x="140" y="304" text-anchor="middle">~/.sandcage/home/</text>
<!-- SSH Keys -->
<rect class="box-component comp-ssh" x="50" y="350" width="180" height="50"/>
<text class="label" x="140" y="376" text-anchor="middle">SSH Keys</text>
<text class="sublabel" x="140" y="392" text-anchor="middle">~/.ssh/</text>
<!-- Arrow from CLI down -->
<line class="arrow" x1="230" y1="100" x2="320" y2="100"/>
<!-- Docker Layer -->
<rect class="box-docker" x="310" y="60" width="450" height="420"/>
<text class="title" x="330" y="88" fill="#9a6b3a">Docker</text>
<!-- Container -->
<rect class="box-container" x="340" y="105" width="390" height="350"/>
<text class="title" x="360" y="133">Container</text>
<!-- Agent -->
<rect class="box-component comp-agent" x="370" y="150" width="330" height="60"/>
<text class="label" x="535" y="176" text-anchor="middle">AI Agent (Claude Code / Codex)</text>
<text class="sublabel" x="535" y="194" text-anchor="middle">runs as entrypoint</text>
<!-- Mounted Workspace -->
<rect class="box-component comp-workspace" x="370" y="235" width="330" height="60"/>
<text class="label" x="535" y="261" text-anchor="middle">/workspace/my-app</text>
<text class="sublabel" x="535" y="279" text-anchor="middle">project mounted read-write</text>
<!-- Mounted Home -->
<rect class="box-component comp-home" x="370" y="320" width="330" height="55"/>
<text class="label" x="535" y="346" text-anchor="middle">/home/agent</text>
<text class="sublabel" x="535" y="362" text-anchor="middle">config, creds, plugins persist</text>
<!-- Mounted SSH -->
<rect class="box-component comp-ssh" x="370" y="395" width="330" height="45"/>
<text class="label" x="535" y="417" text-anchor="middle">/home/agent/.ssh (read-only)</text>
<!-- Volume mount arrows -->
<line class="arrow-mount" x1="230" y1="195" x2="368" y2="260"/>
<text class="arrow-label" x="280" y="218">mount</text>
<line class="arrow-mount" x1="230" y1="290" x2="368" y2="340"/>
<text class="arrow-label" x="272" y="305">persist</text>
<line class="arrow-mount" x1="230" y1="375" x2="368" y2="415"/>
<text class="arrow-label" x="275" y="392">ro mount</text>
<!-- Arrow from agent to workspace -->
<line class="arrow" x1="535" y1="210" x2="535" y2="233"/>
<text class="arrow-label" x="548" y="226">reads/writes</text>
<!-- Step numbers -->
<circle cx="265" cy="85" r="10" fill="#D4A574"/>
<text x="265" y="89" text-anchor="middle" font-size="11" fill="white" font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">1</text>
<circle cx="290" cy="240" r="10" fill="#D4A574"/>
<text x="290" y="244" text-anchor="middle" font-size="11" fill="white" font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">2</text>
<circle cx="535" cy="222" r="10" fill="#D4A574"/>
<text x="535" y="226" text-anchor="middle" font-size="11" fill="white" font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">3</text>
<!-- Legend -->
<text class="sublabel" x="50" y="460">1 CLI orchestrates Docker</text>
<text class="sublabel" x="50" y="476">2 Volumes mount project, home, and SSH into container</text>
<text class="sublabel" x="50" y="492">3 Agent works in mounted workspace — changes visible on host</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB