🛰️ export from upstream (18069bd)

This commit is contained in:
2026-09-21 14:31:12 +02:00
commit 8262b5f39d
52 changed files with 16288 additions and 0 deletions
+434
View File
@@ -0,0 +1,434 @@
# Command Reference
Sandcage provides subcommands for running AI coding agents in isolated Docker containers.
```
sandcage <COMMAND> [OPTIONS]
```
---
## claude
Run the Claude Code agent in a sandboxed container.
```
sandcage claude [OPTIONS] [-- AGENT_ARGS...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
| `--shell` | Drop into a shell instead of launching the agent |
| `--worktree` | Create a git worktree and mount it instead of the project directory |
| `--home <NAME>` | Named home profile for agent auth/config (default: "default") |
**Trailing arguments:** Any arguments after `--` are forwarded directly to the Claude Code binary inside the container.
**Examples:**
```sh
# Run Claude Code on the current directory
sandcage claude
# Run Claude Code on a specific project
sandcage claude --path /home/user/myproject
# Drop into a shell in the Claude container environment
sandcage claude --shell
# Run in a disposable worktree copy
sandcage claude --worktree
# Run with a specific home profile (e.g. work account)
sandcage claude --home work
# Forward arguments to the agent
sandcage claude -- --resume
```
---
## codex
Run the OpenAI Codex agent in a sandboxed container.
```
sandcage codex [OPTIONS] [-- AGENT_ARGS...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
| `--shell` | Drop into a shell instead of launching the agent |
| `--worktree` | Create a git worktree and mount it instead of the project directory |
| `--home <NAME>` | Named home profile for agent auth/config (default: "default") |
**Trailing arguments:** Any arguments after `--` are forwarded directly to the Codex binary inside the container.
**Examples:**
```sh
# Run Codex on the current directory
sandcage codex
# Run Codex in a disposable worktree
sandcage codex --worktree
# Forward arguments to Codex
sandcage codex -- --model o4-mini
```
---
## gemini
Run the Gemini CLI agent in a sandboxed container.
```
sandcage gemini [OPTIONS] [-- AGENT_ARGS...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
| `--shell` | Drop into a shell instead of launching the agent |
| `--worktree` | Create a git worktree and mount it instead of the project directory |
| `--home <NAME>` | Named home profile for agent auth/config (default: "default") |
**Trailing arguments:** Any arguments after `--` are forwarded directly to the Gemini CLI binary inside the container.
**Examples:**
```sh
# Run Gemini CLI on the current directory
sandcage gemini
# Run Gemini in a disposable worktree
sandcage gemini --worktree
# Drop into a shell in the Gemini container
sandcage gemini --shell
```
---
## shell
Open an interactive shell (zsh) with the same sandboxed environment used by agents.
```
sandcage shell [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
| `--home <NAME>` | Named home profile for agent auth/config (default: "default") |
**Examples:**
```sh
# Interactive shell in the sandbox for current project
sandcage shell
# Interactive shell for a specific project
sandcage shell --path /home/user/myproject
```
---
## build
Build the container images used by sandcage services.
```
sandcage build [OPTIONS] [SERVICES...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-f, --force` | Force rebuild even if images are up to date |
**Positional arguments:** Optionally specify which services to build (e.g. `claude`, `codex`, `gemini`). When omitted, all enabled services are built.
**Examples:**
```sh
# Build all enabled service images
sandcage build
# Force rebuild of all images
sandcage build --force
# Build only the claude image
sandcage build claude
# Build claude and gemini images
sandcage build claude gemini
```
---
## init
Initialize a `.sandcage/` directory with configuration for a project.
```
sandcage init
```
Scaffolds a `.sandcage/` directory in the current workspace with `sandcage.yml`, `overlay/`, `local/`, `local/overlay/`, and a `.gitignore` that excludes `local/`.
Refuses to run from linked git worktrees — run it from the main worktree instead.
**Examples:**
```sh
# Initialize sandcage config in current directory
sandcage init
```
---
## setup ssh
Configure SSH key access for containers. Copies selected SSH keys into a Docker volume accessible by sandcage containers.
```
sandcage setup ssh [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--global` | Write to global config (`~/.sandcage/config.toml`) instead of project config |
| `--yes` | Skip confirmation prompt |
| `--refresh` | Re-populate the SSH volume using the previously saved selection |
| `--bind` | Use legacy full bind mount instead of volume copy |
**Examples:**
```sh
# Interactive SSH key setup for the current project
sandcage setup ssh
# Non-interactive setup writing to global config
sandcage setup ssh --global --yes
# Refresh the SSH volume after adding new keys
sandcage setup ssh --refresh
```
---
## acp
Manage and run ACP (Agent Control Protocol) agents. This command group requires the `bollard` feature, which is enabled by default.
```
sandcage acp <SUBCOMMAND>
```
---
### acp run
Run a service in ACP relay mode, attaching stdin/stdout/stderr for bidirectional communication with the container. Uses the Bollard Docker API directly instead of docker compose. Designed for structured agent orchestration.
```
sandcage acp run <SERVICE> [OPTIONS] [-- AGENT_ARGS...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
**Positional arguments:** `<SERVICE>` — the service name to run (e.g. `claude`, `codex`).
**Trailing arguments:** Any arguments after `--` are forwarded directly to the agent binary inside the container.
**Examples:**
```sh
# Run the claude service in ACP relay mode
sandcage acp run claude
# Run against a specific project directory
sandcage acp run claude --path /home/user/myproject
# Forward arguments to the agent
sandcage acp run claude -- --resume
```
---
### acp install
Install an agent from the ACP registry. Downloads the agent binary for the current platform and caches it at `~/.sandcage/agents/<id>/<version>/`.
```
sandcage acp install <AGENT_ID> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--force` | Reinstall even if the agent is already installed |
**Positional arguments:** `<AGENT_ID>` — the identifier of the agent to install from the registry.
**Examples:**
```sh
# Install an agent from the registry
sandcage acp install my-agent
# Force reinstall an already-installed agent
sandcage acp install my-agent --force
```
---
### acp list
List available agents in the ACP registry. Fetches the agent list from the CDN with a 1-hour local cache.
```
sandcage acp list [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--refresh` | Bypass the local cache and fetch a fresh list from the registry |
**Examples:**
```sh
# List available agents (uses cache if fresh)
sandcage acp list
# Force a fresh fetch from the registry
sandcage acp list --refresh
```
---
### acp installed
List locally installed ACP agents.
```
sandcage acp installed
```
No options.
**Examples:**
```sh
# Show all locally installed ACP agents
sandcage acp installed
```
---
## clean
Remove shadow volumes for the current project. By default runs a dry run showing what would be removed.
```
sandcage clean [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--yes` | Actually delete (default is a dry run) |
**Examples:**
```sh
# Preview what would be removed
sandcage clean
# Remove shadow volumes
sandcage clean --yes
```
---
## worktree
Manage sandcage-created git worktrees. Worktrees are created under `~/.sandcage/worktrees/`.
---
### worktree list
List all sandcage-managed worktrees.
```
sandcage worktree list
```
**Examples:**
```sh
sandcage worktree list
```
---
### worktree clean
Remove all sandcage-managed worktrees.
```
sandcage worktree clean [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--yes` | Skip confirmation prompt |
**Examples:**
```sh
# Interactive cleanup
sandcage worktree clean
# Non-interactive cleanup
sandcage worktree clean --yes
```
---
## Common Patterns
**Project path resolution:** All agent commands and `shell` accept `--path` (`-p`) to specify the project directory. When omitted, sandcage resolves the workspace from the current directory.
**Shell override:** The `--shell` flag on agent commands (`claude`, `codex`, `gemini`) drops you into an interactive shell inside the agent's container without launching the agent itself. Useful for debugging the container environment.
**Forwarding arguments to agents:** Agent commands accept trailing arguments (after `--`) that are passed through to the underlying agent binary. This allows setting agent-specific flags without sandcage needing to know about them.
**Available services:** The built-in services are `claude`, `codex`, `gemini`, and `shell`. Each agent service auto-installs its binary on first run if not already present in the container.
+577
View File
@@ -0,0 +1,577 @@
# Configuration Reference
Sandcage uses a layered configuration system. Each layer overrides the previous one:
1. **Compiled defaults** — all fields unset
2. **Global config** (`~/.sandcage/config.toml`) — user-wide preferences (TOML format)
3. **Project config** (`.sandcage/config.toml`) — per-project settings, checked into version control (TOML format)
4. **Local config** (`.sandcage/local/config.local.toml`) — personal overrides, gitignored via `.sandcage/.gitignore` (TOML format)
Later layers win. Fields not set in a layer are inherited from the layer below.
---
## Directory Layout
```
.sandcage/
├── config.toml # project config (committed)
├── overlay/ # files bind-mounted read-only over workspace files in the container
├── local/ # gitignored via .sandcage/.gitignore
│ ├── config.local.toml
│ └── overlay/ # local overlay files (also gitignored)
└── .gitignore # contains "local/"
```
The `.sandcage/` directory itself is invisible inside the container (shadowed by a tmpfs mount).
---
## Config Source Resolution
When sandcage looks for the `.sandcage/` directory, it checks these locations in order:
1. **`SANDCAGE_CONFIG_DIR` env var** — explicit override, used as-is
2. **`.sandcage/` in the workspace** — the most common case
3. **Redirect file** — a `sandcage.toml` at the workspace root containing a `redirect` key pointing to the real `.sandcage/` directory
4. **Main worktree root** — for git worktrees, falls back to the main worktree's `.sandcage/`
The first match wins. `sandcage init` refuses to run from linked worktrees — run it from the main worktree instead.
---
## Fields
### env
Environment variables passed into the container at runtime.
- **Type:** Map of string keys to string values
- **Valid in:** Global, Project, Local
- **Behavior:** Passed as `-e KEY=VALUE` flags to `docker compose run`
```toml
# .sandcage/config.toml
[env]
RUST_LOG = "debug"
DATABASE_URL = "postgres://localhost:5432/dev"
```
```toml
# ~/.sandcage/config.toml
[env]
EDITOR = "vim"
```
---
### packages
System packages to install in the container image (apt packages).
- **Type:** List of strings
- **Valid in:** Global, Project, Local
```toml
packages = ["ripgrep", "fd-find", "postgresql-client"]
```
---
### toolchains
Language toolchain managers to enable. When enabled, the corresponding Dockerfile fragment from `images/fragments/` is included during `sandcage build`.
- **Type:** Map of string keys to boolean values
- **Valid in:** Global, Project, Local
- **Supported keys:** `uv`, `nvm`, `rust`
```toml
[toolchains]
uv = true
nvm = true
rust = true
```
---
### mounts
Additional volume mounts passed to the container. Each entry must contain a `:` separator.
- **Type:** List of strings in Docker mount format (`<source>:<target>[:<options>]`)
- **Valid in:** Global, Project, Local
- **Behavior:** Tilde (`~`) in the source path is expanded to the host home directory. Entries without `:` are skipped with a warning.
```toml
mounts = [
"/data/models:/models:ro",
"~/.gitconfig:/home/agent/.gitconfig:ro",
"/tmp/cache:/cache",
]
```
---
### shell
Default shell inside the container.
- **Type:** String
- **Valid in:** Global, Project, Local
```toml
shell = "zsh"
```
---
### justfile
Path to a justfile to make available in the container.
- **Type:** File path (string)
- **Valid in:** Global, Project, Local
```toml
justfile = "./project.justfile"
```
---
### dockerfiles
Custom Dockerfile overrides for image builds. Keyed by image short name. Values can be a path to a Dockerfile or a directory containing one.
- **Type:** Map of string keys to file paths
- **Valid in:** Global, Project (project overrides require the project to be in `trusted_projects`)
- **Behavior:** Project-level Dockerfile overrides are ignored unless the project directory is listed in `trusted_projects` in the global config.
```toml
# .sandcage/config.toml (only applied if project is trusted)
[dockerfiles]
sandcage = "./docker/Dockerfile.custom"
```
```toml
# ~/.sandcage/config.toml
[dockerfiles]
sandcage = "/home/user/my-dockerfiles/sandcage/"
```
---
### trusted_projects
List of project directories allowed to use Dockerfile overrides.
- **Type:** List of file paths
- **Valid in:** Global only (a project cannot self-trust)
- **Behavior:** Read exclusively from `~/.sandcage/config.toml`. Projects with `dockerfiles` set are ignored unless their path (or a parent) appears here.
```toml
# ~/.sandcage/config.toml
trusted_projects = [
"/home/user/projects/my-trusted-project",
"/home/user/work",
]
```
---
### container_workspace
Override the workspace path inside the container. Must be an absolute path (starting with `/`). Relative paths are ignored with a warning.
- **Type:** String (absolute path)
- **Valid in:** Global, Project, Local
- **Default:** `/workspace/<project-directory-name>` (derived from the host workspace folder name)
```toml
container_workspace = "/workspace/my-app"
```
---
### agent_args
Default arguments passed to agents on every invocation. Keyed by service name. These are appended after the service name but before any extra CLI arguments.
- **Type:** Map of service name to list of strings
- **Valid in:** Global, Project, Local
- **Behavior:** Only the args for the invoked service are used. When running with `--shell`, agent_args are skipped entirely.
```toml
# .sandcage/config.toml
[agent_args]
claude = ["--dangerously-skip-permissions"]
codex = ["--full-auto"]
```
```toml
# ~/.sandcage/config.toml
[agent_args]
claude = ["--dangerously-skip-permissions"]
```
---
### ssh_mode
Controls how SSH keys are made available inside the container.
- **Type:** String — one of `"volume"`, `"bind"`, or `"none"`
- **Valid in:** Global, Project, Local
- **Default:** No SSH mount (equivalent to `"none"`)
| Value | Behavior |
|----------|----------|
| `volume` | Mounts a named Docker volume (`sandcage-ssh`) at `/home/agent/.ssh` (read-only). Populate it with `sandcage setup ssh`. |
| `bind` | Bind-mounts `~/.ssh` from the host at `/home/agent/.ssh` (read-only). |
| `none` | No SSH directory is mounted. |
```toml
ssh_mode = "volume"
```
When using `volume` mode, run `sandcage setup ssh --refresh` after changing keys on the host.
---
### ssh_keys
Declares which SSH keys to copy into the volume when using `sandcage setup ssh`. Each entry specifies a host and identity file.
- **Type:** List of objects with `host` (string) and `identity_file` (string) fields
- **Valid in:** Global, Project, Local
```toml
[[ssh_keys]]
host = "github.com"
identity_file = "~/.ssh/id_ed25519"
[[ssh_keys]]
host = "gitea.internal"
identity_file = "~/.ssh/work_gitea"
```
---
### services
Override the enabled state of built-in services. Keyed by service name.
- **Type:** Map of service name to object with optional `enabled` (boolean) field
- **Valid in:** Global, Project, Local
- **Behavior:** Disabled services cannot be started with `sandcage <service>` and are excluded from generated compose files.
```toml
[services.gemini]
enabled = false
[services.codex]
enabled = false
```
```toml
# ~/.sandcage/config.toml
[services.codex]
enabled = false
```
---
### default_services
Controls which services `sandcage build` prepares by default (when no service names are given on the CLI).
- **Type:** List of strings (service names)
- **Valid in:** Global, Project, Local
```toml
default_services = ["claude", "shell"]
```
---
### image
The Docker image name used for building and running containers.
- **Type:** String
- **Valid in:** Global, Project, Local
- **Default:** `"sandcage"`
```toml
# .sandcage/config.toml
image = "my-custom-sandcage"
```
```toml
# ~/.sandcage/config.toml
image = "my-custom-sandcage"
```
---
### overlay.enabled
Controls whether overlay mounts are active. When enabled, files in `.sandcage/overlay/` (and `.sandcage/local/overlay/`) are bind-mounted read-only over the corresponding workspace files inside the container.
- **Type:** Boolean
- **Valid in:** Global, Project, Local
- **Default:** `true`
```toml
[overlay]
enabled = false
```
Local overlays (`.sandcage/local/overlay/`) take precedence over project overlays (`.sandcage/overlay/`).
---
### shadow
Shadow directories redirect build artifact and dependency directories away from the workspace bind-mount into faster, container-local storage. This prevents large directories like `target/`, `.venv/`, or `node_modules/` from thrashing the host filesystem.
- **Type:** Object with optional `backend` (string) and `dirs` (map) fields
- **Valid in:** Global, Project, Local
- **Default backend:** `"volume"` (named Docker volume)
Each entry under `[shadow.dirs]` can override the global backend and set `shared = true` to share the volume across containers for the same project.
| Backend | Behavior |
|-------------|----------|
| `volume` | Named Docker volume (`sandcage-<project>-<dir-hash>`), persisted across runs |
| `directory` | Host directory under `~/.sandcage/shadow/`, persisted |
| `tmpfs` | In-memory tmpfs mount, lost when the container stops |
```toml
[shadow]
backend = "volume"
[shadow.dirs]
".venv" = {}
"target" = { backend = "directory" }
".cargo/registry" = { shared = true }
"node_modules" = { backend = "tmpfs" }
```
Use `sandcage clean` to remove shadow volumes for a project.
---
### tools
Tool mode declarations control how specific CLI tools behave inside the container. Each entry under `[tools.<name>]` configures one tool.
- **Type:** Map of tool name to object
- **Valid in:** Project, Local
- **Fields:**
- `mode` — `"in-cage"` (default), `"spoof"`, or `"host"`
- `message` — message printed by a spoof script (only for `mode = "spoof"`)
- `allow` — list of glob patterns permitting arguments (only for `mode = "host"`)
- `deny` — list of glob patterns blocking arguments (only for `mode = "host"`)
| Mode | Behavior |
|-----------|----------|
| `in-cage` | Tool runs normally inside the container (default) |
| `spoof` | Replaced by a script that prints `message` and exits. Prevents agents from running the tool. |
| `host` | Forwarded to the host via the forwarding daemon. Requires `trusted_projects`. |
**Allow/deny patterns** use simple `*` wildcard glob matching on the full argument string. Deny patterns are checked first and override allow patterns. If any allow patterns are specified, the command must match at least one.
```toml
[tools.cargo]
mode = "spoof"
message = "Use `just build` to compile this project."
[tools.docker]
mode = "host"
allow = ["compose up *", "compose down"]
deny = ["* prune *", "* rm -f *"]
[tools.uv]
mode = "in-cage"
```
---
### tasks
Named operations that can run on the host or inside the container. Host tasks are forwarded via the forwarding daemon and thumper; in-cage tasks run directly inside the container.
- **Type:** Map of task name to object
- **Valid in:** Project, Local
- **Fields:**
- `where` — `"host"` or `"in-cage"` (where the task executes)
- `run` — the command to execute (string, split on whitespace)
Host tasks (`where = "host"`) require the project to be in `trusted_projects`. A Justfile overlay is auto-generated for host tasks, making them available as `just <task-name>` inside the container.
```toml
[tasks.build]
where = "host"
run = "cargo build --release"
[tasks.test]
where = "host"
run = "cargo test"
[tasks.lint]
where = "in-cage"
run = "clippy"
```
---
### profiles
Security profiles applied to the container via `--security-opt`. Profile files are loaded from `.sandcage/profiles/`.
- **Type:** Object with optional `apparmor` (string) and `seccomp` (string) fields
- **Valid in:** Project, Local
- **Behavior:** Paths are relative to the project root. Missing files are skipped with a warning. Applied on both the bollard and compose backends.
If a seccomp profile contains `SCMP_ACT_NOTIFY`, sandcage detects this as a fermata hook point and logs accordingly.
```toml
[profiles]
apparmor = ".sandcage/profiles/apparmor.conf"
seccomp = ".sandcage/profiles/seccomp.json"
```
### home
Named home profile for agent auth and configuration. Each profile is a separate directory under `~/.sandcage/homes/`, containing its own agent credentials, settings, and session history.
- **Type:** String (profile name)
- **Valid in:** Global, Project, Local
- **Default:** `"default"`
- **CLI override:** `--home <NAME>` on any agent or shell subcommand
- **Behavior:** Resolves to `~/.sandcage/homes/<name>/`, which is mounted as `/home/agent` inside the container. The directory is created automatically on first use. CLI flag takes precedence over config.
```toml
# Use a specific home profile for this project
home = "work"
```
This allows switching between multiple agent identities (e.g., personal and work Claude accounts) without re-authenticating:
```sh
sandcage claude --home personal # uses ~/.sandcage/homes/personal/
sandcage claude --home work # uses ~/.sandcage/homes/work/
sandcage claude # uses ~/.sandcage/homes/default/
```
**Migration:** If a legacy `~/.sandcage/home/` directory exists from before named profiles were introduced, it is automatically moved to `~/.sandcage/homes/default/` on first run.
---
### Reserved Sections
The following top-level config section is parsed and validated but **not yet active**:
- **`forward`** — port/socket forwarding rules (the forwarding daemon uses internal configuration; this section is for future user-facing forwarding config)
Setting this field will not produce errors, but it has no runtime effect yet.
---
## Services
Sandcage ships with four built-in services, all enabled by default:
| Service | Description | Config directory |
|----------|-----------------------|------------------|
| `claude` | Claude Code agent | `.claude` |
| `codex` | Codex agent | `.codex` |
| `gemini` | Gemini CLI agent | `.gemini` |
| `shell` | Interactive zsh shell | (none) |
Agent services are installed on first run into the persistent home directory (`~/.sandcage/homes/<profile>/`). They auto-update on subsequent runs.
To disable a service you do not use:
```toml
[services.codex]
enabled = false
[services.gemini]
enabled = false
```
Disabled services are excluded from Docker compose generation and cannot be invoked.
---
## Examples
### Minimal project config
```toml
# .sandcage/config.toml
packages = ["ripgrep"]
[toolchains]
nvm = true
```
### Full-featured project config
```toml
# .sandcage/config.toml
packages = ["ripgrep", "fd-find", "postgresql-client"]
mounts = ["/data/models:/models:ro"]
shell = "zsh"
[env]
RUST_LOG = "debug"
NODE_ENV = "development"
[toolchains]
rust = true
nvm = true
[agent_args]
claude = ["--dangerously-skip-permissions"]
[services.gemini]
enabled = false
```
### Local overrides (gitignored)
```toml
# .sandcage/local/config.local.toml
ssh_mode = "volume"
mounts = ["~/.aws:/home/agent/.aws:ro"]
[env]
SECRET_API_KEY = "sk-local-dev-key"
[[ssh_keys]]
host = "github.com"
identity_file = "~/.ssh/id_ed25519"
```
### Global config
```toml
# ~/.sandcage/config.toml
shell = "zsh"
[env]
EDITOR = "vim"
[agent_args]
claude = ["--dangerously-skip-permissions"]
[services.codex]
enabled = false
trusted_projects = [
"/home/user/work/trusted-project",
]
```
+113
View File
@@ -0,0 +1,113 @@
# Docker Image
## Base image contents
The sandcage image is built on `debian:bookworm-slim` and includes:
- **Shell:** zsh (default), bash
- **VCS:** git, openssh-client
- **Search:** ripgrep, fd-find
- **Utilities:** jq, curl, sudo, ca-certificates
- **Task runner:** just (installed from upstream binary)
npm is not included in the base image. Node.js is available via the nvm toolchain fragment when `nvm = true` is set in config.
uv is not in the base image by default. It is added via the uv toolchain fragment when `uv = true` is set in config.
The image creates an `agent` user (UID 1000) with a home at `/home/agent` and zsh as the default shell. Sudo is disabled (the passwordless sudo line is commented out in the Dockerfile).
Pre-created directories: `/workspace`, `/home/agent/.claude`, `/home/agent/.codex`, `/home/agent/.gemini`.
## How agents are installed
AI coding agents (Claude Code, Codex, Gemini CLI) are **not** baked into the image. They are installed on first run via their respective entrypoint scripts. The agent home directories (`~/.claude`, `~/.codex`, `~/.gemini`) are persisted across runs through volume mounts to `~/.sandcage/` on the host, so installation only happens once.
Each service has its own entrypoint script installed at `/usr/local/bin/sandcage-<service>-entrypoint`.
## Building images
Build the sandcage image with:
```
sandcage build
```
### Force rebuild
To bypass the cache and rebuild unconditionally:
```
sandcage build --force
```
When `--force` is used, Docker's `--no-cache` flag is also passed to ensure layers are rebuilt from scratch.
### Service filter
Build only images required for specific services:
```
sandcage build claude shell
```
Unknown service names produce an error listing available services.
## Cache awareness
Sandcage uses hash-based rebuild detection to avoid unnecessary image builds.
On each build, the SHA-256 hash of the Dockerfile content (bundled or custom) is computed and compared against the stored hash in `~/.sandcage/.build-hashes`. If they match, the build is skipped with an "up to date" message.
After a successful build, the new hash is persisted. The hash file uses a simple `image:hash` line format.
This means:
- Editing the Dockerfile (or switching to/from a custom one) triggers a rebuild automatically.
- The `--force` flag bypasses hash comparison entirely.
## Custom Dockerfiles
You can override the bundled Dockerfile by specifying a path in configuration.
### Global config (`~/.sandcage/config.toml`)
```toml
[dockerfiles]
sandcage = "/path/to/my/Dockerfile"
```
### Project config (`.sandcage/config.toml`)
```toml
[dockerfiles]
sandcage = "./docker/Dockerfile.custom"
```
The path can point to either:
- A **file** -- used as the Dockerfile with a temporary build context.
- A **directory** -- used as the full build context (must contain a `Dockerfile`).
### trusted_projects requirement
Project-level Dockerfile overrides are only applied if the project directory is listed in the global `trusted_projects` setting. This prevents untrusted repositories from injecting arbitrary build instructions.
```toml
# ~/.sandcage/config.toml
trusted_projects = [
"/home/user/projects/my-trusted-repo",
"/home/user/work",
]
```
A project is considered trusted if its canonical path starts with any entry in `trusted_projects` (prefix matching). If a project specifies `dockerfiles` but is not trusted, the override is silently ignored with a warning.
Global config overrides (`~/.sandcage/config.toml`) are always trusted.
## Cross-platform notes
### UID/GID handling
On **Linux**, sandcage queries the host user's UID and GID via `id -u` / `id -g` and passes them into the container as `SANDCAGE_UID` and `SANDCAGE_GID`. This ensures files created inside the container have correct ownership on bind-mounted host directories.
On **Windows**, UID/GID passthrough is not meaningful. Sandcage hardcodes both to `1000`, matching the `agent` user built into the image. File ownership on Windows bind mounts is handled by Docker Desktop's filesystem sharing layer.
+88
View File
@@ -0,0 +1,88 @@
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="520" viewBox="0 0 700 520">
<style>
text { font-family: system-ui, -apple-system, sans-serif; }
.title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.label { font-size: 13px; fill: #1a1a1a; }
.small { font-size: 11px; fill: #555; }
.edge { font-size: 11px; font-weight: 600; }
.yes { fill: #2d7d46; }
.no { fill: #c0392b; }
.arrow { stroke: #666; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
.diamond { fill: #fff3cd; stroke: #e0c36a; stroke-width: 1.5; }
.result-native { fill: #d4edda; stroke: #7bc88f; stroke-width: 1.5; rx: 8; }
.result-compose { fill: #cce5ff; stroke: #6cb3f5; stroke-width: 1.5; rx: 8; }
.platform { fill: #f0f0f0; stroke: #ccc; stroke-width: 1; stroke-dasharray: 4 2; rx: 6; }
</style>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" fill="#666"/>
</marker>
</defs>
<text x="350" y="30" text-anchor="middle" class="title">Interactive Mode: Backend Selection</text>
<!-- Config check -->
<rect x="225" y="50" width="250" height="44" class="diamond" rx="6"/>
<text x="350" y="69" text-anchor="middle" class="label">compose_backend = "compose"</text>
<text x="350" y="84" text-anchor="middle" class="small">in sandcage.toml?</text>
<!-- Windows-only box -->
<rect x="30" y="140" width="340" height="290" class="platform"/>
<text x="50" y="160" class="small" font-style="italic">Windows only</text>
<!-- MSYSTEM check -->
<rect x="100" y="175" width="200" height="44" class="diamond" rx="6"/>
<text x="200" y="194" text-anchor="middle" class="label">MSYSTEM env var set?</text>
<text x="200" y="209" text-anchor="middle" class="small">(Git Bash / MSYS2)</text>
<!-- Console handle check -->
<rect x="100" y="275" width="200" height="44" class="diamond" rx="6"/>
<text x="200" y="294" text-anchor="middle" class="label">Console handle valid?</text>
<text x="200" y="309" text-anchor="middle" class="small">(GetConsoleMode)</text>
<!-- Raw mode check -->
<rect x="225" y="380" width="250" height="44" class="diamond" rx="6"/>
<text x="350" y="399" text-anchor="middle" class="label">Raw mode supported?</text>
<text x="350" y="414" text-anchor="middle" class="small">(crossterm probe)</text>
<!-- Result: Native TTY -->
<rect x="195" y="468" width="200" height="38" class="result-native"/>
<text x="295" y="491" text-anchor="middle" class="label" font-weight="600">Native TTY</text>
<!-- Result: Compose fallback -->
<rect x="500" y="200" width="170" height="38" class="result-compose"/>
<text x="585" y="223" text-anchor="middle" class="label" font-weight="600">Compose fallback</text>
<!-- Arrows -->
<!-- Config → MSYSTEM (No) -->
<path d="M350,94 L350,120 L200,120 L200,175" class="arrow"/>
<text x="280" y="115" class="edge no">no</text>
<!-- Config → Compose (Yes) -->
<path d="M475,72 L585,72 L585,200" class="arrow"/>
<text x="510" y="65" class="edge yes">yes</text>
<!-- MSYSTEM → Console (No) -->
<path d="M200,219 L200,275" class="arrow"/>
<text x="210" y="252" class="edge no">no</text>
<!-- MSYSTEM → Compose (Yes) -->
<path d="M300,197 L430,197 L430,219 L500,219" class="arrow"/>
<text x="340" y="190" class="edge yes">yes</text>
<!-- Console → Raw mode (Yes) -->
<path d="M200,319 L200,355 L350,355 L350,380" class="arrow"/>
<text x="210" y="345" class="edge yes">yes</text>
<!-- Console → Compose (No) -->
<path d="M300,297 L430,297 L430,219 L500,219" class="arrow"/>
<text x="340" y="290" class="edge no">no</text>
<!-- Raw mode → Native (Yes) -->
<path d="M295,424 L295,468" class="arrow"/>
<text x="275" y="450" class="edge yes">yes</text>
<!-- Raw mode → Compose (No) -->
<path d="M475,402 L585,402 L585,238" class="arrow"/>
<text x="510" y="395" class="edge no">no</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+91
View File
@@ -0,0 +1,91 @@
# Interactive Mode
When you run `sandcage shell` or pass `--shell` to an agent command, sandcage opens an
interactive terminal session inside the container. How that session is wired up depends on
your terminal — sandcage picks the best available method automatically.
## Two backends
### Native TTY (default)
Sandcage talks directly to the Docker API, puts your terminal into raw mode, and relays
keystrokes and output between your terminal and the container. This gives you:
- **Terminal resize tracking** — the container shell adjusts when you resize the window
- **Full TTY fidelity** — colors, cursor movement, and interactive programs (vim, htop, etc.)
work as expected
- **Graceful shutdown** — the container stops cleanly when the session ends
This is the same mechanism `docker run -it` uses under the hood.
### Compose fallback
When native TTY isn't available, sandcage delegates to `docker compose run`, which manages
the terminal connection itself. You get the same interactive shell — the difference is
invisible in normal use. The fallback activates automatically; no configuration needed.
## Which backend am I using?
Sandcage prints a diagnostic line to stderr when it makes its choice:
```
sandcage: tty probe → crossterm raw mode OK — using bollard TTY
```
or
```
sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose
```
These messages appear once at session start and don't affect your shell.
## When does the fallback activate?
![TTY backend decision tree](./images/tty-decision-tree.svg)
The detection runs through these checks in order:
| Check | Triggers fallback when… |
|---|---|
| `compose_backend` config | Set to `"compose"` in `sandcage.toml` — forces compose unconditionally |
| MSYSTEM environment variable (Windows) | Present — indicates Git Bash or MSYS2, whose terminal emulator can't handle raw console mode |
| Console handle (Windows) | Invalid — stdin is piped or redirected, not a real terminal |
| Raw mode probe (all platforms) | Fails — terminal doesn't support raw mode |
If none of these trigger, sandcage uses native TTY.
## Exiting the session
In native TTY mode, your keystrokes are relayed directly to the container shell. This means:
- **Ctrl+D** or typing `exit` ends the session (same as any shell)
- **Ctrl+C** interrupts the current command inside the container — it does *not* kill the
session. This matches `docker run -it` behavior.
In compose fallback mode, Ctrl+C ends the session immediately.
## Forcing compose mode
If you run into rendering issues in an unusual terminal, you can force the compose backend
by adding this to your `sandcage.toml`:
```toml
compose_backend = "compose"
```
This bypasses all detection and always uses `docker compose run` for interactive sessions.
## Windows terminal compatibility
| Terminal | Backend used |
|---|---|
| Windows Terminal / PowerShell | Native TTY |
| CMD (Command Prompt) | Native TTY |
| Git Bash (MinTTY) | Compose fallback |
| MSYS2 / MinGW shells | Compose fallback |
| WSL | Native TTY |
On macOS and Linux, native TTY is used in all standard terminals (iTerm2, Terminal.app,
GNOME Terminal, Alacritty, kitty, etc.). The compose fallback would only activate if stdin
is piped or the terminal is otherwise non-interactive.
+224
View File
@@ -0,0 +1,224 @@
# SSH Key Access
## Overview
Containers launched by sandcage often need SSH access to clone private
repositories or push code. Since the agent user inside the container does not
have access to your host SSH keys by default, sandcage provides a setup command
that copies or mounts the relevant keys into the container environment.
## Modes
Sandcage supports two SSH modes, configured via the `ssh_mode` field:
### Volume mode (recommended)
```
ssh_mode: volume
```
A Docker named volume (`sandcage-ssh`) is created and populated with only the
keys required for your git remotes. The volume is mounted read-only at
`/home/agent/.ssh` inside the container. A synthesized SSH config is written
into the volume so the agent can connect to the correct hosts with the correct
keys.
Advantages:
- Only selected keys are copied (not your entire `~/.ssh` directory).
- A minimal SSH config is synthesized from your host config.
- The volume persists across container restarts without re-reading host files.
- Works identically on Linux, macOS, and Windows (Docker Desktop).
### Bind mode
```
ssh_mode: bind
```
Your host `~/.ssh` directory is bind-mounted read-only into the container at
`/home/agent/.ssh:ro`. This exposes all files in that directory to the
container.
Advantages:
- Zero setup after initial configuration.
- Changes to host keys are immediately visible.
Disadvantages:
- Exposes all keys and config, not just the ones needed.
- On some platforms, file permission translation can cause issues.
### None
```
ssh_mode: none
```
No SSH mount is added. Use this if you do not need SSH inside containers or
handle key injection through other means.
## Setup workflow
Run the interactive setup:
```
sandcage setup ssh
```
The command:
1. Scans `~/.ssh/config` (including `Include` directives) for `Host` blocks
with `User git`.
2. Runs `git remote -v` in the current directory to discover SSH remote hosts.
3. Merges the two sources and displays discovered host/key pairs.
4. Prompts you to select which keys to copy.
5. Asks whether to include `known_hosts`.
6. Populates the `sandcage-ssh` Docker volume with the selected keys and a
synthesized SSH config.
7. Writes the configuration to the appropriate config file.
### Flags
| Flag | Effect |
|------|--------|
| `--global` | Write to `~/.sandcage/config.toml` instead of project-local config |
| `--yes` | Accept all defaults without prompting |
| `--bind` | Use bind-mount mode instead of volume mode |
| `--refresh` | Re-populate the volume from existing config (no interactive selection) |
### Examples
```
# Interactive setup for current project
sandcage setup ssh
# Non-interactive, global config
sandcage setup ssh --global --yes
# Legacy bind-mount mode
sandcage setup ssh --bind
# Refresh volume after adding a new key to ~/.ssh
sandcage setup ssh --refresh
```
## Configuration
### Project config (`.sandcage/local/sandcage.local.yml` or `.sandcage/sandcage.yml`)
```yaml
ssh_mode: volume
ssh_keys:
- host: github.com
identity_file: ~/.ssh/id_ed25519
- host: gitea
identity_file: ~/.ssh/work_gitea
```
### Global config (`~/.sandcage/config.toml`)
```toml
ssh_mode = "volume"
[[ssh_keys]]
host = "github.com"
identity_file = "~/.ssh/id_ed25519"
[[ssh_keys]]
host = "gitea"
identity_file = "~/.ssh/work_gitea"
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `ssh_mode` | string | One of `volume`, `bind`, or `none` |
| `ssh_keys` | array | List of host/key pairs to include in the volume |
| `ssh_keys[].host` | string | SSH host alias (matches `Host` line in SSH config) |
| `ssh_keys[].identity_file` | string | Path to the private key (supports `~/` expansion) |
Configuration layers are merged in order: global, project, local. Later layers
override earlier ones.
## Refreshing keys
After adding or rotating SSH keys on the host, update the container volume:
```
sandcage setup ssh --refresh
```
This re-reads the existing `ssh_keys` configuration and the current
`~/.ssh/config`, then repopulates the `sandcage-ssh` Docker volume with fresh
copies.
For bind mode, no refresh is needed since the host directory is mounted
directly.
## Security considerations
- **Volume mode** copies only the selected private keys into a Docker volume.
The volume is mounted read-only into containers. Keys are owned by the
`agent` user with `600` permissions.
- **Bind mode** mounts the entire `~/.ssh` directory read-only. The container
can read all keys and config present in that directory.
- Neither mode exposes keys to other containers or Docker networks. Keys exist
only on the filesystem inside the running container.
- The `sandcage-ssh` volume persists on disk until explicitly removed
(`docker volume rm sandcage-ssh`). Run `--refresh` or delete the volume when
decommissioning keys.
## Troubleshooting
### Volume not found
```
sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it
```
The `sandcage-ssh` volume does not exist or was removed. Re-run:
```
sandcage setup ssh --refresh
```
Or, if no config exists yet:
```
sandcage setup ssh
```
### Permission denied inside container
If the agent cannot read keys, the volume population step may have failed or
the image does not have an `agent` user at the expected UID. Verify:
```
docker run --rm -v sandcage-ssh:/home/agent/.ssh:ro sandcage:latest ls -la /home/agent/.ssh
```
Private keys should be `600` and owned by `agent:agent`.
### No git SSH hosts found
```
sandcage: no git SSH hosts found in remotes or ~/.ssh/config
```
This means:
- The current directory has no SSH git remotes (all remotes use HTTPS).
- `~/.ssh/config` has no `Host` blocks with `User git`.
Add an SSH remote or configure your SSH config, then re-run setup.
### Migration from legacy bind mount
If your config contains a raw `~/.ssh:/home/agent/.ssh:ro` entry in `mounts`
without an `ssh_mode` field, sandcage detects this as a legacy configuration
and offers to migrate to volume mode. Accepting the migration removes the old
mount entry and writes `ssh_mode: volume` with discovered keys. Declining sets
`ssh_mode: bind` explicitly to suppress future prompts.