🛰️ export from upstream (18069bd)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Read|Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "fermata hook --harness codex"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
*.md text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
# Patterns
|
||||
*.rs.bk
|
||||
*.tmp
|
||||
*.bak
|
||||
*.pyc
|
||||
*.pyo
|
||||
__pycache__
|
||||
*.log
|
||||
*.pdb
|
||||
|
||||
|
||||
# Python / uv
|
||||
.venv/
|
||||
|
||||
# Export worktrees
|
||||
.export-worktrees/
|
||||
|
||||
# Build pipelines and scratch
|
||||
/dist/
|
||||
/local/**
|
||||
|
||||
# Misc
|
||||
/.vscode/*
|
||||
.obsidian
|
||||
settings.local.json
|
||||
.claude/worktrees/
|
||||
CLAUDE.local.md
|
||||
@@ -0,0 +1 @@
|
||||
local/
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sandcage project configuration
|
||||
# Docs: https://github.com/user/sandcage
|
||||
|
||||
# Detected ecosystem: rust
|
||||
|
||||
packages = ["ripgrep", "fd-find"]
|
||||
|
||||
[toolchains]
|
||||
rust = true
|
||||
# uv powers the project's Python tooling (see CLAUDE.md); keep it in the dev image.
|
||||
uv = true
|
||||
|
||||
[agent_args]
|
||||
claude = ["--dangerously-skip-permissions"]
|
||||
|
||||
# shell = "zsh"
|
||||
|
||||
# container_workspace = "/workspace/my-project"
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sandcage
|
||||
|
||||
Rust CLI project for managing Docker-based development environments.
|
||||
|
||||
## Project structure
|
||||
|
||||
- `crates/sandcage/` — Main CLI binary (Cargo workspace member)
|
||||
- `crates/sandcage-test/` — Integration/functional tests (not exported)
|
||||
- `images/` — Base Dockerfile, fragments, and per-service entrypoint scripts
|
||||
- `templates/` — Project templates for `sandcage init`
|
||||
- `scripts/` — Python scripts for tooling, deployment, and automation
|
||||
- `docs/` — Documentation
|
||||
|
||||
## Python / uv
|
||||
|
||||
This is **not** a Python project. The `pyproject.toml` and uv setup exist to provide a
|
||||
managed Python environment for tooling: deploy scripts, data processing, automation recipes,
|
||||
CI helpers, and any ad-hoc scripting that skills or plugins need.
|
||||
|
||||
Run Python scripts via uv:
|
||||
|
||||
```sh
|
||||
uv run scripts/some_script.py
|
||||
uv run python -c "..."
|
||||
```
|
||||
|
||||
Add dependencies as needed:
|
||||
|
||||
```sh
|
||||
uv add <package>
|
||||
uv add --dev <package>
|
||||
```
|
||||
|
||||
## Just commands
|
||||
|
||||
Project tasks are run via `just`. Run `just` to see all available recipes.
|
||||
|
||||
When asked to "export", use:
|
||||
|
||||
```sh
|
||||
just export-dry # preview locally
|
||||
just export # push to public repo
|
||||
```
|
||||
|
||||
Configuration: `scripts/export.toml` (excluded paths, asset overlay directory).
|
||||
Public-facing assets (README, etc.): `scripts/export_assets/`.
|
||||
|
||||
## Testing
|
||||
|
||||
There is no CI pipeline. Do not plan or implement CI-based builds, test matrices, or
|
||||
CI config files unless explicitly asked.
|
||||
|
||||
After finishing a feature implementation, run `just test-functional` (requires Docker +
|
||||
built images) at least once before reporting the work as done. If images need rebuilding
|
||||
first, use `just test-functional-full`. This catches regressions that unit tests miss —
|
||||
like container launch, TTY attach, and mount behavior.
|
||||
|
||||
## Conventions
|
||||
|
||||
See `CONVENTION.md` for commit message format (gitmoji).
|
||||
Generated
+2801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ['crates/sandcage', 'crates/thumper']
|
||||
resolver = "3"
|
||||
@@ -0,0 +1,166 @@
|
||||
<p align="center">
|
||||
<img src="sandcage_logo.png" width="120" alt="Sandcage">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Sandcage</h1>
|
||||
|
||||
<p align="center">
|
||||
Run AI coding agents in isolated Docker containers — your machine stays yours.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!CAUTION]
|
||||
> **Unreleased Software**
|
||||
>
|
||||
> This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project.
|
||||
|
||||
> [!WARNING]
|
||||
> **Development Tool Only**
|
||||
>
|
||||
> Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts.
|
||||
|
||||
---
|
||||
|
||||
## 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. Named home profiles (`--home work`, `--home personal`) let you switch between different accounts without re-logging in.
|
||||
|
||||
<!-- TODO: Terminal demo
|
||||
Recording tool: https://github.com/charmbracelet/vhs
|
||||
Will show: sandcage build → sandcage claude → agent working → exit
|
||||
Place animated GIF or SVG here once recorded.
|
||||
-->
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites:** Docker (running) and a Rust toolchain (cargo).
|
||||
|
||||
```bash
|
||||
# Install
|
||||
cargo install --git https://github.com/dirigence/sandcage
|
||||
|
||||
# Build the container image
|
||||
sandcage build
|
||||
|
||||
# Run Claude Code in the current project
|
||||
sandcage claude
|
||||
```
|
||||
|
||||
That's it. Sandcage resolves your project to its git root, mounts it into the container, and drops you into the agent.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
sandcage claude # Claude Code agent
|
||||
sandcage codex # Codex agent
|
||||
sandcage gemini # Gemini CLI agent
|
||||
sandcage shell # interactive shell, same environment
|
||||
sandcage claude -p ~/project # run in a specific project
|
||||
sandcage claude -- --resume # forward args to the agent
|
||||
sandcage claude --worktree # run in a disposable git worktree
|
||||
sandcage claude --home work # use a named home profile
|
||||
sandcage claude --shell # shell for debugging
|
||||
sandcage build # build/rebuild container image
|
||||
sandcage init # scaffold .sandcage/ directory for your project
|
||||
sandcage setup ssh # configure SSH key access for containers
|
||||
sandcage worktree list # list managed worktrees
|
||||
sandcage worktree clean # remove managed worktrees
|
||||
sandcage acp run claude # ACP relay mode (bidirectional stdio)
|
||||
sandcage acp list # list agents in the ACP registry
|
||||
sandcage acp install <agent-id> # install an agent from the registry
|
||||
sandcage acp installed # list locally installed agents
|
||||
```
|
||||
|
||||
## 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` from your project directory
|
||||
2. Sandcage resolves the workspace, loads layered config, and generates a compose definition
|
||||
3. Your project, persistent home profile, 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
|
||||
|
||||
## ACP Support
|
||||
|
||||
Sandcage includes built-in support for the Agent Control Protocol (ACP). Running `sandcage acp run <service>` starts the named agent container and establishes a bidirectional stdio relay, letting any ACP-compatible client communicate with the agent over standard I/O. Agents can be installed from the ACP registry with `sandcage acp install <agent-id>` and browsed with `sandcage acp list`.
|
||||
|
||||
## Configuration
|
||||
|
||||
<p align="center">
|
||||
<img src="config-layers.svg" alt="Configuration layering — defaults, global, project, local, CLI flags" width="720">
|
||||
</p>
|
||||
|
||||
Configuration is layered — each level overrides the one below:
|
||||
|
||||
| Layer | File | Format |
|
||||
|-------|------|--------|
|
||||
| Global | `~/.sandcage/config.toml` | TOML |
|
||||
| Project | `.sandcage/sandcage.yml` | YAML |
|
||||
| Local | `.sandcage/local/sandcage.local.yml` | YAML |
|
||||
|
||||
Run `sandcage init` to scaffold a `.sandcage/` directory — it detects your project ecosystem (Rust, Node, Python, Go) and suggests appropriate toolchains and packages.
|
||||
|
||||
```
|
||||
.sandcage/
|
||||
├── sandcage.yml # project config (committed)
|
||||
├── overlay/ # files bind-mounted over originals in container
|
||||
├── local/ # gitignored
|
||||
│ ├── sandcage.local.yml
|
||||
│ └── overlay/
|
||||
└── .gitignore # contains "local/"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .sandcage/sandcage.yml — minimal example
|
||||
toolchains:
|
||||
node: "20"
|
||||
packages:
|
||||
- ripgrep
|
||||
services:
|
||||
gemini:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
See **[Configuration Reference](docs/configuration.md)** for all available options.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples |
|
||||
| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern |
|
||||
| [Interactive Mode](docs/interactive-mode.md) | How terminal sessions work, backend selection, and Windows compatibility |
|
||||
| [Architecture](docs/architecture.md) | Isolation model, UID/GID mapping, and container design |
|
||||
| [Docker Image](docs/docker-image.md) | Base image contents, building, custom Dockerfiles |
|
||||
| [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers |
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Named home profiles** — `--home <name>` switches between separate agent identities (e.g., personal vs work accounts) without re-authenticating
|
||||
- **Worktree mode** — `--worktree` creates a disposable git worktree for each run, keeping your main working tree untouched
|
||||
- **Host forwarding** — containers can execute approved commands on the host via a per-run forwarding daemon, with audit logging
|
||||
- **Tasks** — named operations (`[tasks]` config) that run on the host or in-cage, accessible as `just` recipes inside the container
|
||||
- **Tool modes** — control how CLI tools behave in the container: run normally, replace with a message (spoof), or forward to the host with allow/deny argument filtering
|
||||
- **Security profiles** — AppArmor and seccomp profiles applied to containers via `[profiles]` config
|
||||
- **Shadow directories** — redirect build artifact dirs (`target/`, `.venv/`, `node_modules/`) into container-local storage
|
||||
|
||||
## Planned Features
|
||||
|
||||
- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI
|
||||
- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
|
||||
|
||||
## 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
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,82 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420" width="800" height="420">
|
||||
<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; }
|
||||
.layer-desc { font-size: 11px; }
|
||||
.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="#e8ecf0"/>
|
||||
<stop offset="100%" stop-color="#dce1e6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad2" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#a8b2bc"/>
|
||||
<stop offset="100%" stop-color="#94a0ab"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad3" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#e8c9a4"/>
|
||||
<stop offset="100%" stop-color="#d4b08a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad3b" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#c9995c"/>
|
||||
<stop offset="100%" stop-color="#b58548"/>
|
||||
</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 with background -->
|
||||
<rect x="280" y="14" width="240" height="26" rx="4" fill="#f6f8fa" stroke="#d0d7de" stroke-width="0.5"/>
|
||||
<text class="title" x="400" y="33" text-anchor="middle">Configuration Layering</text>
|
||||
|
||||
<!-- Priority arrow on the left -->
|
||||
<line x1="70" y1="60" x2="70" y2="370" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
|
||||
<text class="arrow-text" x="66" y="220" text-anchor="middle" transform="rotate(-90, 66, 220)">Higher priority overrides</text>
|
||||
|
||||
<!-- Layer 1 - Compiled Defaults (bottom, lightest) -->
|
||||
<rect x="120" y="310" width="540" height="56" rx="6" fill="url(#grad1)" stroke="#c9d1d9" stroke-width="1"/>
|
||||
<text class="layer-label" x="390" y="335" text-anchor="middle" fill="#24292f">Compiled Defaults</text>
|
||||
<text class="layer-desc" x="390" y="353" text-anchor="middle" fill="#57606a">Built into the sandcage binary</text>
|
||||
<text class="side-label" x="680" y="335">Base values</text>
|
||||
<text class="side-detail" x="680" y="351">always present</text>
|
||||
|
||||
<!-- Layer 2 - Global Config -->
|
||||
<rect x="140" y="245" width="540" height="56" rx="6" fill="url(#grad2)" stroke="#8b949e" stroke-width="1"/>
|
||||
<text class="layer-label" x="410" y="270" text-anchor="middle" fill="#ffffff">Global Config</text>
|
||||
<text class="layer-desc" x="410" y="288" text-anchor="middle" fill="#f0f0f0">~/.sandcage/config.toml</text>
|
||||
<text class="side-label" x="700" y="270">User-wide</text>
|
||||
<text class="side-detail" x="700" y="286">SSH, shell, mounts</text>
|
||||
|
||||
<!-- Layer 3 - Project Config -->
|
||||
<rect x="160" y="180" width="540" height="56" rx="6" fill="url(#grad3)" stroke="#D4A574" stroke-width="1"/>
|
||||
<text class="layer-label" x="430" y="205" text-anchor="middle" fill="#3d2a12">Project Config</text>
|
||||
<text class="layer-desc" x="430" y="223" text-anchor="middle" fill="#5c3f1e">.sandcage.yml — checked into version control</text>
|
||||
<text class="side-label" x="720" y="205">Per-project</text>
|
||||
<text class="side-detail" x="720" y="221">packages, toolchains, env</text>
|
||||
|
||||
<!-- Layer 4 - Local Config -->
|
||||
<rect x="170" y="115" width="540" height="56" rx="6" fill="url(#grad3b)" stroke="#a06838" stroke-width="1"/>
|
||||
<text class="layer-label" x="440" y="140" text-anchor="middle" fill="#ffffff">Local Config</text>
|
||||
<text class="layer-desc" x="440" y="158" text-anchor="middle" fill="#f0f0f0">.sandcage.local.yml — gitignored, personal overrides</text>
|
||||
<text class="side-label" x="730" y="140">Per-developer</text>
|
||||
<text class="side-detail" x="730" y="156">SSH keys, secrets, mounts</text>
|
||||
|
||||
<!-- Layer 5 - CLI Flags (top, darkest) -->
|
||||
<rect x="180" y="55" width="540" height="50" rx="6" fill="url(#grad4)" stroke="#6b4720" stroke-width="1"/>
|
||||
<text class="layer-label" x="450" y="77" text-anchor="middle" fill="#ffffff">CLI Flags</text>
|
||||
<text class="layer-desc" x="450" y="94" text-anchor="middle" fill="#f0f0f0">sandcage claude -p ~/project -- --resume</text>
|
||||
<text class="side-label" x="740" y="77">Per-invocation</text>
|
||||
<text class="side-detail" x="740" y="93">highest priority</text>
|
||||
|
||||
<!-- Bottom note -->
|
||||
<text x="400" y="398" 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: 4.9 KiB |
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "sandcage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Sandboxed containers for AI coding agents"
|
||||
license = "MIT"
|
||||
keywords = ["docker", "sandbox", "ai", "agent"]
|
||||
|
||||
[features]
|
||||
default = ["bollard"]
|
||||
bollard = [
|
||||
"dep:bollard",
|
||||
"dep:futures-util",
|
||||
"dep:reqwest",
|
||||
"dep:flate2",
|
||||
"dep:tar",
|
||||
"dep:crossterm",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
figment = { version = "0.10", features = ["toml", "env"] }
|
||||
miette = { version = "7", features = ["fancy"] }
|
||||
thiserror = "2"
|
||||
which = "7"
|
||||
dirs = "6"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
tempfile = "3"
|
||||
walkdir = "2"
|
||||
dialoguer = "0.11"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread", "io-std", "io-util",
|
||||
"signal", "time", "macros", "process",
|
||||
] }
|
||||
|
||||
# Feature-gated
|
||||
bollard = { version = "0.21", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||
flate2 = { version = "1", optional = true }
|
||||
tar = { version = "0.4", optional = true }
|
||||
crossterm = { version = "0.28", optional = true }
|
||||
@@ -0,0 +1,231 @@
|
||||
pub mod registry;
|
||||
|
||||
use clap::Subcommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::backend::ContainerBackend;
|
||||
use crate::docker::DockerError;
|
||||
use crate::service;
|
||||
use crate::workspace;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AcpCommands {
|
||||
/// Run a service in ACP relay mode (bidirectional stdio)
|
||||
Run {
|
||||
/// Service name (e.g., claude, codex, gemini)
|
||||
service: String,
|
||||
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Arguments forwarded to the agent inside the container
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Install an agent from the ACP registry
|
||||
Install {
|
||||
/// Agent ID from the registry (e.g., claude-code)
|
||||
agent_id: String,
|
||||
/// Force re-download even if already installed
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// List available agents in the ACP registry
|
||||
List {
|
||||
/// Force refresh of the registry cache
|
||||
#[arg(long)]
|
||||
refresh: bool,
|
||||
},
|
||||
/// List locally installed ACP agents
|
||||
Installed,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
cmd: AcpCommands,
|
||||
backend: &dyn ContainerBackend,
|
||||
workspace_path: Option<PathBuf>,
|
||||
) -> std::result::Result<(), DockerError> {
|
||||
match cmd {
|
||||
AcpCommands::Run {
|
||||
service,
|
||||
path,
|
||||
agent_args,
|
||||
} => {
|
||||
let effective_path = path.or(workspace_path);
|
||||
let workspace = workspace::resolve_workspace(effective_path.as_deref())
|
||||
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
)))?;
|
||||
|
||||
crate::init::preseed()
|
||||
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
)))?;
|
||||
|
||||
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
|
||||
let global_config = home.join(".sandcage").join("config.toml");
|
||||
let config_src = crate::config_source::resolve_config_source(&workspace);
|
||||
let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone());
|
||||
let project_config = config_root.join(".sandcage").join("sandcage.yml");
|
||||
let local_config = config_root.join(".sandcage").join("local").join("sandcage.local.yml");
|
||||
let cfg = crate::config::resolve_config(
|
||||
Some(&global_config),
|
||||
Some(&project_config),
|
||||
Some(&local_config),
|
||||
)
|
||||
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
)))?;
|
||||
|
||||
let registry = service::registry::build_default_registry(&cfg);
|
||||
let ctx = crate::docker::build_compose_context(&workspace, &cfg, &config_root).await?;
|
||||
|
||||
let svc = registry.get(&service).ok_or_else(|| DockerError::UnknownService {
|
||||
service: service.clone(),
|
||||
available: registry.names().collect::<Vec<_>>().join(", "),
|
||||
})?;
|
||||
|
||||
if !svc.enabled() {
|
||||
return Err(DockerError::ServiceDisabled {
|
||||
service: service.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
||||
eprintln!("sandcage: acp relay \u{2192} {service}");
|
||||
|
||||
let exit_code = backend.run_acp_relay(svc, &ctx, &cfg, &agent_args).await?;
|
||||
|
||||
if exit_code != 0 {
|
||||
return Err(DockerError::ServiceFailed {
|
||||
service,
|
||||
code: exit_code,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
AcpCommands::Install { agent_id, force } => {
|
||||
handle_install(&agent_id, force).await.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
})
|
||||
}
|
||||
AcpCommands::List { refresh } => {
|
||||
handle_list(refresh).await.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
})
|
||||
}
|
||||
AcpCommands::Installed => {
|
||||
handle_installed().map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_install(agent_id: &str, force: bool) -> std::result::Result<(), String> {
|
||||
let index = registry::fetch_registry(false).await?;
|
||||
let entry = registry::find_agent(&index, agent_id)
|
||||
.ok_or_else(|| format!("Agent '{}' not found in registry", agent_id))?;
|
||||
|
||||
let platform = registry::platform_key();
|
||||
let target = entry
|
||||
.distribution
|
||||
.binary
|
||||
.as_ref()
|
||||
.and_then(|b| b.get(platform))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Agent '{}' has no binary for platform '{}'",
|
||||
agent_id, platform
|
||||
)
|
||||
})?;
|
||||
|
||||
if !force && registry::is_agent_installed(agent_id, &entry.version) {
|
||||
eprintln!("sandcage: {} v{} already installed", entry.name, entry.version);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if force {
|
||||
if let Some(dir) = registry::agent_install_dir(agent_id, &entry.version) {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
registry::install_agent(entry, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_list(refresh: bool) -> std::result::Result<(), String> {
|
||||
let index = registry::fetch_registry(refresh).await?;
|
||||
let platform = registry::platform_key();
|
||||
|
||||
println!("{:<25} {:<20} {:<10} {}", "ID", "NAME", "VERSION", "PLATFORM");
|
||||
println!("{}", "-".repeat(70));
|
||||
|
||||
for agent in &index.agents {
|
||||
let has_binary = agent
|
||||
.distribution
|
||||
.binary
|
||||
.as_ref()
|
||||
.map(|b| b.contains_key(platform))
|
||||
.unwrap_or(false);
|
||||
|
||||
let platform_status = if has_binary {
|
||||
"binary"
|
||||
} else if agent.distribution.npx.is_some() {
|
||||
"npx"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
|
||||
println!(
|
||||
"{:<25} {:<20} {:<10} {}",
|
||||
agent.id, agent.name, agent.version, platform_status
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_installed() -> std::result::Result<(), String> {
|
||||
let base = dirs::home_dir()
|
||||
.map(|h| h.join(".sandcage").join("agents"))
|
||||
.ok_or("cannot determine home directory")?;
|
||||
|
||||
if !base.exists() {
|
||||
println!("No agents installed.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<25} {:<15} {}", "AGENT", "VERSION", "PATH");
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let agent_id = entry.file_name().to_string_lossy().to_string();
|
||||
if agent_id == "registry.json" || agent_id.ends_with(".timestamp") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let versions = std::fs::read_dir(&path).map_err(|e| e.to_string())?;
|
||||
for ver_entry in versions.flatten() {
|
||||
let ver_path = ver_entry.path();
|
||||
if !ver_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let version = ver_entry.file_name().to_string_lossy().to_string();
|
||||
println!("{:<25} {:<15} {}", agent_id, version, ver_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const REGISTRY_URL: &str =
|
||||
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||
const REFRESH_INTERVAL_SECS: u64 = 3600;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegistryIndex {
|
||||
#[serde(rename = "version")]
|
||||
pub _version: String,
|
||||
pub agents: Vec<RegistryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub website: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
pub distribution: RegistryDistribution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryDistribution {
|
||||
#[serde(default)]
|
||||
pub binary: Option<HashMap<String, RegistryBinaryTarget>>,
|
||||
#[serde(default)]
|
||||
pub npx: Option<RegistryNpxDistribution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryBinaryTarget {
|
||||
pub archive: String,
|
||||
pub cmd: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryNpxDistribution {
|
||||
pub package: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AgentMeta {
|
||||
pub cmd: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub version: String,
|
||||
pub installed_at: String,
|
||||
}
|
||||
|
||||
fn agents_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".sandcage").join("agents"))
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
agents_dir().map(|d| d.join("registry.json"))
|
||||
}
|
||||
|
||||
fn timestamp_path() -> Option<PathBuf> {
|
||||
agents_dir().map(|d| d.join("registry.json.timestamp"))
|
||||
}
|
||||
|
||||
fn is_cache_fresh() -> bool {
|
||||
let Some(ts_path) = timestamp_path() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(content) = std::fs::read_to_string(&ts_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ts) = content.trim().parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts) < REFRESH_INTERVAL_SECS
|
||||
}
|
||||
|
||||
fn write_timestamp() {
|
||||
if let Some(ts_path) = timestamp_path() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
std::fs::write(&ts_path, now.to_string()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_registry(force_refresh: bool) -> std::result::Result<RegistryIndex, String> {
|
||||
let cache = cache_path().ok_or("cannot determine home directory")?;
|
||||
|
||||
if !force_refresh && is_cache_fresh() {
|
||||
if let Ok(data) = std::fs::read_to_string(&cache) {
|
||||
if let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) {
|
||||
return Ok(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(REGISTRY_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch registry: {e}"))?;
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read registry response: {e}"))?;
|
||||
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry JSON: {e}"))?;
|
||||
|
||||
if let Some(dir) = agents_dir() {
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
}
|
||||
if let Some(ref path) = cache_path() {
|
||||
std::fs::write(path, &body).ok();
|
||||
}
|
||||
write_timestamp();
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
pub fn find_agent<'a>(index: &'a RegistryIndex, agent_id: &str) -> Option<&'a RegistryEntry> {
|
||||
index.agents.iter().find(|a| a.id == agent_id)
|
||||
}
|
||||
|
||||
pub fn platform_key() -> &'static str {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
"linux-x86_64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"linux-aarch64"
|
||||
} else {
|
||||
"linux-x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_install_dir(agent_id: &str, version: &str) -> Option<PathBuf> {
|
||||
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
|
||||
agents_dir().map(|d| d.join(&sanitized_id).join(version))
|
||||
}
|
||||
|
||||
pub fn is_agent_installed(agent_id: &str, version: &str) -> bool {
|
||||
agent_install_dir(agent_id, version)
|
||||
.map(|d| d.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn install_agent(
|
||||
entry: &RegistryEntry,
|
||||
target: &RegistryBinaryTarget,
|
||||
) -> std::result::Result<PathBuf, String> {
|
||||
let install_dir = agent_install_dir(&entry.id, &entry.version)
|
||||
.ok_or("cannot determine install directory")?;
|
||||
|
||||
if install_dir.exists() {
|
||||
return Ok(install_dir);
|
||||
}
|
||||
|
||||
eprintln!("sandcage: downloading {} v{}...", entry.name, entry.version);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(&target.archive)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download agent: {e}"))?;
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read agent archive: {e}"))?;
|
||||
|
||||
let parent = install_dir.parent().ok_or("invalid install path")?;
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
|
||||
let url_lower = target.archive.to_lowercase();
|
||||
if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") {
|
||||
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(&install_dir)
|
||||
.map_err(|e| format!("Failed to extract tar.gz: {e}"))?;
|
||||
} else if url_lower.ends_with(".tar.bz2") || url_lower.ends_with(".tbz2") {
|
||||
return Err("tar.bz2 extraction not yet supported".to_string());
|
||||
} else if url_lower.ends_with(".zip") {
|
||||
return Err("zip extraction not yet supported — use tar.gz agents".to_string());
|
||||
} else {
|
||||
return Err(format!("Unknown archive format: {}", target.archive));
|
||||
}
|
||||
|
||||
let meta = AgentMeta {
|
||||
cmd: target.cmd.clone(),
|
||||
args: target.args.clone(),
|
||||
env: target.env.clone(),
|
||||
version: entry.version.clone(),
|
||||
installed_at: chrono_free_now(),
|
||||
};
|
||||
let meta_path = install_dir.join(".sandcage-meta.json");
|
||||
let meta_json = serde_json::to_string_pretty(&meta)
|
||||
.map_err(|e| format!("Failed to serialize meta: {e}"))?;
|
||||
std::fs::write(&meta_path, meta_json)
|
||||
.map_err(|e| format!("Failed to write meta: {e}"))?;
|
||||
|
||||
cleanup_old_versions(&entry.id, &entry.version);
|
||||
|
||||
eprintln!("sandcage: installed {} v{}", entry.name, entry.version);
|
||||
Ok(install_dir)
|
||||
}
|
||||
|
||||
fn chrono_free_now() -> String {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}", dur.as_secs())
|
||||
}
|
||||
|
||||
fn cleanup_old_versions(agent_id: &str, current_version: &str) {
|
||||
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
|
||||
let Some(base) = agents_dir().map(|d| d.join(&sanitized_id)) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str != current_version && entry.path().is_dir() && name_str != ".sandcage-meta.json" {
|
||||
std::fs::remove_dir_all(entry.path()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_agent_command(
|
||||
install_dir: &Path,
|
||||
target: &RegistryBinaryTarget,
|
||||
) -> std::result::Result<(PathBuf, Vec<String>, HashMap<String, String>), String> {
|
||||
let cmd = target.cmd.strip_prefix("./").unwrap_or(&target.cmd);
|
||||
|
||||
if cmd.contains("..") {
|
||||
return Err("path traversal in agent cmd is not allowed".to_string());
|
||||
}
|
||||
|
||||
let bin_path = install_dir.join(cmd);
|
||||
if !bin_path.exists() {
|
||||
return Err(format!(
|
||||
"agent binary not found at {}",
|
||||
bin_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok((bin_path, target.args.clone(), target.env.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_registry_json() -> &'static str {
|
||||
r#"{
|
||||
"version": "1",
|
||||
"agents": [
|
||||
{
|
||||
"id": "test-agent",
|
||||
"name": "Test Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A test agent",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"archive": "https://example.com/test-agent-linux-x86_64.tar.gz",
|
||||
"cmd": "./test-agent",
|
||||
"args": ["--acp"],
|
||||
"env": {"FOO": "bar"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "npx-only-agent",
|
||||
"name": "NPX Agent",
|
||||
"version": "2.0.0",
|
||||
"description": "NPX only",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@test/agent@2.0.0",
|
||||
"args": ["--stdio"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_registry_index() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse registry");
|
||||
assert_eq!(index.agents.len(), 2);
|
||||
assert_eq!(index.agents[0].id, "test-agent");
|
||||
assert_eq!(index.agents[1].id, "npx-only-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_by_id() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
assert!(find_agent(&index, "test-agent").is_some());
|
||||
assert!(find_agent(&index, "nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_target_has_correct_fields() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
let agent = find_agent(&index, "test-agent").unwrap();
|
||||
let target = agent
|
||||
.distribution
|
||||
.binary
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("linux-x86_64")
|
||||
.unwrap();
|
||||
assert_eq!(target.cmd, "./test-agent");
|
||||
assert_eq!(target.args, vec!["--acp"]);
|
||||
assert_eq!(target.env.get("FOO"), Some(&"bar".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npx_agent_has_package() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
let agent = find_agent(&index, "npx-only-agent").unwrap();
|
||||
let npx = agent.distribution.npx.as_ref().unwrap();
|
||||
assert_eq!(npx.package, "@test/agent@2.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_strips_dot_slash() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("test-agent");
|
||||
std::fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
|
||||
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "./test-agent".to_string(),
|
||||
args: vec!["--acp".to_string()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let (path, args, _env) = resolve_agent_command(tmp.path(), &target).unwrap();
|
||||
assert_eq!(path, bin);
|
||||
assert_eq!(args, vec!["--acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_rejects_path_traversal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "../../../etc/passwd".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = resolve_agent_command(tmp.path(), &target);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("path traversal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_fails_for_missing_binary() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "./nonexistent".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = resolve_agent_command(tmp.path(), &target);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_install_dir_sanitizes_id() {
|
||||
let dir = agent_install_dir("my@agent/test", "1.0.0").unwrap();
|
||||
let dir_name = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(!dir_name.contains('@'));
|
||||
assert!(!dir_name.contains('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_registry_with_missing_optional_fields() {
|
||||
let json = r#"{
|
||||
"version": "1",
|
||||
"agents": [{
|
||||
"id": "minimal",
|
||||
"name": "Minimal",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"archive": "https://example.com/a.tar.gz",
|
||||
"cmd": "./agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let index: RegistryIndex = serde_json::from_str(json).expect("parse");
|
||||
let agent = &index.agents[0];
|
||||
assert!(agent.repository.is_none());
|
||||
assert!(agent.website.is_none());
|
||||
assert!(agent.icon.is_none());
|
||||
|
||||
let target = agent.distribution.binary.as_ref().unwrap().get("linux-x86_64").unwrap();
|
||||
assert!(target.args.is_empty());
|
||||
assert!(target.env.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bollard::container::LogOutput;
|
||||
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
|
||||
use bollard::Docker;
|
||||
use crossterm::terminal;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::config::SandcageConfig;
|
||||
use crate::docker::{self, DockerError};
|
||||
use crate::service::registry::ServiceRegistry;
|
||||
use crate::service::{ComposeContext, ComposeServiceDef, Service};
|
||||
|
||||
use super::Result;
|
||||
|
||||
/// RAII guard that restores terminal from raw mode on drop.
|
||||
struct RawModeGuard;
|
||||
|
||||
impl RawModeGuard {
|
||||
fn enter() -> std::result::Result<Self, std::io::Error> {
|
||||
terminal::enable_raw_mode()?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
terminal::disable_raw_mode().ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BollardBackend {
|
||||
docker: Docker,
|
||||
runtime_info: tokio::sync::OnceCell<super::runtime::RuntimeInfo>,
|
||||
}
|
||||
|
||||
impl BollardBackend {
|
||||
pub fn try_new() -> super::Result<Self> {
|
||||
let docker = Self::connect_docker().map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
format!(
|
||||
"Cannot connect to Docker daemon: {e}. \
|
||||
Check that Docker is running and $DOCKER_HOST is set correctly."
|
||||
),
|
||||
))
|
||||
})?;
|
||||
Ok(Self {
|
||||
docker,
|
||||
runtime_info: tokio::sync::OnceCell::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_docker() -> std::result::Result<Docker, bollard::errors::Error> {
|
||||
if std::env::var("DOCKER_HOST").is_ok() {
|
||||
return Docker::connect_with_local_defaults();
|
||||
}
|
||||
if let Some(socket) = Self::socket_from_docker_context() {
|
||||
eprintln!("sandcage: using docker socket from context → {socket}");
|
||||
return Docker::connect_with_socket(&socket, 120, bollard::API_DEFAULT_VERSION);
|
||||
}
|
||||
Docker::connect_with_local_defaults()
|
||||
}
|
||||
|
||||
fn socket_from_docker_context() -> Option<String> {
|
||||
let output = std::process::Command::new("docker")
|
||||
.args(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let host = String::from_utf8(output.stdout).ok()?.trim().to_string();
|
||||
let path = host.strip_prefix("unix://")?;
|
||||
if std::path::Path::new(path).exists() {
|
||||
Some(path.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::ContainerBackend for BollardBackend {
|
||||
// Bollard-native interactive TTY with crossterm raw mode.
|
||||
// Falls back to compose if raw mode is unavailable (e.g. MinTTY/Git Bash).
|
||||
async fn run_interactive(
|
||||
&self,
|
||||
service: &dyn Service,
|
||||
ctx: &ComposeContext,
|
||||
config: &SandcageConfig,
|
||||
registry: &ServiceRegistry,
|
||||
shell_override: bool,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32> {
|
||||
if !self.can_use_bollard_tty() {
|
||||
let compose = super::compose::ComposeBackend;
|
||||
return compose
|
||||
.run_interactive(service, ctx, config, registry, shell_override, extra_args)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut compose_def = service.compose_service(ctx);
|
||||
if shell_override {
|
||||
compose_def.entrypoint = vec!["/bin/zsh".to_string()];
|
||||
}
|
||||
self.run_interactive_tty(&compose_def, config, &ctx.config_root, extra_args, ctx.forwarding_socket.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_acp_relay(
|
||||
&self,
|
||||
service: &dyn Service,
|
||||
ctx: &ComposeContext,
|
||||
config: &SandcageConfig,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32> {
|
||||
let compose_def = service.compose_service(ctx);
|
||||
self.acp_relay(&compose_def, config, &ctx.config_root, extra_args).await
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image: &str) -> Result<bool> {
|
||||
Ok(self.docker.inspect_image(image).await.is_ok())
|
||||
}
|
||||
|
||||
async fn volume_exists(&self, name: &str) -> Result<bool> {
|
||||
Ok(self.docker.inspect_volume(name).await.is_ok())
|
||||
}
|
||||
|
||||
async fn check_availability(&self) -> Result<()> {
|
||||
self.docker.ping().await.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
format!("Cannot connect to Docker: {e}"),
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
|
||||
let info = self
|
||||
.runtime_info
|
||||
.get_or_init(|| super::runtime::detect_runtime(&self.docker))
|
||||
.await;
|
||||
Ok(info.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl BollardBackend {
|
||||
fn build_container_config(
|
||||
&self,
|
||||
compose_def: &ComposeServiceDef,
|
||||
config: &SandcageConfig,
|
||||
config_root: &std::path::Path,
|
||||
extra_args: &[String],
|
||||
interactive: bool,
|
||||
forwarding_socket: Option<&std::path::Path>,
|
||||
security_opts: &[String],
|
||||
) -> ContainerCreateBody {
|
||||
let mut env: Vec<String> = compose_def.environment.clone();
|
||||
if let Some(ref env_map) = config.env {
|
||||
for (key, value) in env_map {
|
||||
env.push(format!("{key}={value}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut binds: Vec<String> = compose_def.volumes.clone();
|
||||
if let Some(ref mount_list) = config.mounts {
|
||||
for mount in mount_list {
|
||||
if mount.contains(':') {
|
||||
binds.push(docker::expand_mount_path(mount));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut mounts: Vec<Mount> = Vec::new();
|
||||
match config.ssh_mode.as_deref() {
|
||||
Some("volume") => {
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/agent/.ssh".to_string()),
|
||||
source: Some("sandcage-ssh".to_string()),
|
||||
typ: Some(MountType::VOLUME),
|
||||
read_only: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Some("bind") => {
|
||||
let mount = docker::expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
|
||||
binds.push(mount);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Overlay mounts from .sandcage/overlay/ and .sandcage/local/overlay/
|
||||
let overlay_enabled = config.overlay.as_ref()
|
||||
.and_then(|o| o.enabled)
|
||||
.unwrap_or(true);
|
||||
if overlay_enabled {
|
||||
for mount in docker::collect_overlay_mounts(config_root, &compose_def.working_dir) {
|
||||
binds.push(mount);
|
||||
}
|
||||
}
|
||||
|
||||
// Shadow mounts (volume + directory as -v binds; tmpfs folded into tmpfs_map below).
|
||||
let shadow_mounts =
|
||||
docker::collect_shadow_mounts(config, config_root, &compose_def.working_dir);
|
||||
binds.extend(crate::shadow::shadow_bind_strings(&shadow_mounts));
|
||||
|
||||
if let Some(spoof) = docker::collect_spoof_mount(config_root) {
|
||||
binds.push(spoof);
|
||||
}
|
||||
|
||||
// Forwarding socket mount
|
||||
if let Some(ref sock) = forwarding_socket {
|
||||
binds.push(format!(
|
||||
"{}:{}",
|
||||
sock.display(),
|
||||
crate::forwarding::CONTAINER_SOCKET_PATH
|
||||
));
|
||||
}
|
||||
|
||||
let cmd: Option<Vec<String>> = if extra_args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(extra_args.to_vec())
|
||||
};
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("sandcage.managed".to_string(), "true".to_string());
|
||||
labels.insert(
|
||||
"sandcage.mode".to_string(),
|
||||
if interactive { "interactive" } else { "acp" }.to_string(),
|
||||
);
|
||||
|
||||
// Build tmpfs map for .sandcage/ exclusion plus any tmpfs-backed shadow dirs.
|
||||
let mut tmpfs_paths: Vec<String> = compose_def.tmpfs.clone();
|
||||
tmpfs_paths.extend(crate::shadow::shadow_tmpfs_paths(&shadow_mounts));
|
||||
let tmpfs_map: Option<HashMap<String, String>> = if tmpfs_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tmpfs_paths
|
||||
.into_iter()
|
||||
.map(|p| (p, String::new()))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
ContainerCreateBody {
|
||||
image: Some(compose_def.image.clone()),
|
||||
entrypoint: Some(compose_def.entrypoint.clone()),
|
||||
cmd,
|
||||
working_dir: Some(compose_def.working_dir.clone()),
|
||||
user: Some(compose_def.user.clone()),
|
||||
env: Some(env),
|
||||
labels: Some(labels),
|
||||
tty: Some(interactive),
|
||||
open_stdin: Some(true),
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
stdin_once: Some(false),
|
||||
host_config: Some(HostConfig {
|
||||
binds: Some(binds),
|
||||
mounts: if mounts.is_empty() { None } else { Some(mounts) },
|
||||
tmpfs: tmpfs_map,
|
||||
auto_remove: Some(true),
|
||||
security_opt: if security_opts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(security_opts.to_vec())
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether bollard-native TTY is usable in the current terminal.
|
||||
/// On Windows, MinTTY (Git Bash) uses MSYS PTY pipes instead of a real
|
||||
/// Windows console handle, so `GetConsoleMode` on stdin fails.
|
||||
fn can_use_bollard_tty(&self) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// MinTTY (Git Bash / MSYS2) provides a Windows console handle, so
|
||||
// GetConsoleMode and crossterm raw mode both succeed — but MinTTY's
|
||||
// terminal emulator can't render ANSI output in raw console mode,
|
||||
// producing garbled escape sequences. Detect MSYS2/MinGW by MSYSTEM
|
||||
// env var and fall back to compose, which lets MinTTY handle the PTY.
|
||||
if std::env::var_os("MSYSTEM").is_some() {
|
||||
eprintln!("sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose");
|
||||
return false;
|
||||
}
|
||||
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
unsafe extern "system" {
|
||||
fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
|
||||
}
|
||||
let handle = std::io::stdin().as_raw_handle();
|
||||
let mut mode: u32 = 0;
|
||||
if unsafe { GetConsoleMode(handle as *mut _, &mut mode) } == 0 {
|
||||
eprintln!("sandcage: tty probe → stdin is not a Windows console handle — using compose");
|
||||
return false;
|
||||
}
|
||||
eprintln!("sandcage: tty probe → Windows console handle OK (mode=0x{mode:x})");
|
||||
}
|
||||
match terminal::enable_raw_mode() {
|
||||
Ok(()) => {
|
||||
terminal::disable_raw_mode().ok();
|
||||
eprintln!("sandcage: tty probe → crossterm raw mode OK — using bollard TTY");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("sandcage: tty probe → crossterm raw mode failed ({e}) — using compose");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bollard-native interactive session with crossterm raw mode and resize handling.
|
||||
async fn run_interactive_tty(
|
||||
&self,
|
||||
compose_def: &ComposeServiceDef,
|
||||
config: &SandcageConfig,
|
||||
config_root: &std::path::Path,
|
||||
extra_args: &[String],
|
||||
forwarding_socket: Option<&std::path::Path>,
|
||||
) -> Result<i32> {
|
||||
self.cleanup_orphans().await;
|
||||
|
||||
// Verify image exists
|
||||
let image = &compose_def.image;
|
||||
self.docker.inspect_image(image).await.map_err(|_| {
|
||||
DockerError::ImageNotFound {
|
||||
image: image.clone(),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Create and start container with TTY
|
||||
let security_opts = docker::resolve_security_opts(config, config_root);
|
||||
let container_config =
|
||||
self.build_container_config(compose_def, config, config_root, extra_args, true, forwarding_socket, &security_opts);
|
||||
|
||||
let container = self
|
||||
.docker
|
||||
.create_container(
|
||||
None::<bollard::query_parameters::CreateContainerOptions>,
|
||||
container_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to create container: {e}"),
|
||||
))
|
||||
})?;
|
||||
let id = container.id.clone();
|
||||
eprintln!("sandcage: container created → {}", &id[..12.min(id.len())]);
|
||||
|
||||
self.docker
|
||||
.start_container(
|
||||
&id,
|
||||
None::<bollard::query_parameters::StartContainerOptions>,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to start container: {e}"),
|
||||
))
|
||||
})?;
|
||||
eprintln!("sandcage: container started");
|
||||
|
||||
// Initial terminal resize
|
||||
if let Ok((w, h)) = terminal::size() {
|
||||
eprintln!("sandcage: terminal size {w}x{h} — resizing container TTY");
|
||||
self.docker
|
||||
.resize_container_tty(
|
||||
&id,
|
||||
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
|
||||
.w(w as i32)
|
||||
.h(h as i32)
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Enter raw mode (guard restores on drop, including panics)
|
||||
let _guard = RawModeGuard::enter().map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to enter raw mode: {e}"),
|
||||
))
|
||||
})?;
|
||||
eprintln!("sandcage: raw mode entered");
|
||||
|
||||
// Attach to container
|
||||
let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default()
|
||||
.stdin(true)
|
||||
.stdout(true)
|
||||
.stderr(true)
|
||||
.stream(true)
|
||||
.build();
|
||||
|
||||
let bollard::container::AttachContainerResults { mut output, mut input } = self
|
||||
.docker
|
||||
.attach_container(&id, Some(attach_options))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to attach to container: {e}"),
|
||||
))
|
||||
})?;
|
||||
eprintln!("sandcage: attached — spawning relay tasks");
|
||||
|
||||
// Container wait task
|
||||
let docker_wait = self.docker.clone();
|
||||
let id_wait = id.clone();
|
||||
let wait_handle = tokio::spawn(async move {
|
||||
let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default()
|
||||
.condition("not-running")
|
||||
.build();
|
||||
let mut stream = docker_wait.wait_container(&id_wait, Some(opts));
|
||||
if let Some(Ok(resp)) = stream.next().await {
|
||||
resp.status_code
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
});
|
||||
|
||||
// Stdin relay task
|
||||
let stdin_task = tokio::spawn(async move {
|
||||
let mut host_stdin = io::stdin();
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut total_bytes: usize = 0;
|
||||
loop {
|
||||
match host_stdin.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
eprintln!("sandcage: stdin → EOF after {total_bytes} bytes");
|
||||
input.shutdown().await.ok();
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
total_bytes += n;
|
||||
if input.write_all(&buf[..n]).await.is_err() {
|
||||
eprintln!("sandcage: stdin → write to container failed after {total_bytes} bytes");
|
||||
break;
|
||||
}
|
||||
if input.flush().await.is_err() {
|
||||
eprintln!("sandcage: stdin → flush to container failed after {total_bytes} bytes");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("sandcage: stdin → read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Output relay — TTY mode sends Console variant, non-TTY sends StdOut/StdErr
|
||||
let output_task = tokio::spawn(async move {
|
||||
let mut host_stdout = io::stdout();
|
||||
let mut host_stderr = io::stderr();
|
||||
let mut chunk_count: usize = 0;
|
||||
let mut total_bytes: usize = 0;
|
||||
while let Some(result) = output.next().await {
|
||||
match result {
|
||||
Ok(LogOutput::Console { message })
|
||||
| Ok(LogOutput::StdOut { message }) => {
|
||||
chunk_count += 1;
|
||||
total_bytes += message.len();
|
||||
host_stdout.write_all(&message).await.ok();
|
||||
host_stdout.flush().await.ok();
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
chunk_count += 1;
|
||||
total_bytes += message.len();
|
||||
host_stderr.write_all(&message).await.ok();
|
||||
host_stderr.flush().await.ok();
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("sandcage: output → unexpected variant: {other:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("sandcage: output → stream error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("sandcage: output → stream ended ({chunk_count} chunks, {total_bytes} bytes)");
|
||||
});
|
||||
|
||||
// Resize poller — checks terminal size every 250ms
|
||||
let docker_resize = self.docker.clone();
|
||||
let id_resize = id.clone();
|
||||
let resize_task = tokio::spawn(async move {
|
||||
let mut last_size = terminal::size().unwrap_or((80, 24));
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
if let Ok(current) = terminal::size() {
|
||||
if current != last_size {
|
||||
last_size = current;
|
||||
docker_resize
|
||||
.resize_container_tty(
|
||||
&id_resize,
|
||||
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
|
||||
.w(current.0 as i32)
|
||||
.h(current.1 as i32)
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Signal handler — Ctrl+C stops the container, lets wait_handle return naturally
|
||||
let docker_signal = self.docker.clone();
|
||||
let id_signal = id.clone();
|
||||
let signal_task = tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
eprintln!("sandcage: received interrupt, stopping container...");
|
||||
let opts = bollard::query_parameters::StopContainerOptionsBuilder::default()
|
||||
.t(2)
|
||||
.build();
|
||||
docker_signal
|
||||
.stop_container(&id_signal, Some(opts))
|
||||
.await
|
||||
.ok();
|
||||
});
|
||||
|
||||
// Wait for output to finish (container exited), then clean up tasks
|
||||
let _ = output_task.await;
|
||||
stdin_task.abort();
|
||||
signal_task.abort();
|
||||
resize_task.abort();
|
||||
|
||||
let exit_code = wait_handle.await.unwrap_or(-1);
|
||||
|
||||
// _guard drops here, restoring terminal from raw mode
|
||||
Ok(exit_code as i32)
|
||||
}
|
||||
|
||||
async fn acp_relay(
|
||||
&self,
|
||||
compose_def: &ComposeServiceDef,
|
||||
config: &SandcageConfig,
|
||||
config_root: &std::path::Path,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32> {
|
||||
self.run_container(compose_def, config, config_root, extra_args, false).await
|
||||
}
|
||||
|
||||
async fn run_container(
|
||||
&self,
|
||||
compose_def: &ComposeServiceDef,
|
||||
config: &SandcageConfig,
|
||||
config_root: &std::path::Path,
|
||||
extra_args: &[String],
|
||||
interactive: bool,
|
||||
) -> Result<i32> {
|
||||
self.cleanup_orphans().await;
|
||||
|
||||
let image = &compose_def.image;
|
||||
self.docker.inspect_image(image).await.map_err(|_| {
|
||||
DockerError::ImageNotFound {
|
||||
image: image.clone(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let container_config =
|
||||
self.build_container_config(compose_def, config, config_root, extra_args, interactive, None, &[]);
|
||||
|
||||
let container = self
|
||||
.docker
|
||||
.create_container(
|
||||
None::<bollard::query_parameters::CreateContainerOptions>,
|
||||
container_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to create container: {e}"),
|
||||
))
|
||||
})?;
|
||||
let id = container.id.clone();
|
||||
|
||||
let docker_signal = self.docker.clone();
|
||||
let id_signal = id.clone();
|
||||
let signal_task = tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
eprintln!("sandcage: received interrupt, stopping container...");
|
||||
let opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default()
|
||||
.force(true)
|
||||
.build();
|
||||
docker_signal
|
||||
.remove_container(&id_signal, Some(opts))
|
||||
.await
|
||||
.ok();
|
||||
std::process::exit(130);
|
||||
});
|
||||
|
||||
self.docker
|
||||
.start_container(
|
||||
&id,
|
||||
None::<bollard::query_parameters::StartContainerOptions>,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to start container: {e}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default()
|
||||
.stdin(true)
|
||||
.stdout(true)
|
||||
.stderr(true)
|
||||
.stream(true)
|
||||
.build();
|
||||
|
||||
let bollard::container::AttachContainerResults { mut output, mut input } = self
|
||||
.docker
|
||||
.attach_container(&id, Some(attach_options))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to attach to container: {e}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
let docker_wait = self.docker.clone();
|
||||
let id_wait = id.clone();
|
||||
let wait_handle = tokio::spawn(async move {
|
||||
let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default()
|
||||
.condition("not-running")
|
||||
.build();
|
||||
let mut stream = docker_wait.wait_container(&id_wait, Some(opts));
|
||||
if let Some(Ok(resp)) = stream.next().await {
|
||||
resp.status_code
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
});
|
||||
|
||||
let stdin_task = tokio::spawn(async move {
|
||||
let mut host_stdin = io::stdin();
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match host_stdin.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
input.shutdown().await.ok();
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
if input.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if input.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let output_task = tokio::spawn(async move {
|
||||
let mut host_stdout = io::stdout();
|
||||
let mut host_stderr = io::stderr();
|
||||
while let Some(result) = output.next().await {
|
||||
match result {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
host_stdout.write_all(&message).await.ok();
|
||||
host_stdout.flush().await.ok();
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
host_stderr.write_all(&message).await.ok();
|
||||
host_stderr.flush().await.ok();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("sandcage: container stream error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = output_task.await;
|
||||
stdin_task.abort();
|
||||
signal_task.abort();
|
||||
|
||||
let exit_code = wait_handle.await.unwrap_or(-1);
|
||||
Ok(exit_code as i32)
|
||||
}
|
||||
|
||||
async fn cleanup_orphans(&self) {
|
||||
let mut filters = HashMap::new();
|
||||
filters.insert("label", vec!["sandcage.managed=true"]);
|
||||
let opts = bollard::query_parameters::ListContainersOptionsBuilder::default()
|
||||
.all(true)
|
||||
.filters(&filters)
|
||||
.build();
|
||||
|
||||
if let Ok(containers) = self.docker.list_containers(Some(opts)).await {
|
||||
for container in containers {
|
||||
if let Some(id) = container.id {
|
||||
eprintln!("sandcage: cleaning up orphaned container {}", &id[..12.min(id.len())]);
|
||||
let rm_opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default()
|
||||
.force(true)
|
||||
.build();
|
||||
self.docker.remove_container(&id, Some(rm_opts)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use async_trait::async_trait;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::config::SandcageConfig;
|
||||
use crate::docker::{self, DockerError};
|
||||
use crate::service::registry::ServiceRegistry;
|
||||
use crate::service::{ComposeContext, Service};
|
||||
|
||||
use super::Result;
|
||||
|
||||
pub struct ComposeBackend;
|
||||
|
||||
#[async_trait]
|
||||
impl super::ContainerBackend for ComposeBackend {
|
||||
async fn run_interactive(
|
||||
&self,
|
||||
service: &dyn Service,
|
||||
ctx: &ComposeContext,
|
||||
config: &SandcageConfig,
|
||||
registry: &ServiceRegistry,
|
||||
shell_override: bool,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32> {
|
||||
let docker_path = docker::require_docker()?;
|
||||
docker::require_compose(&docker_path).await?;
|
||||
|
||||
let image_tag = format!("{}:latest", ctx.image);
|
||||
docker::require_image(&docker_path, &image_tag).await?;
|
||||
|
||||
if config.ssh_mode.as_deref() == Some("volume") {
|
||||
let vol_check = Command::new(&docker_path)
|
||||
.args(["volume", "inspect", "sandcage-ssh"])
|
||||
.output()
|
||||
.await;
|
||||
if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
|
||||
eprintln!(
|
||||
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compose_content = crate::service::compose::generate_compose(registry, ctx);
|
||||
let compose_file = docker::write_compose_tempfile(&compose_content)?;
|
||||
let compose_path = compose_file.path().to_string_lossy().into_owned();
|
||||
|
||||
let security_opts = docker::resolve_security_opts(config, &ctx.config_root);
|
||||
let run_args = docker::build_run_args(
|
||||
service.name(),
|
||||
&compose_path,
|
||||
config,
|
||||
&ctx.config_root,
|
||||
&ctx.container_dir,
|
||||
shell_override,
|
||||
extra_args,
|
||||
ctx.forwarding_socket.as_deref(),
|
||||
&security_opts,
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&docker_path);
|
||||
cmd.args(&run_args);
|
||||
cmd.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
|
||||
let status = cmd.status().await.map_err(DockerError::SpawnFailed)?;
|
||||
Ok(status.code().unwrap_or(-1))
|
||||
}
|
||||
|
||||
async fn run_acp_relay(
|
||||
&self,
|
||||
_service: &dyn Service,
|
||||
_ctx: &ComposeContext,
|
||||
_config: &SandcageConfig,
|
||||
_extra_args: &[String],
|
||||
) -> Result<i32> {
|
||||
Err(DockerError::AcpRequiresBollard)
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image: &str) -> Result<bool> {
|
||||
let docker_path = docker::require_docker()?;
|
||||
let status = Command::new(&docker_path)
|
||||
.args(["image", "inspect", image])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map_err(DockerError::SpawnFailed)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
async fn volume_exists(&self, name: &str) -> Result<bool> {
|
||||
let docker_path = docker::require_docker()?;
|
||||
let status = Command::new(&docker_path)
|
||||
.args(["volume", "inspect", name])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map_err(DockerError::SpawnFailed)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
async fn check_availability(&self) -> Result<()> {
|
||||
let docker_path = docker::require_docker()?;
|
||||
docker::require_compose(&docker_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
|
||||
Ok(super::runtime::RuntimeInfo::default())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
pub mod compose;
|
||||
pub mod runtime;
|
||||
#[cfg(feature = "bollard")]
|
||||
pub mod bollard;
|
||||
#[cfg(feature = "bollard")]
|
||||
pub mod network;
|
||||
#[cfg(feature = "bollard")]
|
||||
pub mod orchestrator;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::config::SandcageConfig;
|
||||
use crate::docker::DockerError;
|
||||
use crate::service::{ComposeContext, Service};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DockerError>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContainerBackend: Send + Sync {
|
||||
async fn run_interactive(
|
||||
&self,
|
||||
service: &dyn Service,
|
||||
ctx: &ComposeContext,
|
||||
config: &SandcageConfig,
|
||||
registry: &crate::service::registry::ServiceRegistry,
|
||||
shell_override: bool,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32>;
|
||||
|
||||
async fn run_acp_relay(
|
||||
&self,
|
||||
service: &dyn Service,
|
||||
ctx: &ComposeContext,
|
||||
config: &SandcageConfig,
|
||||
extra_args: &[String],
|
||||
) -> Result<i32>;
|
||||
|
||||
async fn image_exists(&self, image: &str) -> Result<bool>;
|
||||
|
||||
async fn volume_exists(&self, name: &str) -> Result<bool>;
|
||||
|
||||
async fn check_availability(&self) -> Result<()>;
|
||||
|
||||
async fn runtime_info(&self) -> Result<runtime::RuntimeInfo>;
|
||||
}
|
||||
|
||||
pub fn default_backend() -> Result<Box<dyn ContainerBackend>> {
|
||||
#[cfg(feature = "bollard")]
|
||||
{
|
||||
Ok(Box::new(bollard::BollardBackend::try_new()?))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "bollard"))]
|
||||
{
|
||||
Ok(Box::new(compose::ComposeBackend))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use bollard::Docker;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::docker::DockerError;
|
||||
|
||||
/// Generate a deterministic network name from the workspace path.
|
||||
pub fn network_name(workspace: &Path) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(workspace.to_string_lossy().as_bytes());
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
format!("sandcage-{}", &hash[..12])
|
||||
}
|
||||
|
||||
/// Create or reuse a project-scoped Docker network. Returns the network name.
|
||||
pub async fn ensure_network(
|
||||
docker: &Docker,
|
||||
workspace: &Path,
|
||||
) -> Result<String, DockerError> {
|
||||
let name = network_name(workspace);
|
||||
|
||||
// Check if it already exists
|
||||
if docker.inspect_network(&name, None::<bollard::query_parameters::InspectNetworkOptions>).await.is_ok() {
|
||||
return Ok(name);
|
||||
}
|
||||
|
||||
// Create it
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("sandcage.managed".to_string(), "true".to_string());
|
||||
|
||||
let config = bollard::models::NetworkCreateRequest {
|
||||
name: name.clone(),
|
||||
driver: Some("bridge".to_string()),
|
||||
labels: Some(labels),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
docker.create_network(config).await.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to create network {name}: {e}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Remove a project network. Only removes if it has the sandcage.managed label.
|
||||
pub async fn cleanup_network(
|
||||
docker: &Docker,
|
||||
network_name: &str,
|
||||
) -> Result<(), DockerError> {
|
||||
// Verify it's ours before removing
|
||||
match docker
|
||||
.inspect_network(network_name, None::<bollard::query_parameters::InspectNetworkOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
let is_ours = info
|
||||
.labels
|
||||
.as_ref()
|
||||
.map(|l| l.get("sandcage.managed").map(|v| v == "true").unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !is_ours {
|
||||
return Ok(()); // Not our network, don't touch it
|
||||
}
|
||||
}
|
||||
Err(_) => return Ok(()), // Already gone
|
||||
}
|
||||
|
||||
docker.remove_network(network_name).await.map_err(|e| {
|
||||
DockerError::SpawnFailed(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to remove network {network_name}: {e}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_name_is_deterministic() {
|
||||
let p = Path::new("/home/user/projects/my-app");
|
||||
let name1 = network_name(p);
|
||||
let name2 = network_name(p);
|
||||
assert_eq!(name1, name2);
|
||||
assert!(name1.starts_with("sandcage-"));
|
||||
assert_eq!(name1.len(), "sandcage-".len() + 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_paths_produce_different_names() {
|
||||
let a = network_name(Path::new("/a"));
|
||||
let b = network_name(Path::new("/b"));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::service::ComposeServiceDef;
|
||||
|
||||
use super::network;
|
||||
|
||||
/// A plan for running one or more containers on a shared network.
|
||||
#[derive(Debug)]
|
||||
pub struct OrchestrationPlan {
|
||||
pub network_name: String,
|
||||
pub services: Vec<ServiceInstance>,
|
||||
/// Which service to attach stdin/stdout to.
|
||||
pub primary: String,
|
||||
}
|
||||
|
||||
/// A single container to be started as part of an orchestration.
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceInstance {
|
||||
pub name: String,
|
||||
pub compose_def: ComposeServiceDef,
|
||||
pub depends_on: Vec<String>,
|
||||
}
|
||||
|
||||
impl OrchestrationPlan {
|
||||
/// Create a simple plan for a single service (no network needed).
|
||||
pub fn single(name: &str, compose_def: ComposeServiceDef) -> Self {
|
||||
Self {
|
||||
network_name: String::new(),
|
||||
services: vec![ServiceInstance {
|
||||
name: name.to_string(),
|
||||
compose_def,
|
||||
depends_on: vec![],
|
||||
}],
|
||||
primary: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a plan with multiple services on a shared network.
|
||||
pub fn multi(
|
||||
workspace: &Path,
|
||||
services: Vec<ServiceInstance>,
|
||||
primary: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
network_name: network::network_name(workspace),
|
||||
services,
|
||||
primary: primary.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this plan requires a Docker network.
|
||||
pub fn needs_network(&self) -> bool {
|
||||
self.services.len() > 1
|
||||
}
|
||||
|
||||
/// Return services in dependency order (simple topological sort).
|
||||
/// Services with no dependencies come first.
|
||||
pub fn startup_order(&self) -> Vec<&ServiceInstance> {
|
||||
let mut result: Vec<&ServiceInstance> = Vec::new();
|
||||
let mut placed: Vec<&str> = Vec::new();
|
||||
|
||||
// Simple iterative approach — works for small service counts
|
||||
let mut remaining: Vec<&ServiceInstance> = self.services.iter().collect();
|
||||
while !remaining.is_empty() {
|
||||
let before = remaining.len();
|
||||
remaining.retain(|s| {
|
||||
if s.depends_on.iter().all(|dep| placed.contains(&dep.as_str())) {
|
||||
result.push(s);
|
||||
placed.push(&s.name);
|
||||
false // remove from remaining
|
||||
} else {
|
||||
true // keep in remaining
|
||||
}
|
||||
});
|
||||
if remaining.len() == before {
|
||||
// Circular dependency — just append the rest
|
||||
result.extend(remaining.drain(..));
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::ComposeServiceDef;
|
||||
|
||||
fn dummy_def() -> ComposeServiceDef {
|
||||
ComposeServiceDef {
|
||||
image: "test:latest".to_string(),
|
||||
entrypoint: vec!["/bin/sh".to_string()],
|
||||
working_dir: "/workspace".to_string(),
|
||||
user: "1000:1000".to_string(),
|
||||
volumes: vec![],
|
||||
environment: vec![],
|
||||
tmpfs: vec![],
|
||||
tty: true,
|
||||
stdin_open: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_plan_has_one_service() {
|
||||
let plan = OrchestrationPlan::single("claude", dummy_def());
|
||||
assert_eq!(plan.services.len(), 1);
|
||||
assert_eq!(plan.primary, "claude");
|
||||
assert!(!plan.needs_network());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_plan_needs_network() {
|
||||
let plan = OrchestrationPlan::multi(
|
||||
Path::new("/workspace"),
|
||||
vec![
|
||||
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
|
||||
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
|
||||
],
|
||||
"app",
|
||||
);
|
||||
assert!(plan.needs_network());
|
||||
assert_eq!(plan.services.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_order_respects_dependencies() {
|
||||
let plan = OrchestrationPlan::multi(
|
||||
Path::new("/workspace"),
|
||||
vec![
|
||||
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
|
||||
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
|
||||
],
|
||||
"app",
|
||||
);
|
||||
let order = plan.startup_order();
|
||||
assert_eq!(order[0].name, "db");
|
||||
assert_eq!(order[1].name, "app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_order_handles_no_deps() {
|
||||
let plan = OrchestrationPlan::single("solo", dummy_def());
|
||||
let order = plan.startup_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0].name, "solo");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/// Runtime detection for rootless/rootful Docker and Podman.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuntimeMode {
|
||||
RootlessDocker,
|
||||
RootfulDocker,
|
||||
Podman,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RootlessDocker => write!(f, "docker (rootless)"),
|
||||
Self::RootfulDocker => write!(f, "docker (rootful)"),
|
||||
Self::Podman => write!(f, "podman (rootless)"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeInfo {
|
||||
pub mode: RuntimeMode,
|
||||
pub cgroup_v2: bool,
|
||||
pub api_version: Option<String>,
|
||||
pub server_version: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: RuntimeMode::Unknown,
|
||||
cgroup_v2: false,
|
||||
api_version: None,
|
||||
server_version: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeInfo {
|
||||
pub fn supports_resource_limits(&self) -> bool {
|
||||
self.cgroup_v2
|
||||
}
|
||||
|
||||
pub fn supports_privileged(&self) -> bool {
|
||||
self.mode == RuntimeMode::RootfulDocker
|
||||
}
|
||||
|
||||
pub fn supports_low_ports(&self) -> bool {
|
||||
self.mode == RuntimeMode::RootfulDocker
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bollard")]
|
||||
fn detect_cgroup_v2() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::path::Path::new("/sys/fs/cgroup/cgroup.controllers").exists()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bollard")]
|
||||
pub async fn detect_runtime(docker: &bollard::Docker) -> RuntimeInfo {
|
||||
let info = match docker.info().await {
|
||||
Ok(info) => info,
|
||||
Err(_) => return RuntimeInfo::default(),
|
||||
};
|
||||
|
||||
// Determine mode
|
||||
let mode = if let Some(ref sec_opts) = info.security_options {
|
||||
if sec_opts.iter().any(|s| s.contains("rootless")) {
|
||||
RuntimeMode::RootlessDocker
|
||||
} else {
|
||||
detect_mode_from_root_dir(&info)
|
||||
}
|
||||
} else {
|
||||
detect_mode_from_root_dir(&info)
|
||||
};
|
||||
|
||||
let server_version = info.server_version.clone();
|
||||
|
||||
// Get API version from docker.version()
|
||||
let api_version = match docker.version().await {
|
||||
Ok(ver) => ver.api_version,
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
RuntimeInfo {
|
||||
mode,
|
||||
cgroup_v2: detect_cgroup_v2(),
|
||||
api_version,
|
||||
server_version,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bollard")]
|
||||
fn detect_mode_from_root_dir(info: &bollard::models::SystemInfo) -> RuntimeMode {
|
||||
if let Some(ref root_dir) = info.docker_root_dir {
|
||||
if root_dir.contains(".local/share/docker") {
|
||||
return RuntimeMode::RootlessDocker;
|
||||
}
|
||||
}
|
||||
RuntimeMode::RootfulDocker
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_runtime_mode() {
|
||||
assert_eq!(RuntimeMode::RootlessDocker.to_string(), "docker (rootless)");
|
||||
assert_eq!(RuntimeMode::RootfulDocker.to_string(), "docker (rootful)");
|
||||
assert_eq!(RuntimeMode::Podman.to_string(), "podman (rootless)");
|
||||
assert_eq!(RuntimeMode::Unknown.to_string(), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_resource_limits_requires_cgroup_v2() {
|
||||
let info = RuntimeInfo {
|
||||
mode: RuntimeMode::RootfulDocker,
|
||||
cgroup_v2: false,
|
||||
api_version: None,
|
||||
server_version: None,
|
||||
};
|
||||
assert!(!info.supports_resource_limits());
|
||||
|
||||
let info = RuntimeInfo {
|
||||
cgroup_v2: true,
|
||||
..info
|
||||
};
|
||||
assert!(info.supports_resource_limits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_privileged_only_rootful() {
|
||||
let rootful = RuntimeInfo {
|
||||
mode: RuntimeMode::RootfulDocker,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(rootful.supports_privileged());
|
||||
|
||||
let rootless = RuntimeInfo {
|
||||
mode: RuntimeMode::RootlessDocker,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!rootless.supports_privileged());
|
||||
|
||||
let podman = RuntimeInfo {
|
||||
mode: RuntimeMode::Podman,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!podman.supports_privileged());
|
||||
|
||||
let unknown = RuntimeInfo::default();
|
||||
assert!(!unknown.supports_privileged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_low_ports_only_rootful() {
|
||||
let rootful = RuntimeInfo {
|
||||
mode: RuntimeMode::RootfulDocker,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(rootful.supports_low_ports());
|
||||
|
||||
let rootless = RuntimeInfo {
|
||||
mode: RuntimeMode::RootlessDocker,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!rootless.supports_low_ports());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_runtime_info() {
|
||||
let info = RuntimeInfo::default();
|
||||
assert_eq!(info.mode, RuntimeMode::Unknown);
|
||||
assert!(!info.cgroup_v2);
|
||||
assert!(info.api_version.is_none());
|
||||
assert!(info.server_version.is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigSource {
|
||||
pub root: PathBuf,
|
||||
pub origin: ConfigOrigin,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigOrigin {
|
||||
EnvVar,
|
||||
Workspace,
|
||||
Redirect { from: PathBuf },
|
||||
MainWorktree,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigOrigin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConfigOrigin::EnvVar => write!(f, "SANDCAGE_CONFIG_DIR"),
|
||||
ConfigOrigin::Workspace => write!(f, "workspace"),
|
||||
ConfigOrigin::Redirect { from } => write!(f, "redirect from {}", from.display()),
|
||||
ConfigOrigin::MainWorktree => write!(f, "main worktree"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the directory from which `.sandcage/` config is loaded.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `SANDCAGE_CONFIG_DIR` env var
|
||||
/// 2. `.sandcage/config.toml` in workspace
|
||||
/// 3. Redirect file (`sandcage.toml` at workspace root with `redirect =` key)
|
||||
/// 4. Main worktree root (if workspace is a git worktree)
|
||||
/// 5. None — no project config found
|
||||
pub fn resolve_config_source(workspace: &Path) -> Option<ConfigSource> {
|
||||
if let Ok(dir) = std::env::var("SANDCAGE_CONFIG_DIR") {
|
||||
let expanded = expand_tilde(&dir);
|
||||
if expanded.join(".sandcage").join("config.toml").exists() {
|
||||
return Some(ConfigSource { root: expanded, origin: ConfigOrigin::EnvVar });
|
||||
}
|
||||
eprintln!(
|
||||
"sandcage: warning: SANDCAGE_CONFIG_DIR={dir} does not contain \
|
||||
.sandcage/config.toml, ignoring"
|
||||
);
|
||||
}
|
||||
|
||||
if workspace.join(".sandcage").join("config.toml").exists() {
|
||||
return Some(ConfigSource {
|
||||
root: workspace.to_path_buf(),
|
||||
origin: ConfigOrigin::Workspace,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(target) = check_redirect(workspace) {
|
||||
return Some(ConfigSource {
|
||||
root: target,
|
||||
origin: ConfigOrigin::Redirect { from: workspace.to_path_buf() },
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(main_root) = find_main_worktree_root(workspace) {
|
||||
if main_root != workspace {
|
||||
if main_root.join(".sandcage").join("config.toml").exists() {
|
||||
return Some(ConfigSource {
|
||||
root: main_root,
|
||||
origin: ConfigOrigin::MainWorktree,
|
||||
});
|
||||
}
|
||||
if let Some(target) = check_redirect(&main_root) {
|
||||
return Some(ConfigSource {
|
||||
root: target,
|
||||
origin: ConfigOrigin::Redirect { from: main_root },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
|
||||
} else if let Some(suffix) = path.strip_prefix("~/") {
|
||||
match dirs::home_dir() {
|
||||
Some(home) => home.join(suffix),
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_redirect(dir: &Path) -> Option<PathBuf> {
|
||||
let redirect_file = dir.join("sandcage.toml");
|
||||
if !redirect_file.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&redirect_file).ok()?;
|
||||
let parsed: toml::Value = toml::from_str(&content).ok()?;
|
||||
let redirect = parsed.get("redirect")?.as_str()?;
|
||||
|
||||
let target = expand_tilde(redirect);
|
||||
let resolved = if target.is_relative() {
|
||||
dir.join(&target)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
if resolved.join(".sandcage").join("config.toml").exists() {
|
||||
Some(resolved)
|
||||
} else {
|
||||
eprintln!(
|
||||
"sandcage: warning: redirect in {} points to {}, which does not contain \
|
||||
.sandcage/config.toml",
|
||||
redirect_file.display(),
|
||||
resolved.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn find_main_worktree_root(from: &Path) -> Option<PathBuf> {
|
||||
let common = Command::new("git")
|
||||
.args(["rev-parse", "--git-common-dir"])
|
||||
.current_dir(from)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !common.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let git_dir = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(from)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !git_dir.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim());
|
||||
let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim());
|
||||
|
||||
let common_abs = from.join(&common_path).canonicalize().ok()?;
|
||||
let git_dir_abs = from.join(&git_dir_path).canonicalize().ok()?;
|
||||
|
||||
if common_abs == git_dir_abs {
|
||||
return None;
|
||||
}
|
||||
|
||||
common_abs.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_git_repo() -> (TempDir, PathBuf) {
|
||||
let dir = TempDir::new().expect("create tempdir");
|
||||
let path = dir.path().to_path_buf();
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&path)
|
||||
.status()
|
||||
.expect("git init");
|
||||
// Need at least one commit for worktrees to work
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(&path)
|
||||
.status()
|
||||
.expect("git commit");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn scaffold_sandcage(root: &Path) {
|
||||
let dir = root.join(".sandcage");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("config.toml"), "# test config\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_from_workspace() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
scaffold_sandcage(dir.path());
|
||||
let result = resolve_config_source(dir.path());
|
||||
assert!(result.is_some());
|
||||
let src = result.unwrap();
|
||||
assert!(matches!(src.origin, ConfigOrigin::Workspace));
|
||||
assert_eq!(src.root, dir.path().to_path_buf());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let result = resolve_config_source(dir.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_redirect() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
scaffold_sandcage(target.path());
|
||||
|
||||
let redirect_content = format!(
|
||||
"redirect = \"{}\"",
|
||||
target.path().to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
std::fs::write(dir.path().join("sandcage.toml"), redirect_content).unwrap();
|
||||
|
||||
let result = resolve_config_source(dir.path());
|
||||
assert!(result.is_some());
|
||||
let src = result.unwrap();
|
||||
assert!(matches!(src.origin, ConfigOrigin::Redirect { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_redirect_returns_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("sandcage.toml"),
|
||||
"redirect = \"/nonexistent/path\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let result = resolve_config_source(dir.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_config_wins_over_redirect() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
scaffold_sandcage(dir.path());
|
||||
// Also place a redirect — should be ignored since .sandcage/ exists
|
||||
std::fs::write(dir.path().join("sandcage.toml"), "redirect = \"/somewhere\"\n").unwrap();
|
||||
|
||||
let result = resolve_config_source(dir.path());
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_from_main_worktree() {
|
||||
let (_guard, repo_root) = make_git_repo();
|
||||
scaffold_sandcage(&repo_root);
|
||||
|
||||
let wt_path = repo_root.parent().unwrap().join("test-worktree");
|
||||
let status = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
&wt_path.to_string_lossy(),
|
||||
"-b",
|
||||
"test-wt",
|
||||
])
|
||||
.current_dir(&repo_root)
|
||||
.status()
|
||||
.expect("git worktree add");
|
||||
assert!(status.success(), "git worktree add failed");
|
||||
|
||||
let result = resolve_config_source(&wt_path);
|
||||
assert!(result.is_some(), "should find config from main worktree");
|
||||
let src = result.unwrap();
|
||||
assert!(
|
||||
matches!(src.origin, ConfigOrigin::MainWorktree),
|
||||
"origin should be MainWorktree, got {:?}",
|
||||
src.origin
|
||||
);
|
||||
|
||||
// Cleanup worktree
|
||||
Command::new("git")
|
||||
.args(["worktree", "remove", &wt_path.to_string_lossy()])
|
||||
.current_dir(&repo_root)
|
||||
.status()
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_worktree_not_triggered_when_config_in_workspace() {
|
||||
let (_guard, repo_root) = make_git_repo();
|
||||
scaffold_sandcage(&repo_root);
|
||||
|
||||
// In normal (non-worktree) case, should resolve as Workspace
|
||||
let result = resolve_config_source(&repo_root);
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_expands_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_tilde("~/foo"), home.join("foo"));
|
||||
assert_eq!(expand_tilde("~"), home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_leaves_absolute_unchanged() {
|
||||
assert_eq!(expand_tilde("/absolute/path"), PathBuf::from("/absolute/path"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::SandcageConfig;
|
||||
use crate::spoof::ToolMode;
|
||||
|
||||
pub const CONTAINER_SOCKET_PATH: &str = "/run/sandcage/cmd.sock";
|
||||
|
||||
pub fn session_dir(sandcage_home: &Path, session_id: &str) -> PathBuf {
|
||||
sandcage_home.join("run").join(session_id)
|
||||
}
|
||||
|
||||
pub fn socket_path(sandcage_home: &Path, session_id: &str) -> PathBuf {
|
||||
session_dir(sandcage_home, session_id).join("cmd.sock")
|
||||
}
|
||||
|
||||
pub fn audit_log_path(sandcage_home: &Path, session_id: &str) -> PathBuf {
|
||||
session_dir(sandcage_home, session_id).join("audit.jsonl")
|
||||
}
|
||||
|
||||
pub fn generate_session_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = ts.as_secs();
|
||||
let nanos = ts.subsec_nanos();
|
||||
format!("{secs}-{nanos:09}")
|
||||
}
|
||||
|
||||
pub fn create_session_dir(sandcage_home: &Path, session_id: &str) -> std::io::Result<PathBuf> {
|
||||
let dir = session_dir(sandcage_home, session_id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn cleanup_session_dir(sandcage_home: &Path, session_id: &str) {
|
||||
let dir = session_dir(sandcage_home, session_id);
|
||||
if dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathMapping {
|
||||
pub container_prefix: String,
|
||||
pub host_prefix: String,
|
||||
}
|
||||
|
||||
impl PathMapping {
|
||||
pub fn to_host(&self, container_path: &str) -> String {
|
||||
if let Some(rest) = container_path.strip_prefix(&self.container_prefix) {
|
||||
format!("{}{rest}", self.host_prefix)
|
||||
} else {
|
||||
container_path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_container(&self, host_path: &str) -> String {
|
||||
if let Some(rest) = host_path.strip_prefix(&self.host_prefix) {
|
||||
format!("{}{rest}", self.container_prefix)
|
||||
} else {
|
||||
host_path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate_args_to_host(&self, args: &[String]) -> Vec<String> {
|
||||
args.iter()
|
||||
.map(|arg| arg.replace(&self.container_prefix, &self.host_prefix))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn translate_output_to_container(&self, text: &str) -> String {
|
||||
text.replace(&self.host_prefix, &self.container_prefix)
|
||||
}
|
||||
}
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[cfg(unix)]
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuditEntry {
|
||||
pub timestamp: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
pub fn append_audit(log_path: &Path, entry: &AuditEntry) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path)?;
|
||||
let json = serde_json::to_string(entry)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
writeln!(file, "{json}")
|
||||
}
|
||||
|
||||
pub fn now_iso8601() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = d.as_secs();
|
||||
let time_secs = secs % 86400;
|
||||
let hours = time_secs / 3600;
|
||||
let minutes = (time_secs % 3600) / 60;
|
||||
let seconds = time_secs % 60;
|
||||
let (year, month, day) = epoch_days_to_ymd(secs / 86400);
|
||||
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
|
||||
}
|
||||
|
||||
fn epoch_days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
||||
days += 719468;
|
||||
let era = days / 146097;
|
||||
let doe = days - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
pub fn seccomp_uses_notify(profile_path: &Path) -> bool {
|
||||
std::fs::read_to_string(profile_path)
|
||||
.map(|content| content.contains("SCMP_ACT_NOTIFY"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn validate_request(
|
||||
kind: &str,
|
||||
name: &str,
|
||||
config: &SandcageConfig,
|
||||
is_trusted: bool,
|
||||
) -> Result<(), String> {
|
||||
if !is_trusted {
|
||||
return Err("rejected: project is not in trusted_projects — host forwarding disabled".to_string());
|
||||
}
|
||||
|
||||
match kind {
|
||||
"tool" => {
|
||||
let tools = config.tools.as_ref().ok_or_else(|| {
|
||||
format!("rejected: tool '{name}' not configured (no [tools] section)")
|
||||
})?;
|
||||
let tool_cfg = tools.get(name).ok_or_else(|| {
|
||||
format!("rejected: tool '{name}' not configured in [tools]")
|
||||
})?;
|
||||
let mode = tool_cfg
|
||||
.mode
|
||||
.as_deref()
|
||||
.map(ToolMode::parse)
|
||||
.unwrap_or(ToolMode::InCage);
|
||||
if mode != ToolMode::Host {
|
||||
return Err(format!(
|
||||
"rejected: tool '{name}' is mode={}, not host",
|
||||
tool_cfg.mode.as_deref().unwrap_or("in-cage")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"task" => {
|
||||
let tasks = config.tasks.as_ref().ok_or_else(|| {
|
||||
format!("rejected: task '{name}' not configured (no [tasks] section)")
|
||||
})?;
|
||||
let task_cfg = tasks.get(name).ok_or_else(|| {
|
||||
format!("rejected: task '{name}' not configured in [tasks]")
|
||||
})?;
|
||||
let location = task_cfg.location.as_deref().unwrap_or("in-cage");
|
||||
if location != "host" {
|
||||
return Err(format!(
|
||||
"rejected: task '{name}' is where={location}, not host — \
|
||||
it runs in-cage and should not be forwarded"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
other => Err(format!("rejected: unknown request kind '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon loop (Unix only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IncomingRequest {
|
||||
kind: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn serve_one(
|
||||
socket_path: &Path,
|
||||
audit_log: &Path,
|
||||
mapping: &PathMapping,
|
||||
config: &SandcageConfig,
|
||||
is_trusted: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(socket_path)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
|
||||
// Set socket permissions to 0o600
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(socket_path, perms)?;
|
||||
}
|
||||
|
||||
let (stream, _addr) = listener.accept().await?;
|
||||
handle_connection(stream, audit_log, mapping, config, is_trusted).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn serve_loop(
|
||||
socket_path: &Path,
|
||||
audit_log: &Path,
|
||||
mapping: &PathMapping,
|
||||
config: &SandcageConfig,
|
||||
is_trusted: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(socket_path)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
|
||||
// Set socket permissions to 0o600
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(socket_path, perms)?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await?;
|
||||
let audit_log = audit_log.to_path_buf();
|
||||
let mapping = mapping.clone();
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, &audit_log, &mapping, &config, is_trusted).await {
|
||||
eprintln!("sandcage forwarding: connection error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
audit_log: &Path,
|
||||
mapping: &PathMapping,
|
||||
config: &SandcageConfig,
|
||||
is_trusted: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
|
||||
let mut line = String::new();
|
||||
buf_reader.read_line(&mut line).await?;
|
||||
let line = line.trim();
|
||||
|
||||
let req: IncomingRequest = serde_json::from_str(line)?;
|
||||
|
||||
// Validate
|
||||
if let Err(msg) = validate_request(&req.kind, &req.name, config, is_trusted) {
|
||||
let err_frame = serde_json::to_string(&serde_json::json!({"stream": "stderr", "data": format!("{msg}\n")}))?;
|
||||
writer.write_all(err_frame.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
let exit_frame = serde_json::to_string(&serde_json::json!({"exit": 1}))?;
|
||||
writer.write_all(exit_frame.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
|
||||
let entry = AuditEntry {
|
||||
timestamp: now_iso8601(),
|
||||
kind: req.kind,
|
||||
name: req.name,
|
||||
args: req.args,
|
||||
cwd: req.cwd,
|
||||
exit_code: 1,
|
||||
};
|
||||
let _ = append_audit(audit_log, &entry);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Translate paths
|
||||
let host_args = mapping.translate_args_to_host(&req.args);
|
||||
let host_cwd = mapping.to_host(&req.cwd);
|
||||
|
||||
// Execute
|
||||
let exit_code = execute_forwarded(&req.name, &host_args, &host_cwd, mapping, &mut writer).await?;
|
||||
|
||||
// Exit frame
|
||||
let exit_frame = serde_json::to_string(&serde_json::json!({"exit": exit_code}))?;
|
||||
writer.write_all(exit_frame.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
|
||||
// Audit
|
||||
let entry = AuditEntry {
|
||||
timestamp: now_iso8601(),
|
||||
kind: req.kind,
|
||||
name: req.name,
|
||||
args: req.args,
|
||||
cwd: req.cwd,
|
||||
exit_code,
|
||||
};
|
||||
let _ = append_audit(audit_log, &entry);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn execute_forwarded<W: tokio::io::AsyncWrite + Unpin>(
|
||||
name: &str,
|
||||
args: &[String],
|
||||
cwd: &str,
|
||||
mapping: &PathMapping,
|
||||
writer: &mut W,
|
||||
) -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let mut child = Command::new(name)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<(String, String)>(64);
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let _ = tx_out.send(("stdout".to_string(), line.clone())).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let _ = tx_err.send(("stderr".to_string(), line.clone())).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream frames as they arrive
|
||||
// We need to drop the tasks' senders first by awaiting them, but we also
|
||||
// need to read from rx concurrently. Use a select-style approach.
|
||||
let mapping_clone = mapping.clone();
|
||||
let stream_task = tokio::spawn(async move {
|
||||
// Collect frames — we cannot write to `writer` from a spawned task
|
||||
// because it borrows the caller's writer. Instead, collect and return.
|
||||
let mut frames = Vec::new();
|
||||
while let Some((stream_name, data)) = rx.recv().await {
|
||||
let translated = mapping_clone.translate_output_to_container(&data);
|
||||
frames.push((stream_name, translated));
|
||||
}
|
||||
frames
|
||||
});
|
||||
|
||||
stdout_task.await?;
|
||||
stderr_task.await?;
|
||||
|
||||
let frames = stream_task.await?;
|
||||
for (stream_name, data) in frames {
|
||||
let frame = serde_json::to_string(&serde_json::json!({"stream": stream_name, "data": data}))?;
|
||||
writer.write_all(frame.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
pub fn resolve_task_command(
|
||||
task_name: &str,
|
||||
config: &SandcageConfig,
|
||||
) -> Result<(String, Vec<String>), String> {
|
||||
resolve_task_command_with_args(task_name, &[] as &[String], config)
|
||||
}
|
||||
|
||||
pub fn resolve_task_command_with_args<S: AsRef<str>>(
|
||||
task_name: &str,
|
||||
extra_args: &[S],
|
||||
config: &SandcageConfig,
|
||||
) -> Result<(String, Vec<String>), String> {
|
||||
let tasks = config.tasks.as_ref().ok_or("no [tasks] section")?;
|
||||
let task_cfg = tasks
|
||||
.get(task_name)
|
||||
.ok_or_else(|| format!("task '{task_name}' not found"))?;
|
||||
let run_cmd = task_cfg
|
||||
.run
|
||||
.as_deref()
|
||||
.ok_or_else(|| format!("task '{task_name}' has no `run` command"))?;
|
||||
|
||||
let mut parts: Vec<String> = run_cmd.split_whitespace().map(String::from).collect();
|
||||
if parts.is_empty() {
|
||||
return Err(format!("task '{task_name}' has an empty `run` command"));
|
||||
}
|
||||
|
||||
let cmd = parts.remove(0);
|
||||
parts.extend(extra_args.iter().map(|a| a.as_ref().to_string()));
|
||||
Ok((cmd, parts))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{SandcageConfig, TaskConfig, ToolConfig};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn config_with_host_tool(name: &str) -> SandcageConfig {
|
||||
let mut tools = HashMap::new();
|
||||
tools.insert(
|
||||
name.to_string(),
|
||||
ToolConfig {
|
||||
mode: Some("host".to_string()),
|
||||
message: None,
|
||||
allow: vec![],
|
||||
deny: vec![],
|
||||
},
|
||||
);
|
||||
SandcageConfig {
|
||||
tools: Some(tools),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_allows_configured_host_tool() {
|
||||
let config = config_with_host_tool("docker");
|
||||
let result = validate_request("tool", "docker", &config, true);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unconfigured_tool() {
|
||||
let config = config_with_host_tool("docker");
|
||||
let result = validate_request("tool", "git", &config, true);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not configured"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_when_untrusted() {
|
||||
let config = config_with_host_tool("docker");
|
||||
let result = validate_request("tool", "docker", &config, false);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("trusted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_non_host_mode_tool() {
|
||||
let mut tools = HashMap::new();
|
||||
tools.insert(
|
||||
"cargo".to_string(),
|
||||
ToolConfig {
|
||||
mode: Some("spoof".to_string()),
|
||||
message: Some("use just build".to_string()),
|
||||
allow: vec![],
|
||||
deny: vec![],
|
||||
},
|
||||
);
|
||||
let config = SandcageConfig {
|
||||
tools: Some(tools),
|
||||
..Default::default()
|
||||
};
|
||||
let result = validate_request("tool", "cargo", &config, true);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Session directory tests
|
||||
#[test]
|
||||
fn session_dir_is_under_sandcage_run() {
|
||||
let home = PathBuf::from("/home/user/.sandcage");
|
||||
let dir = session_dir(&home, "abc123");
|
||||
assert_eq!(dir, PathBuf::from("/home/user/.sandcage/run/abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_is_unique() {
|
||||
let a = generate_session_id();
|
||||
let b = generate_session_id();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.len() > 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_is_cmd_sock() {
|
||||
let home = PathBuf::from("/home/user/.sandcage");
|
||||
assert_eq!(
|
||||
socket_path(&home, "abc123"),
|
||||
PathBuf::from("/home/user/.sandcage/run/abc123/cmd.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_log_path_is_in_session_dir() {
|
||||
let home = PathBuf::from("/home/user/.sandcage");
|
||||
assert_eq!(
|
||||
audit_log_path(&home, "abc123"),
|
||||
PathBuf::from("/home/user/.sandcage/run/abc123/audit.jsonl")
|
||||
);
|
||||
}
|
||||
|
||||
// Path translation tests
|
||||
#[test]
|
||||
fn translate_container_to_host() {
|
||||
let m = PathMapping {
|
||||
container_prefix: "/workspace/app".to_string(),
|
||||
host_prefix: "/home/user/projects/app".to_string(),
|
||||
};
|
||||
assert_eq!(m.to_host("/workspace/app/src/main.rs"), "/home/user/projects/app/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_host_to_container() {
|
||||
let m = PathMapping {
|
||||
container_prefix: "/workspace/app".to_string(),
|
||||
host_prefix: "/home/user/projects/app".to_string(),
|
||||
};
|
||||
assert_eq!(m.to_container("/home/user/projects/app/Cargo.toml"), "/workspace/app/Cargo.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_translation_when_prefix_absent() {
|
||||
let m = PathMapping {
|
||||
container_prefix: "/workspace/app".to_string(),
|
||||
host_prefix: "/home/user/projects/app".to_string(),
|
||||
};
|
||||
assert_eq!(m.to_host("/usr/local/bin/cargo"), "/usr/local/bin/cargo");
|
||||
assert_eq!(m.to_container("/etc/passwd"), "/etc/passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_args_replaces_all_occurrences() {
|
||||
let m = PathMapping {
|
||||
container_prefix: "/workspace/app".to_string(),
|
||||
host_prefix: "/home/user/projects/app".to_string(),
|
||||
};
|
||||
let args = vec![
|
||||
"test".to_string(),
|
||||
"--manifest-path=/workspace/app/Cargo.toml".to_string(),
|
||||
"/workspace/app/src/lib.rs".to_string(),
|
||||
];
|
||||
let translated = m.translate_args_to_host(&args);
|
||||
assert_eq!(translated[0], "test");
|
||||
assert_eq!(translated[1], "--manifest-path=/home/user/projects/app/Cargo.toml");
|
||||
assert_eq!(translated[2], "/home/user/projects/app/src/lib.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_output_text_replaces_host_paths() {
|
||||
let m = PathMapping {
|
||||
container_prefix: "/workspace/app".to_string(),
|
||||
host_prefix: "/home/user/projects/app".to_string(),
|
||||
};
|
||||
let output = "error[E0308]: /home/user/projects/app/src/main.rs:42:5\n";
|
||||
assert_eq!(
|
||||
m.translate_output_to_container(output),
|
||||
"error[E0308]: /workspace/app/src/main.rs:42:5\n"
|
||||
);
|
||||
}
|
||||
|
||||
// Audit logging tests
|
||||
#[test]
|
||||
fn audit_entry_serializes() {
|
||||
let entry = AuditEntry {
|
||||
timestamp: "2026-06-02T14:00:00Z".to_string(),
|
||||
kind: "task".to_string(),
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/home/user/projects/app".to_string(),
|
||||
exit_code: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("\"name\":\"build\""));
|
||||
assert!(json.contains("\"exit_code\":0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_audit_creates_file_and_appends() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log_path = dir.path().join("audit.jsonl");
|
||||
|
||||
let entry1 = AuditEntry {
|
||||
timestamp: "2026-06-02T14:00:00Z".to_string(),
|
||||
kind: "task".to_string(),
|
||||
name: "build".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
exit_code: 0,
|
||||
};
|
||||
append_audit(&log_path, &entry1).unwrap();
|
||||
|
||||
let entry2 = AuditEntry {
|
||||
timestamp: "2026-06-02T14:00:01Z".to_string(),
|
||||
kind: "tool".to_string(),
|
||||
name: "docker".to_string(),
|
||||
args: vec!["ps".to_string()],
|
||||
cwd: "/workspace".to_string(),
|
||||
exit_code: 0,
|
||||
};
|
||||
append_audit(&log_path, &entry2).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&log_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("build"));
|
||||
assert!(lines[1].contains("docker"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn daemon_executes_echo_and_streams_output() {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
let audit = dir.path().join("audit.jsonl");
|
||||
|
||||
let mapping = PathMapping {
|
||||
container_prefix: "/workspace".to_string(),
|
||||
host_prefix: dir.path().to_string_lossy().to_string(),
|
||||
};
|
||||
let config = SandcageConfig::default();
|
||||
|
||||
let daemon_sock = sock.clone();
|
||||
let daemon_audit = audit.clone();
|
||||
let daemon_mapping = mapping.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, true).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut stream = TokioUnixStream::connect(&sock).await.unwrap();
|
||||
let request = r#"{"kind":"task","name":"echo","args":["hello-from-daemon"],"cwd":"/workspace"}"#;
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream.write_all(b"\n").await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
|
||||
let reader = TokioBufReader::new(&mut stream);
|
||||
let mut lines = reader.lines();
|
||||
let mut got_exit = false;
|
||||
let mut output_data = String::new();
|
||||
while let Some(line) = lines.next_line().await.unwrap() {
|
||||
if line.contains("\"exit\"") {
|
||||
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["exit"].as_i64().unwrap(), 0);
|
||||
got_exit = true;
|
||||
break;
|
||||
}
|
||||
if line.contains("\"data\"") {
|
||||
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
output_data.push_str(v["data"].as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
assert!(got_exit, "should receive exit frame");
|
||||
assert!(output_data.contains("hello-from-daemon"), "output should contain 'hello-from-daemon', got: '{output_data}'");
|
||||
|
||||
handle.await.unwrap().unwrap();
|
||||
|
||||
let audit_content = std::fs::read_to_string(&audit).unwrap();
|
||||
assert!(audit_content.contains("echo"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn daemon_rejects_untrusted_request() {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
let audit = dir.path().join("audit.jsonl");
|
||||
|
||||
let mapping = PathMapping {
|
||||
container_prefix: "/workspace".to_string(),
|
||||
host_prefix: dir.path().to_string_lossy().to_string(),
|
||||
};
|
||||
let config = SandcageConfig::default();
|
||||
|
||||
let daemon_sock = sock.clone();
|
||||
let daemon_audit = audit.clone();
|
||||
let daemon_mapping = mapping.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, false).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut stream = TokioUnixStream::connect(&sock).await.unwrap();
|
||||
let request = r#"{"kind":"task","name":"echo","args":["hello"],"cwd":"/workspace"}"#;
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream.write_all(b"\n").await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
|
||||
let reader = TokioBufReader::new(&mut stream);
|
||||
let mut lines = reader.lines();
|
||||
let mut got_rejection = false;
|
||||
let mut got_exit = false;
|
||||
while let Some(line) = lines.next_line().await.unwrap() {
|
||||
if line.contains("trusted") {
|
||||
got_rejection = true;
|
||||
}
|
||||
if line.contains("\"exit\"") {
|
||||
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["exit"].as_i64().unwrap(), 1, "rejected requests should exit 1");
|
||||
got_exit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(got_rejection, "should mention trust rejection");
|
||||
assert!(got_exit, "should send exit frame");
|
||||
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn config_with_task(name: &str, location: &str, run_cmd: &str) -> SandcageConfig {
|
||||
let mut tasks = HashMap::new();
|
||||
tasks.insert(
|
||||
name.to_string(),
|
||||
TaskConfig {
|
||||
location: Some(location.to_string()),
|
||||
run: Some(run_cmd.to_string()),
|
||||
},
|
||||
);
|
||||
SandcageConfig {
|
||||
tasks: Some(tasks),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_allows_configured_host_task() {
|
||||
let config = config_with_task("build", "host", "cargo build --release");
|
||||
let result = validate_request("task", "build", &config, true);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unconfigured_task() {
|
||||
let config = config_with_task("build", "host", "cargo build");
|
||||
let result = validate_request("task", "deploy", &config, true);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_in_cage_task_via_forwarding() {
|
||||
let config = config_with_task("lint", "in-cage", "clippy");
|
||||
let result = validate_request("task", "lint", &config, true);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_task_command_returns_run_field() {
|
||||
let config = config_with_task("build", "host", "cargo build --release");
|
||||
let (cmd, args) = resolve_task_command("build", &config).unwrap();
|
||||
assert_eq!(cmd, "cargo");
|
||||
assert_eq!(args, vec!["build", "--release"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_task_command_appends_extra_args() {
|
||||
let config = config_with_task("test", "host", "cargo test");
|
||||
let (cmd, args) = resolve_task_command_with_args("test", &["--lib"], &config).unwrap();
|
||||
assert_eq!(cmd, "cargo");
|
||||
assert_eq!(args, vec!["test", "--lib"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_scmp_act_notify_in_seccomp_profile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let with_notify = dir.path().join("notify.json");
|
||||
std::fs::write(
|
||||
&with_notify,
|
||||
r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[{"names":["execve"],"action":"SCMP_ACT_NOTIFY"}]}"#,
|
||||
).unwrap();
|
||||
assert!(seccomp_uses_notify(&with_notify));
|
||||
|
||||
let without = dir.path().join("basic.json");
|
||||
std::fs::write(
|
||||
&without,
|
||||
r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[]}"#,
|
||||
).unwrap();
|
||||
assert!(!seccomp_uses_notify(&without));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
const CLAUDE_SETTINGS_TEMPLATE: &str =
|
||||
include_str!("../../../templates/claude/settings.json");
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum InitError {
|
||||
#[error("Failed to create directory {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::init::create_dir_failed))]
|
||||
CreateDirFailed(PathBuf, #[source] std::io::Error),
|
||||
|
||||
#[error("Failed to write file {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::init::write_file_failed))]
|
||||
WriteFileFailed(PathBuf, #[source] std::io::Error),
|
||||
|
||||
#[error("Cannot determine home directory")]
|
||||
#[diagnostic(code(sandcage::init::no_home_dir))]
|
||||
NoHomeDir,
|
||||
|
||||
#[error(".sandcage/config.toml already exists at {0}; remove it manually to re-initialise")]
|
||||
#[diagnostic(code(sandcage::init::config_already_exists))]
|
||||
ConfigAlreadyExists(PathBuf),
|
||||
|
||||
#[error("You're in a git worktree. Run `sandcage init` from the main worktree at {0} instead.")]
|
||||
#[diagnostic(code(sandcage::init::in_worktree))]
|
||||
InWorktree(PathBuf),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, InitError>;
|
||||
|
||||
pub fn preseed() -> Result<()> {
|
||||
let home = dirs::home_dir().ok_or(InitError::NoHomeDir)?;
|
||||
preseed_with_home(&home)
|
||||
}
|
||||
|
||||
pub fn preseed_with_home(home: &Path) -> Result<()> {
|
||||
let sandcage_home = home.join(".sandcage");
|
||||
let agent_home = sandcage_home.join("homes").join("default");
|
||||
|
||||
// 1. ~/.sandcage/homes/default/ — persistent agent home directory
|
||||
create_dir_all(&agent_home)?;
|
||||
|
||||
// 2. ~/.sandcage/home/.claude/
|
||||
let claude_dir = agent_home.join(".claude");
|
||||
create_dir_all(&claude_dir)?;
|
||||
|
||||
// 3. ~/.sandcage/home/.codex/
|
||||
let codex_dir = agent_home.join(".codex");
|
||||
create_dir_all(&codex_dir)?;
|
||||
|
||||
// ~/.sandcage/home/.gemini/
|
||||
let gemini_dir = agent_home.join(".gemini");
|
||||
create_dir_all(&gemini_dir)?;
|
||||
|
||||
// 4. Seed Claude settings.json from the bundled template
|
||||
let settings_dest = claude_dir.join("settings.json");
|
||||
if !settings_dest.exists() {
|
||||
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
|
||||
.map_err(|e| InitError::WriteFileFailed(settings_dest.clone(), e))?;
|
||||
println!("sandcage: seeded {} from template", settings_dest.display());
|
||||
}
|
||||
|
||||
// 5. Seed .claude.json so the bind mount targets a file, not a directory
|
||||
let claude_json = agent_home.join(".claude.json");
|
||||
if !claude_json.exists() {
|
||||
std::fs::write(&claude_json, "{}\n")
|
||||
.map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?;
|
||||
}
|
||||
|
||||
// 6. Create an empty .justfile if absent
|
||||
let justfile = agent_home.join(".justfile");
|
||||
if !justfile.exists() {
|
||||
std::fs::write(&justfile, "")
|
||||
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_dir_all(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(path)
|
||||
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project scaffolding — `sandcage init`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Language ecosystems we can detect from marker files.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Ecosystem {
|
||||
Rust,
|
||||
Node,
|
||||
Python,
|
||||
Go,
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl Ecosystem {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Ecosystem::Rust => "rust",
|
||||
Ecosystem::Node => "node",
|
||||
Ecosystem::Python => "python",
|
||||
Ecosystem::Go => "go",
|
||||
Ecosystem::Generic => "generic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the primary language ecosystem from files present in `dir`.
|
||||
pub fn detect_ecosystem(dir: &Path) -> Ecosystem {
|
||||
if dir.join("Cargo.toml").exists() {
|
||||
Ecosystem::Rust
|
||||
} else if dir.join("package.json").exists() {
|
||||
Ecosystem::Node
|
||||
} else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
|
||||
Ecosystem::Python
|
||||
} else if dir.join("go.mod").exists() {
|
||||
Ecosystem::Go
|
||||
} else {
|
||||
Ecosystem::Generic
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the TOML content for a `config.toml` config file.
|
||||
fn build_toml(ecosystem: Ecosystem) -> String {
|
||||
let toolchain_and_packages = match ecosystem {
|
||||
Ecosystem::Rust => {
|
||||
"[toolchains]\nrust = true\n\npackages = [\"ripgrep\", \"fd-find\"]\n"
|
||||
}
|
||||
Ecosystem::Node => {
|
||||
"[toolchains]\nnvm = true\n\npackages = [\"git\", \"curl\"]\n"
|
||||
}
|
||||
Ecosystem::Python => {
|
||||
"[toolchains]\nuv = true\n\npackages = [\"git\", \"curl\"]\n"
|
||||
}
|
||||
Ecosystem::Go => {
|
||||
"# [toolchains]\n# go = true # TBD\n\npackages = [\"git\", \"curl\"]\n"
|
||||
}
|
||||
Ecosystem::Generic => {
|
||||
"packages = [\"git\", \"curl\"]\n"
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"# Sandcage project configuration\n\
|
||||
# Docs: https://github.com/user/sandcage\n\
|
||||
\n\
|
||||
# Detected ecosystem: {ecosystem}\n\
|
||||
\n\
|
||||
# [env]\n\
|
||||
# EXAMPLE_VAR = \"value\"\n\
|
||||
\n\
|
||||
{toolchain_and_packages}\
|
||||
\n\
|
||||
# mounts = [\n\
|
||||
# \"~/.ssh:/home/agent/.ssh:ro\",\n\
|
||||
# \"~/.gitconfig:/home/agent/.gitconfig:ro\",\n\
|
||||
# ]\n\
|
||||
\n\
|
||||
# [agent_args]\n\
|
||||
# claude = [\"--dangerously-skip-permissions\"]\n\
|
||||
\n\
|
||||
# shell = \"zsh\"\n\
|
||||
\n\
|
||||
# container_workspace = \"/workspace/my-project\"\n",
|
||||
ecosystem = ecosystem.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if `path` is a linked git worktree (not the main worktree).
|
||||
/// Returns the main worktree root if so.
|
||||
fn detect_linked_worktree(path: &Path) -> Option<PathBuf> {
|
||||
let common = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--git-common-dir"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !common.status.success() {
|
||||
return None;
|
||||
}
|
||||
let git_dir = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !git_dir.status.success() {
|
||||
return None;
|
||||
}
|
||||
let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim());
|
||||
let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim());
|
||||
let common_abs = path.join(&common_path).canonicalize().ok()?;
|
||||
let git_dir_abs = path.join(&git_dir_path).canonicalize().ok()?;
|
||||
if common_abs == git_dir_abs {
|
||||
return None;
|
||||
}
|
||||
common_abs.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Scaffold a `.sandcage/` directory in `path` with starter configuration
|
||||
/// based on the detected language ecosystem. Returns an error if the
|
||||
/// config file already exists.
|
||||
pub fn scaffold_project(path: &Path) -> Result<()> {
|
||||
if let Some(main_root) = detect_linked_worktree(path) {
|
||||
return Err(InitError::InWorktree(main_root));
|
||||
}
|
||||
|
||||
let sandcage_dir = path.join(".sandcage");
|
||||
let config_path = sandcage_dir.join("config.toml");
|
||||
|
||||
if config_path.exists() {
|
||||
return Err(InitError::ConfigAlreadyExists(config_path));
|
||||
}
|
||||
|
||||
let ecosystem = detect_ecosystem(path);
|
||||
let content = build_toml(ecosystem);
|
||||
|
||||
// Create directory structure
|
||||
create_dir_all(&sandcage_dir)?;
|
||||
create_dir_all(&sandcage_dir.join("overlay"))?;
|
||||
let local_dir = sandcage_dir.join("local");
|
||||
create_dir_all(&local_dir)?;
|
||||
create_dir_all(&local_dir.join("overlay"))?;
|
||||
|
||||
// Write config file
|
||||
std::fs::write(&config_path, &content)
|
||||
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
|
||||
|
||||
// Write .sandcage/.gitignore to exclude local/
|
||||
let inner_gitignore = sandcage_dir.join(".gitignore");
|
||||
std::fs::write(&inner_gitignore, "local/\nshadow/\n")
|
||||
.map_err(|e| InitError::WriteFileFailed(inner_gitignore, e))?;
|
||||
|
||||
println!(
|
||||
"sandcage: initialised {} (detected ecosystem: {})",
|
||||
config_path.display(),
|
||||
ecosystem.as_str(),
|
||||
);
|
||||
println!();
|
||||
print!("{content}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn run(home: &Path) -> Result<()> {
|
||||
preseed_with_home(home)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_home_dir() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
assert!(
|
||||
home.path().join(".sandcage/homes/default").is_dir(),
|
||||
"home dir should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_agent_config_dirs() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
assert!(
|
||||
home.path().join(".sandcage/homes/default/.claude").is_dir(),
|
||||
".claude dir should exist"
|
||||
);
|
||||
assert!(
|
||||
home.path().join(".sandcage/homes/default/.codex").is_dir(),
|
||||
".codex dir should exist"
|
||||
);
|
||||
assert!(
|
||||
home.path().join(".sandcage/homes/default/.gemini").is_dir(),
|
||||
".gemini dir should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_claude_settings_when_absent() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let settings = home.path().join(".sandcage/homes/default/.claude/settings.json");
|
||||
assert!(settings.exists(), "settings.json should be created");
|
||||
|
||||
let content = std::fs::read_to_string(&settings).expect("read settings");
|
||||
assert_eq!(
|
||||
content.trim(),
|
||||
CLAUDE_SETTINGS_TEMPLATE.trim(),
|
||||
"settings.json content should match the template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_overwrite_existing_claude_settings() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
// Create the directory and put custom content in settings.json first
|
||||
let claude_dir = home.path().join(".sandcage/homes/default/.claude");
|
||||
std::fs::create_dir_all(&claude_dir).expect("mkdir");
|
||||
let settings = claude_dir.join("settings.json");
|
||||
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
|
||||
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let content = std::fs::read_to_string(&settings).expect("read");
|
||||
assert_eq!(
|
||||
content, r#"{"existing": true}"#,
|
||||
"existing settings.json must not be overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_empty_justfile_when_absent() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let justfile = home.path().join(".sandcage/homes/default/.justfile");
|
||||
assert!(justfile.exists(), "Justfile should be created");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&justfile).expect("read"),
|
||||
"",
|
||||
"Justfile should be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_overwrite_existing_justfile() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
// Seed a Justfile with content first
|
||||
let sandcage_dir = home.path().join(".sandcage/homes/default");
|
||||
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
|
||||
let justfile = sandcage_dir.join(".justfile");
|
||||
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
|
||||
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let content = std::fs::read_to_string(&justfile).expect("read");
|
||||
assert_eq!(
|
||||
content, "default:\n\t@echo hello",
|
||||
"existing Justfile must not be overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_second_run() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("first preseed");
|
||||
run(home.path()).expect("second preseed should also succeed");
|
||||
|
||||
// All artefacts should still exist after two runs
|
||||
assert!(home.path().join(".sandcage/homes/default/.claude").is_dir());
|
||||
assert!(home.path().join(".sandcage/homes/default/.codex").is_dir());
|
||||
assert!(home.path().join(".sandcage/homes/default/.gemini").is_dir());
|
||||
assert!(home.path().join(".sandcage/homes/default/.claude/settings.json").exists());
|
||||
assert!(home.path().join(".sandcage/homes/default/.justfile").exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// scaffold_project / ecosystem detection tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn touch(dir: &Path, name: &str) {
|
||||
std::fs::write(dir.join(name), "").expect("touch file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust_from_cargo_toml() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Rust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_node_from_package_json() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "package.json");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Node);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_python_from_pyproject_toml() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "pyproject.toml");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_python_from_requirements_txt() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "requirements.txt");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_go_from_go_mod() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "go.mod");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Go);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_generic_when_no_markers() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_creates_config_toml() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
|
||||
scaffold_project(dir.path()).expect("scaffold");
|
||||
|
||||
assert!(dir.path().join(".sandcage").join("config.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_refuses_to_overwrite_existing_config() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let sandcage_dir = dir.path().join(".sandcage");
|
||||
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
|
||||
std::fs::write(sandcage_dir.join("config.toml"), "# existing config\n").expect("write");
|
||||
|
||||
let result = scaffold_project(dir.path());
|
||||
assert!(
|
||||
matches!(result, Err(InitError::ConfigAlreadyExists(_))),
|
||||
"expected ConfigAlreadyExists, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_generated_toml_is_valid() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
|
||||
scaffold_project(dir.path()).expect("scaffold");
|
||||
|
||||
let content = std::fs::read_to_string(dir.path().join(".sandcage").join("config.toml"))
|
||||
.expect("read config.toml");
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&content).expect("TOML must be valid");
|
||||
let table = parsed.as_table().expect("top-level TOML must be a table");
|
||||
assert!(
|
||||
table.contains_key("toolchains"),
|
||||
"expected 'toolchains' key in generated TOML"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_creates_directory_structure() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
|
||||
scaffold_project(dir.path()).expect("scaffold");
|
||||
|
||||
let sandcage = dir.path().join(".sandcage");
|
||||
assert!(sandcage.join("overlay").is_dir(), "overlay/ should exist");
|
||||
assert!(sandcage.join("local").is_dir(), "local/ should exist");
|
||||
assert!(sandcage.join("local").join("overlay").is_dir(), "local/overlay/ should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_creates_inner_gitignore() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
|
||||
scaffold_project(dir.path()).expect("scaffold");
|
||||
|
||||
let gitignore = dir.path().join(".sandcage").join(".gitignore");
|
||||
assert!(gitignore.exists(), ".sandcage/.gitignore should exist");
|
||||
let content = std::fs::read_to_string(&gitignore).expect("read .gitignore");
|
||||
assert!(content.contains("local/"), ".gitignore should contain 'local/'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_gitignore_excludes_shadow() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
scaffold_project(dir.path()).expect("scaffold");
|
||||
let content = std::fs::read_to_string(dir.path().join(".sandcage").join(".gitignore")).unwrap();
|
||||
assert!(content.contains("local/"));
|
||||
assert!(content.contains("shadow/"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pub mod backend;
|
||||
pub mod config;
|
||||
pub mod config_source;
|
||||
pub mod docker;
|
||||
pub mod forwarding;
|
||||
pub mod init;
|
||||
pub mod recipe;
|
||||
pub mod service;
|
||||
pub mod setup;
|
||||
pub mod shadow;
|
||||
pub mod spoof;
|
||||
pub mod ssh_config;
|
||||
pub mod tasks;
|
||||
pub mod workspace;
|
||||
pub mod worktree;
|
||||
|
||||
#[cfg(feature = "bollard")]
|
||||
pub mod acp;
|
||||
@@ -0,0 +1,412 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use miette::Diagnostic;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
use sandcage::config;
|
||||
use sandcage::config_source;
|
||||
use sandcage::docker;
|
||||
use sandcage::init;
|
||||
use sandcage::service;
|
||||
use sandcage::setup;
|
||||
use sandcage::workspace;
|
||||
|
||||
/// Sandboxed containers for AI coding agents
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sandcage", version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Run Claude Code agent in a sandboxed container
|
||||
Claude {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Drop into a shell instead of launching the agent
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
|
||||
/// Create a git worktree and mount it instead of the project directory
|
||||
#[arg(long)]
|
||||
worktree: bool,
|
||||
|
||||
/// Named home profile for agent auth/config (default: "default")
|
||||
#[arg(long)]
|
||||
home: Option<String>,
|
||||
|
||||
/// Arguments forwarded to the agent inside the container
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Run Codex agent in a sandboxed container
|
||||
Codex {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Drop into a shell instead of launching the agent
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
|
||||
/// Create a git worktree and mount it instead of the project directory
|
||||
#[arg(long)]
|
||||
worktree: bool,
|
||||
|
||||
/// Named home profile for agent auth/config (default: "default")
|
||||
#[arg(long)]
|
||||
home: Option<String>,
|
||||
|
||||
/// Arguments forwarded to the agent inside the container
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Run Gemini CLI agent in a sandboxed container
|
||||
Gemini {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Drop into a shell instead of launching the agent
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
|
||||
/// Create a git worktree and mount it instead of the project directory
|
||||
#[arg(long)]
|
||||
worktree: bool,
|
||||
|
||||
/// Named home profile for agent auth/config (default: "default")
|
||||
#[arg(long)]
|
||||
home: Option<String>,
|
||||
|
||||
/// Arguments forwarded to the agent inside the container
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Interactive shell with the same environment
|
||||
Shell {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Named home profile for agent auth/config (default: "default")
|
||||
#[arg(long)]
|
||||
home: Option<String>,
|
||||
},
|
||||
/// Build container images
|
||||
Build {
|
||||
/// Force rebuild even if images are up to date
|
||||
#[arg(long, short)]
|
||||
force: bool,
|
||||
|
||||
/// Services to build (default: all enabled services)
|
||||
services: Vec<String>,
|
||||
},
|
||||
/// Initialize a .sandcage/ directory for a project
|
||||
Init,
|
||||
/// Remove this project's shadow volumes (named `sandcage-<project>-…`).
|
||||
Clean {
|
||||
/// Actually delete (default is a dry run).
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Guided configuration helpers
|
||||
Setup {
|
||||
#[command(subcommand)]
|
||||
action: SetupAction,
|
||||
},
|
||||
/// Manage sandcage-created worktrees
|
||||
Worktree {
|
||||
#[command(subcommand)]
|
||||
action: WorktreeAction,
|
||||
},
|
||||
/// ACP (Agent Control Protocol) commands
|
||||
#[cfg(feature = "bollard")]
|
||||
Acp {
|
||||
#[command(subcommand)]
|
||||
action: sandcage::acp::AcpCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SetupAction {
|
||||
/// Configure SSH key access for containers
|
||||
Ssh {
|
||||
/// Write to global config instead of project config
|
||||
#[arg(long)]
|
||||
global: bool,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
|
||||
/// Re-populate the SSH volume using saved selection
|
||||
#[arg(long)]
|
||||
refresh: bool,
|
||||
|
||||
/// Use legacy full bind mount instead of volume copy
|
||||
#[arg(long)]
|
||||
bind: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum WorktreeAction {
|
||||
/// List sandcage-managed worktrees
|
||||
List,
|
||||
/// Remove all sandcage-managed worktrees
|
||||
Clean {
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
enum AppError {
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Workspace(#[from] workspace::WorkspaceError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Init(#[from] init::InitError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Config(#[from] config::ConfigError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Docker(#[from] docker::DockerError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Setup(#[from] setup::SetupError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Worktree(#[from] sandcage::worktree::WorktreeError),
|
||||
}
|
||||
|
||||
async fn print_runtime_info(config: &sandcage::config::SandcageConfig) {
|
||||
let backend = match sandcage::backend::default_backend() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
if backend.check_availability().await.is_err() {
|
||||
return; // Docker not available — will fail later with a proper error
|
||||
}
|
||||
|
||||
match backend.runtime_info().await {
|
||||
Ok(info) => {
|
||||
use sandcage::backend::runtime::RuntimeMode;
|
||||
match info.mode {
|
||||
RuntimeMode::RootlessDocker => {
|
||||
eprintln!("sandcage: runtime \u{2192} {} (recommended)", info.mode);
|
||||
}
|
||||
RuntimeMode::RootfulDocker => {
|
||||
if config.runtime.as_deref() == Some("rootful") {
|
||||
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||
} else {
|
||||
eprintln!("sandcage: runtime \u{2192} {} \u{2014} consider rootless for better isolation", info.mode);
|
||||
}
|
||||
}
|
||||
RuntimeMode::Podman => {
|
||||
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||
}
|
||||
RuntimeMode::Unknown => {
|
||||
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||
}
|
||||
}
|
||||
|
||||
if !info.cgroup_v2 && info.mode != RuntimeMode::Unknown {
|
||||
eprintln!("sandcage: note: cgroup v2 not detected — resource limits will not be enforced");
|
||||
}
|
||||
|
||||
if let Some(ref runtime_pref) = config.runtime {
|
||||
match runtime_pref.as_str() {
|
||||
"rootless" if info.mode != RuntimeMode::RootlessDocker && info.mode != RuntimeMode::Podman => {
|
||||
eprintln!("sandcage: error: config requires rootless runtime, but connected daemon is {}", info.mode);
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {} // Silent — will fail later
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, worktree: bool, home_override: Option<String>, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
||||
|
||||
let workspace = if worktree {
|
||||
let sandcage_home = dirs::home_dir()
|
||||
.map(|h| h.join(".sandcage"))
|
||||
.unwrap_or_else(|| PathBuf::from(".sandcage"));
|
||||
let slug = sandcage::worktree::project_slug(&workspace);
|
||||
let ts = sandcage::worktree::generate_timestamp();
|
||||
let wt_path = sandcage::worktree::worktree_dir(&sandcage_home, &slug, &ts);
|
||||
|
||||
eprintln!("sandcage: creating worktree at {}", wt_path.display());
|
||||
sandcage::worktree::create_worktree(&workspace, &wt_path, None)?;
|
||||
eprintln!("sandcage: launching in worktree");
|
||||
wt_path
|
||||
} else {
|
||||
workspace
|
||||
};
|
||||
|
||||
init::preseed()?;
|
||||
|
||||
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
|
||||
let global_config = home.join(".sandcage").join("config.toml");
|
||||
|
||||
let config_src = config_source::resolve_config_source(&workspace);
|
||||
let (project_config, local_config) = match &config_src {
|
||||
Some(src) => {
|
||||
if !matches!(src.origin, config_source::ConfigOrigin::Workspace) {
|
||||
eprintln!("sandcage: config → {} ({})", src.root.display(), src.origin);
|
||||
}
|
||||
(
|
||||
src.root.join(".sandcage").join("config.toml"),
|
||||
src.root.join(".sandcage").join("local").join("config.local.toml"),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
workspace.join(".sandcage").join("config.toml"),
|
||||
workspace.join(".sandcage").join("local").join("config.local.toml"),
|
||||
),
|
||||
};
|
||||
|
||||
// Warn about old YAML config files that have not yet been migrated.
|
||||
for (old_name, new_name) in [
|
||||
("sandcage.yml", "config.toml"),
|
||||
("sandcage.local.yml", "config.local.toml"),
|
||||
] {
|
||||
let dir = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace);
|
||||
let old_path = dir.join(".sandcage").join(old_name);
|
||||
if old_path.exists() {
|
||||
eprintln!("sandcage: warning: found old {old_name} — please migrate to {new_name}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg = config::resolve_config(
|
||||
Some(&global_config),
|
||||
Some(&project_config),
|
||||
Some(&local_config),
|
||||
)?;
|
||||
|
||||
if let Some(home_name) = home_override {
|
||||
cfg.home = Some(home_name);
|
||||
}
|
||||
|
||||
print_runtime_info(&cfg).await;
|
||||
|
||||
let registry = service::registry::build_default_registry(&cfg);
|
||||
|
||||
eprintln!("sandcage: service \u{2192} {service}");
|
||||
let config_root = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace);
|
||||
docker::run_service(service, &workspace, &cfg, config_root, ®istry, shell_override, &agent_args).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> miette::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Claude { path, shell, worktree, home, agent_args } => {
|
||||
run_service("claude", path, shell, worktree, home, agent_args).await?
|
||||
}
|
||||
Commands::Codex { path, shell, worktree, home, agent_args } => {
|
||||
run_service("codex", path, shell, worktree, home, agent_args).await?
|
||||
}
|
||||
Commands::Gemini { path, shell, worktree, home, agent_args } => {
|
||||
run_service("gemini", path, shell, worktree, home, agent_args).await?
|
||||
}
|
||||
Commands::Shell { path, home } => run_service("shell", path, false, false, home, vec![]).await?,
|
||||
Commands::Build { force, services } => {
|
||||
docker::build_images(force, &services).await?;
|
||||
}
|
||||
Commands::Init => {
|
||||
let workspace = workspace::resolve_workspace(None)?;
|
||||
init::scaffold_project(&workspace)?;
|
||||
}
|
||||
Commands::Clean { yes } => {
|
||||
use miette::IntoDiagnostic;
|
||||
let workspace = std::env::current_dir().into_diagnostic()?;
|
||||
docker::clean(&workspace, yes).await.map_err(AppError::Docker)?;
|
||||
}
|
||||
Commands::Setup { action } => match action {
|
||||
SetupAction::Ssh { global, yes, refresh, bind } => {
|
||||
setup::run_ssh_setup(global, yes, refresh, bind)?;
|
||||
}
|
||||
},
|
||||
Commands::Worktree { action } => {
|
||||
let sandcage_home = dirs::home_dir()
|
||||
.map(|h| h.join(".sandcage"))
|
||||
.unwrap_or_else(|| PathBuf::from(".sandcage"));
|
||||
|
||||
match action {
|
||||
WorktreeAction::List => {
|
||||
let entries = sandcage::worktree::list_worktrees(&sandcage_home);
|
||||
if entries.is_empty() {
|
||||
eprintln!("No sandcage worktrees found.");
|
||||
} else {
|
||||
for entry in &entries {
|
||||
println!("{}", entry.path.display());
|
||||
}
|
||||
eprintln!("{} worktree(s)", entries.len());
|
||||
}
|
||||
}
|
||||
WorktreeAction::Clean { yes } => {
|
||||
let entries = sandcage::worktree::list_worktrees(&sandcage_home);
|
||||
if entries.is_empty() {
|
||||
eprintln!("No worktrees to clean.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("Worktrees to remove:");
|
||||
for entry in &entries {
|
||||
eprintln!(" {}", entry.path.display());
|
||||
}
|
||||
|
||||
if !yes {
|
||||
eprint!("Remove {} worktree(s)? [y/N] ", entries.len());
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).ok();
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
eprintln!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let results = sandcage::worktree::clean_all(&sandcage_home, false);
|
||||
for (name, result) in &results {
|
||||
match result {
|
||||
Ok(()) => eprintln!(" removed: {name}"),
|
||||
Err(e) => eprintln!(" failed: {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "bollard")]
|
||||
Commands::Acp { action } => {
|
||||
let backend = sandcage::backend::default_backend()?;
|
||||
backend.check_availability().await
|
||||
.map_err(|e| AppError::Docker(e))?;
|
||||
sandcage::acp::handle(action, backend.as_ref(), None).await
|
||||
.map_err(|e| AppError::Docker(e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miette::Diagnostic;
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
const RECIPES_TOML: &str = include_str!("../../../images/fragments/recipes.toml");
|
||||
const FRAGMENT_UV: &str = include_str!("../../../images/fragments/uv.Dockerfile");
|
||||
const FRAGMENT_NVM: &str = include_str!("../../../images/fragments/nvm.Dockerfile");
|
||||
const FRAGMENT_RUST: &str = include_str!("../../../images/fragments/rust.Dockerfile");
|
||||
|
||||
/// Where a toolchain's Dockerfile fragment comes from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FragmentSource {
|
||||
/// Compiled into the binary — built-in recipes (`include_str!`).
|
||||
Embedded(&'static str),
|
||||
/// A path on disk, relative to the project — custom recipes.
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
/// A fully-resolved toolchain recipe.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Recipe {
|
||||
pub name: String,
|
||||
pub fragment: FragmentSource,
|
||||
pub shadow: Vec<String>,
|
||||
pub shared_caches: Vec<String>,
|
||||
}
|
||||
|
||||
/// The deserializable shape of a recipe in TOML (built-in bundle or custom config).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RecipeSpec {
|
||||
pub fragment: String,
|
||||
#[serde(default)]
|
||||
pub shadow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub shared_caches: Vec<String>,
|
||||
}
|
||||
|
||||
/// Map a built-in fragment filename to its embedded content.
|
||||
fn embedded_fragment(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"uv.Dockerfile" => Some(FRAGMENT_UV),
|
||||
"nvm.Dockerfile" => Some(FRAGMENT_NVM),
|
||||
"rust.Dockerfile" => Some(FRAGMENT_RUST),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the bundled recipe TOML into resolved built-in recipes.
|
||||
/// Panics on a malformed bundle — this is compiled-in data, so a failure is a build bug.
|
||||
pub fn builtin_recipes() -> HashMap<String, Recipe> {
|
||||
let specs: HashMap<String, RecipeSpec> =
|
||||
toml::from_str(RECIPES_TOML).expect("bundled recipes.toml must parse");
|
||||
|
||||
specs
|
||||
.into_iter()
|
||||
.map(|(name, spec)| {
|
||||
let fragment = embedded_fragment(&spec.fragment).unwrap_or_else(|| {
|
||||
panic!("built-in recipe '{name}' references unknown fragment '{}'", spec.fragment)
|
||||
});
|
||||
let recipe = Recipe {
|
||||
name: name.clone(),
|
||||
fragment: FragmentSource::Embedded(fragment),
|
||||
shadow: spec.shadow,
|
||||
shared_caches: spec.shared_caches,
|
||||
};
|
||||
(name, recipe)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum RecipeError {
|
||||
#[error("toolchain '{name}' has no built-in recipe")]
|
||||
#[diagnostic(
|
||||
code(sandcage::recipe::no_builtin),
|
||||
help("Provide a [toolchains.{name}] table with a `fragment` path, or remove it.")
|
||||
)]
|
||||
NoBuiltin { name: String },
|
||||
|
||||
#[error("custom toolchain '{name}' has an invalid table: {reason}")]
|
||||
#[diagnostic(code(sandcage::recipe::invalid_spec))]
|
||||
InvalidSpec { name: String, reason: String },
|
||||
|
||||
#[error("fragment for toolchain '{name}' not found at {path}")]
|
||||
#[diagnostic(
|
||||
code(sandcage::recipe::fragment_not_found),
|
||||
help("Check the `fragment` path in [toolchains.{name}]; it is resolved relative to the project root.")
|
||||
)]
|
||||
FragmentNotFound { name: String, path: PathBuf },
|
||||
}
|
||||
|
||||
/// Resolve `[toolchains]` config into a list of recipes.
|
||||
///
|
||||
/// - `name = true` → built-in recipe (error if none exists).
|
||||
/// - `name = false` → disabled, skipped.
|
||||
/// - `[toolchains.name]` table → custom recipe, fully overriding any built-in of the same name.
|
||||
/// The `fragment` path is resolved relative to `project_root` and must exist.
|
||||
///
|
||||
/// Iteration order is sorted by name so output is deterministic (the input is a HashMap).
|
||||
pub fn resolve_recipes(
|
||||
toolchains: &HashMap<String, toml::Value>,
|
||||
project_root: &Path,
|
||||
) -> Result<Vec<Recipe>, RecipeError> {
|
||||
let builtins = builtin_recipes();
|
||||
let mut names: Vec<&String> = toolchains.keys().collect();
|
||||
names.sort();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for name in names {
|
||||
let value = &toolchains[name];
|
||||
match value {
|
||||
toml::Value::Boolean(false) => continue,
|
||||
toml::Value::Boolean(true) => {
|
||||
let recipe = builtins
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?;
|
||||
out.push(recipe);
|
||||
}
|
||||
toml::Value::Table(_) => {
|
||||
let spec: RecipeSpec = value.clone().try_into().map_err(|e| {
|
||||
RecipeError::InvalidSpec { name: name.clone(), reason: e.to_string() }
|
||||
})?;
|
||||
let frag_path = project_root.join(&spec.fragment);
|
||||
if !frag_path.exists() {
|
||||
return Err(RecipeError::FragmentNotFound {
|
||||
name: name.clone(),
|
||||
path: frag_path,
|
||||
});
|
||||
}
|
||||
out.push(Recipe {
|
||||
name: name.clone(),
|
||||
fragment: FragmentSource::Path(frag_path),
|
||||
shadow: spec.shadow,
|
||||
shared_caches: spec.shared_caches,
|
||||
});
|
||||
}
|
||||
// Strings and other scalar truthy forms: treat like `true` for back-compat
|
||||
// with the previous `match value` logic (String meant "enabled").
|
||||
toml::Value::String(_) => {
|
||||
let recipe = builtins
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?;
|
||||
out.push(recipe);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
/// The Dockerfile fragment text for this recipe.
|
||||
pub fn fragment_text(&self) -> Result<String, RecipeError> {
|
||||
match &self.fragment {
|
||||
FragmentSource::Embedded(s) => Ok((*s).to_string()),
|
||||
FragmentSource::Path(p) => std::fs::read_to_string(p).map_err(|_| {
|
||||
RecipeError::FragmentNotFound { name: self.name.clone(), path: p.clone() }
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn fragment_source_variants_construct() {
|
||||
let embedded = FragmentSource::Embedded("FROM scratch");
|
||||
let path = FragmentSource::Path(PathBuf::from(".sandcage/fragments/zig.Dockerfile"));
|
||||
assert_ne!(embedded, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_holds_fields() {
|
||||
let r = Recipe {
|
||||
name: "rust".to_string(),
|
||||
fragment: FragmentSource::Embedded("FROM rust"),
|
||||
shadow: vec!["target".to_string()],
|
||||
shared_caches: vec![".cargo/registry".to_string()],
|
||||
};
|
||||
assert_eq!(r.name, "rust");
|
||||
assert_eq!(r.shadow, vec!["target".to_string()]);
|
||||
assert_eq!(r.shared_caches, vec![".cargo/registry".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_recipes_load_uv_nvm_rust() {
|
||||
let builtins = builtin_recipes();
|
||||
assert!(builtins.contains_key("uv"), "uv built-in must exist");
|
||||
assert!(builtins.contains_key("nvm"), "nvm built-in must exist");
|
||||
assert!(builtins.contains_key("rust"), "rust built-in must exist");
|
||||
|
||||
let rust = &builtins["rust"];
|
||||
assert_eq!(rust.shadow, vec!["target".to_string()]);
|
||||
assert_eq!(rust.shared_caches, vec![".cargo/registry".to_string()]);
|
||||
// Built-ins resolve to embedded fragment content.
|
||||
match &rust.fragment {
|
||||
FragmentSource::Embedded(s) => assert!(s.contains("rustup"), "rust fragment should mention rustup"),
|
||||
other => panic!("built-in fragment must be Embedded, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::HashMap as Map;
|
||||
|
||||
fn toolchains(pairs: &[(&str, toml::Value)]) -> Map<String, toml::Value> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_enables_builtin_on_true() {
|
||||
let tc = toolchains(&[("rust", toml::Value::Boolean(true))]);
|
||||
let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves");
|
||||
assert_eq!(recipes.len(), 1);
|
||||
assert_eq!(recipes[0].name, "rust");
|
||||
assert!(matches!(recipes[0].fragment, FragmentSource::Embedded(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skips_disabled_builtin() {
|
||||
let tc = toolchains(&[("rust", toml::Value::Boolean(false))]);
|
||||
let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves");
|
||||
assert!(recipes.is_empty(), "rust = false must not produce a recipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_true_for_unknown_name_errors() {
|
||||
let tc = toolchains(&[("zig", toml::Value::Boolean(true))]);
|
||||
let err = resolve_recipes(&tc, std::path::Path::new("/proj")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, RecipeError::NoBuiltin { ref name } if name == "zig"),
|
||||
"expected NoBuiltin for zig, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_custom_table_overrides_with_existing_fragment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let frag = dir.path().join("zig.Dockerfile");
|
||||
std::fs::write(&frag, "RUN install zig\n").unwrap();
|
||||
|
||||
let mut table = toml::value::Table::new();
|
||||
table.insert("fragment".into(), toml::Value::String("zig.Dockerfile".into()));
|
||||
table.insert(
|
||||
"shadow".into(),
|
||||
toml::Value::Array(vec![toml::Value::String("zig-cache".into())]),
|
||||
);
|
||||
let tc = toolchains(&[("zig", toml::Value::Table(table))]);
|
||||
|
||||
let recipes = resolve_recipes(&tc, dir.path()).expect("resolves custom");
|
||||
assert_eq!(recipes.len(), 1);
|
||||
assert_eq!(recipes[0].name, "zig");
|
||||
assert_eq!(recipes[0].shadow, vec!["zig-cache".to_string()]);
|
||||
assert_eq!(recipes[0].fragment, FragmentSource::Path(frag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_custom_table_missing_fragment_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut table = toml::value::Table::new();
|
||||
table.insert("fragment".into(), toml::Value::String("nope.Dockerfile".into()));
|
||||
let tc = toolchains(&[("zig", toml::Value::Table(table))]);
|
||||
|
||||
let err = resolve_recipes(&tc, dir.path()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, RecipeError::FragmentNotFound { .. }),
|
||||
"expected FragmentNotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_text_reads_embedded_and_path() {
|
||||
let embedded = Recipe {
|
||||
name: "uv".into(),
|
||||
fragment: FragmentSource::Embedded("FROM uv-base"),
|
||||
shadow: vec![],
|
||||
shared_caches: vec![],
|
||||
};
|
||||
assert_eq!(embedded.fragment_text().unwrap(), "FROM uv-base");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let frag = dir.path().join("z.Dockerfile");
|
||||
std::fs::write(&frag, "RUN custom\n").unwrap();
|
||||
let custom = Recipe {
|
||||
name: "zig".into(),
|
||||
fragment: FragmentSource::Path(frag),
|
||||
shadow: vec![],
|
||||
shared_caches: vec![],
|
||||
};
|
||||
assert_eq!(custom.fragment_text().unwrap(), "RUN custom\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::ServiceDef;
|
||||
|
||||
pub const CLAUDE_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
CLAUDE_BIN="$HOME/.local/bin/claude"
|
||||
if [ ! -x "$CLAUDE_BIN" ]; then
|
||||
echo "sandcage: installing Claude Code..." >&2
|
||||
WORKDIR="$(pwd)"
|
||||
cd "$HOME"
|
||||
curl -fsSL https://claude.ai/install.sh | bash >&2
|
||||
cd "$WORKDIR"
|
||||
fi
|
||||
exec "$CLAUDE_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub const CODEX_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
CODEX_BIN="$HOME/.local/bin/codex"
|
||||
if [ ! -x "$CODEX_BIN" ]; then
|
||||
echo "sandcage: installing Codex..." >&2
|
||||
WORKDIR="$(pwd)"
|
||||
cd "$HOME"
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
||||
aarch64) target="aarch64-unknown-linux-musl" ;;
|
||||
*) echo "sandcage: unsupported arch: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/openai/codex/releases/latest/download/codex-${target}.tar.gz" \
|
||||
| tar xz -C "$HOME/.local/bin"
|
||||
mv "$HOME/.local/bin/codex-${target}" "$CODEX_BIN"
|
||||
cd "$WORKDIR"
|
||||
fi
|
||||
exec "$CODEX_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub const GEMINI_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
GEMINI_BIN="$HOME/.local/bin/gemini"
|
||||
if [ ! -x "$GEMINI_BIN" ]; then
|
||||
echo "sandcage: installing Gemini CLI..." >&2
|
||||
WORKDIR="$(pwd)"
|
||||
cd "$HOME"
|
||||
npm install -g @anthropic-ai/gemini-cli >&2
|
||||
cd "$WORKDIR"
|
||||
fi
|
||||
exec "$GEMINI_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub fn claude() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "claude".to_string(),
|
||||
description: "Claude Code agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-claude-entrypoint".to_string()),
|
||||
entrypoint_script: Some(CLAUDE_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".claude".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn codex() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "codex".to_string(),
|
||||
description: "Codex agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-codex-entrypoint".to_string()),
|
||||
entrypoint_script: Some(CODEX_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".codex".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gemini() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "gemini".to_string(),
|
||||
description: "Gemini CLI agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-gemini-entrypoint".to_string()),
|
||||
entrypoint_script: Some(GEMINI_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".gemini".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shell() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "shell".to_string(),
|
||||
description: "Interactive shell".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("/bin/zsh".to_string()),
|
||||
entrypoint_script: None,
|
||||
config_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> Vec<ServiceDef> {
|
||||
vec![claude(), codex(), gemini(), shell()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::Service;
|
||||
|
||||
#[test]
|
||||
fn all_builtins_are_enabled_by_default() {
|
||||
for svc in all() {
|
||||
assert!(svc.enabled(), "{} should be enabled by default", svc.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_builtins_have_unique_names() {
|
||||
let services = all();
|
||||
let mut names: Vec<&str> = services.iter().map(|s| s.name()).collect();
|
||||
let count = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), count, "service names must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_services_have_entrypoint_scripts() {
|
||||
for svc in [claude(), codex(), gemini()] {
|
||||
assert!(
|
||||
svc.entrypoint_script().is_some(),
|
||||
"{} should have an entrypoint script",
|
||||
svc.name()
|
||||
);
|
||||
assert!(
|
||||
svc.entrypoint_script().unwrap().starts_with("#!/bin/sh"),
|
||||
"{} entrypoint should start with shebang",
|
||||
svc.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_has_no_entrypoint_script() {
|
||||
let svc = shell();
|
||||
assert!(svc.entrypoint_script().is_none());
|
||||
assert_eq!(svc.entrypoint_name(), Some("/bin/zsh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_services_have_config_dirs() {
|
||||
assert_eq!(claude().config_dir(), Some(".claude"));
|
||||
assert_eq!(codex().config_dir(), Some(".codex"));
|
||||
assert_eq!(gemini().config_dir(), Some(".gemini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_has_no_config_dir() {
|
||||
assert_eq!(shell().config_dir(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_entrypoint_installs_from_claude_ai() {
|
||||
assert!(CLAUDE_ENTRYPOINT.contains("claude.ai/install.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_entrypoint_handles_arch_detection() {
|
||||
assert!(CODEX_ENTRYPOINT.contains("uname -m"));
|
||||
assert!(CODEX_ENTRYPOINT.contains("x86_64"));
|
||||
assert!(CODEX_ENTRYPOINT.contains("aarch64"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::registry::ServiceRegistry;
|
||||
use super::ComposeContext;
|
||||
|
||||
pub fn generate_compose(registry: &ServiceRegistry, ctx: &ComposeContext) -> String {
|
||||
let mut services = serde_yaml::Mapping::new();
|
||||
|
||||
for svc in registry.enabled() {
|
||||
let def = svc.compose_service(ctx);
|
||||
let value = serde_yaml::to_value(&def).expect("ComposeServiceDef must serialize");
|
||||
services.insert(
|
||||
serde_yaml::Value::String(svc.name().to_string()),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
let mut doc = serde_yaml::Mapping::new();
|
||||
doc.insert(
|
||||
serde_yaml::Value::String("services".to_string()),
|
||||
serde_yaml::Value::Mapping(services),
|
||||
);
|
||||
|
||||
serde_yaml::to_string(&serde_yaml::Value::Mapping(doc))
|
||||
.expect("compose doc must serialize")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::builtin;
|
||||
use crate::service::ServiceDef;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_context() -> ComposeContext {
|
||||
ComposeContext {
|
||||
uid: "1000".to_string(),
|
||||
gid: "1000".to_string(),
|
||||
workspace: PathBuf::from("/home/user/projects/my-app"),
|
||||
container_dir: "/workspace/my-app".to_string(),
|
||||
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||
image: "sandcage".to_string(),
|
||||
config_root: PathBuf::from("/home/user/projects/my-app"),
|
||||
forwarding_socket: None,
|
||||
agent_home: PathBuf::from("/home/user/.sandcage/homes/default"),
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_with(services: Vec<ServiceDef>) -> ServiceRegistry {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
for svc in services {
|
||||
reg.register(Box::new(svc));
|
||||
}
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_valid_yaml() {
|
||||
let reg = registry_with(vec![builtin::claude(), builtin::shell()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&yaml).expect("output must be valid YAML");
|
||||
assert!(parsed.as_mapping().unwrap().contains_key("services"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_only_enabled_services() {
|
||||
let mut codex = builtin::codex();
|
||||
codex.enabled = false;
|
||||
|
||||
let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
assert!(yaml.contains("claude:"), "claude should be present");
|
||||
assert!(yaml.contains("shell:"), "shell should be present");
|
||||
assert!(!yaml.contains("codex:"), "disabled codex should be absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_produces_empty_services() {
|
||||
let reg = ServiceRegistry::new();
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed.as_mapping().unwrap().get("services").unwrap();
|
||||
assert!(
|
||||
services.as_mapping().unwrap().is_empty(),
|
||||
"services should be empty mapping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_image() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage:latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_entrypoint() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage-claude-entrypoint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_working_dir() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/workspace/my-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_home_volume() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/home/user/.sandcage/homes/default:/home/agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_builtins_generate_successfully() {
|
||||
let reg = registry_with(builtin::all());
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
for name in ["claude", "codex", "gemini", "shell"] {
|
||||
assert!(
|
||||
yaml.contains(&format!("{name}:")),
|
||||
"compose should contain {name} service"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_claude_codex_shell_match_old_structure() {
|
||||
let reg = registry_with(vec![
|
||||
builtin::claude(),
|
||||
builtin::codex(),
|
||||
builtin::shell(),
|
||||
]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed["services"].as_mapping().unwrap();
|
||||
|
||||
for name in ["claude", "codex", "shell"] {
|
||||
let svc = &services[&serde_yaml::Value::String(name.to_string())];
|
||||
assert_eq!(
|
||||
svc["image"].as_str().unwrap(),
|
||||
"sandcage:latest",
|
||||
"{name}: image"
|
||||
);
|
||||
assert!(svc["tty"].as_bool().unwrap(), "{name}: tty");
|
||||
assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
pub mod builtin;
|
||||
pub mod compose;
|
||||
pub mod registry;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposeContext {
|
||||
pub uid: String,
|
||||
pub gid: String,
|
||||
pub workspace: PathBuf,
|
||||
pub container_dir: String,
|
||||
pub sandcage_home: PathBuf,
|
||||
pub image: String,
|
||||
/// Directory containing .sandcage/ (may differ from workspace in worktrees)
|
||||
pub config_root: PathBuf,
|
||||
/// Unix socket for the forwarding daemon (bind-mounted into container).
|
||||
pub forwarding_socket: Option<PathBuf>,
|
||||
/// Resolved path to the agent home directory on the host
|
||||
/// (e.g. `~/.sandcage/homes/work/`).
|
||||
pub agent_home: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ComposeServiceDef {
|
||||
pub image: String,
|
||||
pub entrypoint: Vec<String>,
|
||||
pub working_dir: String,
|
||||
pub user: String,
|
||||
pub volumes: Vec<String>,
|
||||
pub environment: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tmpfs: Vec<String>,
|
||||
pub tty: bool,
|
||||
pub stdin_open: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub trait Service: std::fmt::Debug + Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn enabled(&self) -> bool;
|
||||
fn set_enabled(&mut self, enabled: bool);
|
||||
fn entrypoint_name(&self) -> Option<&str>;
|
||||
fn entrypoint_script(&self) -> Option<&str>;
|
||||
fn config_dir(&self) -> Option<&str>;
|
||||
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceDef — standard implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub entrypoint_name: Option<String>,
|
||||
pub entrypoint_script: Option<String>,
|
||||
pub config_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Service for ServiceDef {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
fn entrypoint_name(&self) -> Option<&str> {
|
||||
self.entrypoint_name.as_deref()
|
||||
}
|
||||
|
||||
fn entrypoint_script(&self) -> Option<&str> {
|
||||
self.entrypoint_script.as_deref()
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> Option<&str> {
|
||||
self.config_dir.as_deref()
|
||||
}
|
||||
|
||||
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef {
|
||||
let entrypoint = match self.entrypoint_name.as_deref() {
|
||||
Some(name) => vec![name.to_string()],
|
||||
None => vec!["/bin/zsh".to_string()],
|
||||
};
|
||||
|
||||
ComposeServiceDef {
|
||||
image: format!("{}:latest", ctx.image),
|
||||
entrypoint,
|
||||
working_dir: ctx.container_dir.clone(),
|
||||
user: format!("{}:{}", ctx.uid, ctx.gid),
|
||||
volumes: vec![
|
||||
format!("{}:/home/agent", ctx.agent_home.display()),
|
||||
format!(
|
||||
"{}:{}",
|
||||
ctx.workspace.display(),
|
||||
ctx.container_dir
|
||||
),
|
||||
],
|
||||
environment: vec!["HOME=/home/agent".to_string()],
|
||||
tmpfs: vec![format!("{}/.sandcage", ctx.container_dir)],
|
||||
tty: true,
|
||||
stdin_open: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_context() -> ComposeContext {
|
||||
ComposeContext {
|
||||
uid: "1000".to_string(),
|
||||
gid: "1000".to_string(),
|
||||
workspace: PathBuf::from("/home/user/projects/my-app"),
|
||||
agent_home: PathBuf::from("/home/user/.sandcage/homes/default"),
|
||||
container_dir: "/workspace/my-app".to_string(),
|
||||
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||
image: "sandcage".to_string(),
|
||||
config_root: PathBuf::from("/home/user/projects/my-app"),
|
||||
forwarding_socket: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_service() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "test-agent".to_string(),
|
||||
description: "A test agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-test-entrypoint".to_string()),
|
||||
entrypoint_script: Some("#!/bin/sh\nexec echo hello".to_string()),
|
||||
config_dir: Some(".test-agent".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_def_trait_accessors() {
|
||||
let svc = agent_service();
|
||||
assert_eq!(svc.name(), "test-agent");
|
||||
assert_eq!(svc.description(), "A test agent");
|
||||
assert!(svc.enabled());
|
||||
assert_eq!(svc.entrypoint_name(), Some("sandcage-test-entrypoint"));
|
||||
assert!(svc.entrypoint_script().is_some());
|
||||
assert_eq!(svc.config_dir(), Some(".test-agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_def_set_enabled() {
|
||||
let mut svc = agent_service();
|
||||
assert!(svc.enabled());
|
||||
svc.set_enabled(false);
|
||||
assert!(!svc.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_correct_image() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.image, "sandcage:latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_uses_entrypoint_name() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["sandcage-test-entrypoint"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_shell_uses_bin_zsh_when_no_entrypoint() {
|
||||
let svc = ServiceDef {
|
||||
entrypoint_name: None,
|
||||
..agent_service()
|
||||
};
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_correct_user() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.user, "1000:1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_home_and_workspace_volumes() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.volumes.len(), 2);
|
||||
assert!(compose.volumes[0].contains("/homes/default:/home/agent"));
|
||||
assert!(compose.volumes[1].contains("/workspace/my-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_tty_and_stdin() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert!(compose.tty);
|
||||
assert!(compose.stdin_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_service_uses_explicit_entrypoint() {
|
||||
let svc = ServiceDef {
|
||||
name: "shell".to_string(),
|
||||
description: "Interactive shell".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("/bin/zsh".to_string()),
|
||||
entrypoint_script: None,
|
||||
config_dir: None,
|
||||
};
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_uses_configured_image() {
|
||||
let svc = agent_service();
|
||||
let mut ctx = test_context();
|
||||
ctx.image = "custom-img".to_string();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.image, "custom-img:latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_excludes_sandcage_dir_via_tmpfs() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.tmpfs, vec!["/workspace/my-app/.sandcage"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::Service;
|
||||
|
||||
pub struct ServiceRegistry {
|
||||
services: IndexMap<String, Box<dyn Service>>,
|
||||
}
|
||||
|
||||
impl Default for ServiceRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, service: Box<dyn Service>) {
|
||||
self.services.insert(service.name().to_string(), service);
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Service> {
|
||||
self.services.get(name).map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, name: &str) -> Option<&mut Box<dyn Service>> {
|
||||
self.services.get_mut(name)
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> impl Iterator<Item = &dyn Service> {
|
||||
self.services.values().filter(|s| s.enabled()).map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn all(&self) -> impl Iterator<Item = &dyn Service> {
|
||||
self.services.values().map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.services.keys().map(|k| k.as_str())
|
||||
}
|
||||
|
||||
pub fn enabled_names(&self) -> Vec<&str> {
|
||||
self.enabled().map(|s| s.name()).collect()
|
||||
}
|
||||
|
||||
pub fn apply_overrides(&mut self, config: &crate::config::SandcageConfig) {
|
||||
if let Some(ref overrides) = config.services {
|
||||
for (name, ovr) in overrides {
|
||||
if let Some(svc) = self.get_mut(name)
|
||||
&& let Some(enabled) = ovr.enabled
|
||||
{
|
||||
svc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServiceRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ServiceRegistry")
|
||||
.field("services", &self.services.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_default_registry(config: &crate::config::SandcageConfig) -> ServiceRegistry {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
for svc in super::builtin::all() {
|
||||
reg.register(Box::new(svc));
|
||||
}
|
||||
reg.apply_overrides(config);
|
||||
reg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::ServiceDef;
|
||||
|
||||
fn make_service(name: &str, enabled: bool) -> Box<dyn Service> {
|
||||
Box::new(ServiceDef {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} service"),
|
||||
enabled,
|
||||
entrypoint_name: Some(format!("sandcage-{name}-entrypoint")),
|
||||
entrypoint_script: Some("#!/bin/sh\nexec echo test".to_string()),
|
||||
config_dir: Some(format!(".{name}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
assert!(reg.get("claude").is_some());
|
||||
assert!(reg.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_filters_disabled_services() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
reg.register(make_service("shell", true));
|
||||
|
||||
let enabled: Vec<&str> = reg.enabled().map(|s| s.name()).collect();
|
||||
assert_eq!(enabled, vec!["claude", "shell"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_every_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
|
||||
let all: Vec<&str> = reg.all().map(|s| s.name()).collect();
|
||||
assert_eq!(all, vec!["claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_returns_all_keys() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let names: Vec<&str> = reg.names().collect();
|
||||
assert_eq!(names, vec!["claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_insertion_order() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("shell", true));
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let names: Vec<&str> = reg.names().collect();
|
||||
assert_eq!(names, vec!["shell", "claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mut_can_disable_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
reg.get_mut("claude").unwrap().set_enabled(false);
|
||||
assert!(!reg.get("claude").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_names_convenience() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
reg.register(make_service("shell", true));
|
||||
|
||||
assert_eq!(reg.enabled_names(), vec!["claude", "shell"]);
|
||||
}
|
||||
|
||||
use crate::config::{SandcageConfig, ServiceOverride};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_disables_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
reg.apply_overrides(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
assert!(!reg.get("codex").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_ignores_unknown_services() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("nonexistent".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
reg.apply_overrides(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_default_registry_has_all_builtins() {
|
||||
let config = SandcageConfig::default();
|
||||
let reg = build_default_registry(&config);
|
||||
|
||||
for name in ["claude", "codex", "gemini", "shell"] {
|
||||
assert!(
|
||||
reg.get(name).is_some(),
|
||||
"{name} should be in default registry"
|
||||
);
|
||||
assert!(
|
||||
reg.get(name).unwrap().enabled(),
|
||||
"{name} should be enabled by default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_default_registry_applies_overrides() {
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
overrides.insert("gemini".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let reg = build_default_registry(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
assert!(!reg.get("codex").unwrap().enabled());
|
||||
assert!(!reg.get("gemini").unwrap().enabled());
|
||||
assert!(reg.get("shell").unwrap().enabled());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::ShadowConfig;
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ShadowBackend {
|
||||
Volume,
|
||||
Directory,
|
||||
Tmpfs,
|
||||
}
|
||||
|
||||
impl ShadowBackend {
|
||||
fn parse(s: &str) -> ShadowBackend {
|
||||
match s {
|
||||
"directory" => ShadowBackend::Directory,
|
||||
"tmpfs" => ShadowBackend::Tmpfs,
|
||||
_ => ShadowBackend::Volume,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShadowMount {
|
||||
pub dir: String,
|
||||
pub container_path: String,
|
||||
pub backend: ShadowBackend,
|
||||
pub volume_name: Option<String>,
|
||||
pub host_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Flatten enabled recipes into `(dir, shared)` shadow pairs.
|
||||
pub fn recipe_shadow_dirs(recipes: &[Recipe]) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for r in recipes {
|
||||
for d in &r.shadow {
|
||||
out.push((d.clone(), false));
|
||||
}
|
||||
for d in &r.shared_caches {
|
||||
out.push((d.clone(), true));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve effective shadow mounts from explicit `[shadow]` config + recipe dirs.
|
||||
/// Explicit `[shadow.dirs]` overrides a recipe dir of the same name. Default backend = volume.
|
||||
/// `project_root` is the config root (where `.sandcage/` lives).
|
||||
pub fn resolve_shadow_mounts(
|
||||
shadow: Option<&ShadowConfig>,
|
||||
recipe_dirs: &[(String, bool)],
|
||||
project_root: &Path,
|
||||
container_dir: &str,
|
||||
) -> Vec<ShadowMount> {
|
||||
let default_backend = shadow
|
||||
.and_then(|s| s.backend.as_deref())
|
||||
.map(ShadowBackend::parse)
|
||||
.unwrap_or(ShadowBackend::Volume);
|
||||
|
||||
// dir → (explicit backend override, shared). BTreeMap = deterministic order.
|
||||
let mut merged: std::collections::BTreeMap<String, (Option<ShadowBackend>, bool)> =
|
||||
std::collections::BTreeMap::new();
|
||||
for (dir, shared) in recipe_dirs {
|
||||
merged.insert(dir.clone(), (None, *shared));
|
||||
}
|
||||
if let Some(cfg) = shadow {
|
||||
if let Some(dirs) = &cfg.dirs {
|
||||
for (dir, spec) in dirs {
|
||||
merged.insert(dir.clone(), (spec.backend.as_deref().map(ShadowBackend::parse), spec.shared));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
.into_iter()
|
||||
.map(|(dir, (backend_override, shared))| {
|
||||
let backend = backend_override.unwrap_or_else(|| default_backend.clone());
|
||||
let container_path = format!("{}/{}", container_dir.trim_end_matches('/'), dir);
|
||||
let (vol_name, host_path) = match backend {
|
||||
ShadowBackend::Volume => (Some(volume_name(project_root, &dir, shared)), None),
|
||||
ShadowBackend::Directory => (
|
||||
None,
|
||||
Some(project_root.join(".sandcage").join("shadow").join(normalize_dir(&dir))),
|
||||
),
|
||||
ShadowBackend::Tmpfs => (None, None),
|
||||
};
|
||||
ShadowMount { dir, container_path, backend, volume_name: vol_name, host_path }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Normalize a shadow dir name into Docker's allowed volume-name charset.
|
||||
/// Leading dots stripped; non-`[a-zA-Z0-9_]` chars become `-`.
|
||||
pub fn normalize_dir(dir: &str) -> String {
|
||||
let trimmed = dir.trim_start_matches('.');
|
||||
let mut out = String::with_capacity(trimmed.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in trimmed.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
out.push(ch);
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Human-legible project slug: basename, lowercased, sanitized.
|
||||
pub fn project_slug(project_root: &Path) -> String {
|
||||
let base = project_root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "project".to_string());
|
||||
normalize_dir(&base.to_lowercase())
|
||||
}
|
||||
|
||||
/// Short (8 hex) hash of the absolute project path — uniqueness across same-named projects.
|
||||
pub fn project_hash(project_root: &Path) -> String {
|
||||
let abs = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(abs.to_string_lossy().as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
hex::encode(&digest[..4])
|
||||
}
|
||||
|
||||
/// Docker volume name for a shadow dir.
|
||||
pub fn volume_name(project_root: &Path, dir: &str, shared: bool) -> String {
|
||||
let d = normalize_dir(dir);
|
||||
if shared {
|
||||
format!("sandcage-shared-{d}")
|
||||
} else {
|
||||
format!("sandcage-{}-{}-{}", project_slug(project_root), project_hash(project_root), d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind / `-v` strings for `volume` (named) and `directory` (host bind) mounts.
|
||||
/// A `name:path` source with no leading `/` is treated by Docker as a named volume
|
||||
/// (auto-created); a host path is a bind. Tmpfs mounts are excluded.
|
||||
pub fn shadow_bind_strings(mounts: &[ShadowMount]) -> Vec<String> {
|
||||
mounts
|
||||
.iter()
|
||||
.filter_map(|m| match m.backend {
|
||||
ShadowBackend::Volume => {
|
||||
Some(format!("{}:{}", m.volume_name.as_deref().unwrap(), m.container_path))
|
||||
}
|
||||
ShadowBackend::Directory => {
|
||||
Some(format!("{}:{}:rw", m.host_path.as_ref().unwrap().display(), m.container_path))
|
||||
}
|
||||
ShadowBackend::Tmpfs => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Container paths for `tmpfs`-backend mounts.
|
||||
pub fn shadow_tmpfs_paths(mounts: &[ShadowMount]) -> Vec<String> {
|
||||
mounts
|
||||
.iter()
|
||||
.filter(|m| matches!(m.backend, ShadowBackend::Tmpfs))
|
||||
.map(|m| m.container_path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{ShadowConfig, ShadowDir};
|
||||
use crate::recipe::{Recipe, FragmentSource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn shadow_cfg(backend: Option<&str>, dirs: &[(&str, Option<&str>, bool)]) -> ShadowConfig {
|
||||
let mut map = HashMap::new();
|
||||
for (name, b, shared) in dirs {
|
||||
map.insert(name.to_string(), ShadowDir { backend: b.map(|s| s.to_string()), shared: *shared });
|
||||
}
|
||||
ShadowConfig { backend: backend.map(|s| s.to_string()), dirs: if map.is_empty() { None } else { Some(map) } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_default_backend_volume() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let cfg = shadow_cfg(None, &[("target", None, false)]);
|
||||
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert_eq!(mounts[0].container_path, "/workspace/app/target");
|
||||
assert!(matches!(mounts[0].backend, ShadowBackend::Volume));
|
||||
assert_eq!(mounts[0].volume_name.as_deref().unwrap(), volume_name(root, "target", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_per_dir_directory_backend() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let cfg = shadow_cfg(Some("volume"), &[(".venv", Some("directory"), false)]);
|
||||
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
|
||||
assert!(matches!(mounts[0].backend, ShadowBackend::Directory));
|
||||
assert_eq!(
|
||||
mounts[0].host_path.as_deref().unwrap(),
|
||||
Path::new("/home/u/projects/app/.sandcage/shadow/venv")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_tmpfs_backend() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let cfg = shadow_cfg(Some("tmpfs"), &[("target", None, false)]);
|
||||
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
|
||||
assert!(matches!(mounts[0].backend, ShadowBackend::Tmpfs));
|
||||
assert!(mounts[0].volume_name.is_none() && mounts[0].host_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_dir_uses_shared_volume_name() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let cfg = shadow_cfg(None, &[(".cargo/registry", None, true)]);
|
||||
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
|
||||
assert_eq!(mounts[0].volume_name.as_deref().unwrap(), "sandcage-shared-cargo-registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_dirs_flatten_and_resolve() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let recipes = vec![Recipe {
|
||||
name: "rust".into(),
|
||||
fragment: FragmentSource::Embedded("FROM rust"),
|
||||
shadow: vec!["target".into()],
|
||||
shared_caches: vec![".cargo/registry".into()],
|
||||
}];
|
||||
let pairs = recipe_shadow_dirs(&recipes);
|
||||
assert!(pairs.contains(&("target".to_string(), false)));
|
||||
assert!(pairs.contains(&(".cargo/registry".to_string(), true)));
|
||||
let mounts = resolve_shadow_mounts(None, &pairs, root, "/workspace/app");
|
||||
assert_eq!(mounts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_overrides_recipe_dir_backend() {
|
||||
let root = Path::new("/home/u/projects/app");
|
||||
let recipe_dirs = vec![("target".to_string(), false)];
|
||||
let cfg = shadow_cfg(None, &[("target", Some("directory"), false)]);
|
||||
let mounts = resolve_shadow_mounts(Some(&cfg), &recipe_dirs, root, "/workspace/app");
|
||||
assert_eq!(mounts.len(), 1, "same dir name de-duplicated");
|
||||
assert!(matches!(mounts[0].backend, ShadowBackend::Directory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_no_recipes_yields_empty() {
|
||||
let mounts = resolve_shadow_mounts(None, &[], Path::new("/x"), "/workspace/x");
|
||||
assert!(mounts.is_empty());
|
||||
}
|
||||
|
||||
fn sample_mounts() -> Vec<ShadowMount> {
|
||||
vec![
|
||||
ShadowMount { dir: "target".into(), container_path: "/workspace/app/target".into(),
|
||||
backend: ShadowBackend::Volume, volume_name: Some("sandcage-app-dead-target".into()), host_path: None },
|
||||
ShadowMount { dir: ".venv".into(), container_path: "/workspace/app/.venv".into(),
|
||||
backend: ShadowBackend::Directory, volume_name: None,
|
||||
host_path: Some(Path::new("/home/u/projects/app/.sandcage/shadow/venv").to_path_buf()) },
|
||||
ShadowMount { dir: "cache".into(), container_path: "/workspace/app/cache".into(),
|
||||
backend: ShadowBackend::Tmpfs, volume_name: None, host_path: None },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_strings_cover_volume_and_directory_only() {
|
||||
let binds = shadow_bind_strings(&sample_mounts());
|
||||
assert_eq!(binds.len(), 2);
|
||||
assert!(binds.contains(&"sandcage-app-dead-target:/workspace/app/target".to_string()));
|
||||
assert!(binds.contains(&"/home/u/projects/app/.sandcage/shadow/venv:/workspace/app/.venv:rw".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmpfs_paths_cover_tmpfs_only() {
|
||||
let paths = shadow_tmpfs_paths(&sample_mounts());
|
||||
assert_eq!(paths, vec!["/workspace/app/cache".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_dir_sanitizes_path_separators_and_dots() {
|
||||
assert_eq!(normalize_dir(".venv"), "venv");
|
||||
assert_eq!(normalize_dir("target"), "target");
|
||||
assert_eq!(normalize_dir(".cargo/registry"), "cargo-registry");
|
||||
assert_eq!(normalize_dir("node_modules"), "node_modules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_slug_is_basename_sanitized() {
|
||||
assert_eq!(project_slug(Path::new("/home/u/projects/My-App")), "my-app");
|
||||
assert_eq!(project_slug(Path::new("/home/u/projects/api.service")), "api-service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_hash_is_stable_and_path_specific() {
|
||||
let a = project_hash(Path::new("/home/u/projects/api"));
|
||||
let b = project_hash(Path::new("/home/u/projects/api"));
|
||||
let c = project_hash(Path::new("/home/u/other/api"));
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(a.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_name_per_project_and_shared() {
|
||||
let root = Path::new("/home/u/projects/my-app");
|
||||
let per = volume_name(root, "target", false);
|
||||
assert!(per.starts_with("sandcage-my-app-"), "got {per}");
|
||||
assert!(per.ends_with("-target"), "got {per}");
|
||||
assert_eq!(volume_name(root, ".cargo/registry", true), "sandcage-shared-cargo-registry");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::ToolConfig;
|
||||
|
||||
/// Container directory baked onto the front of PATH in the base image.
|
||||
pub const SPOOF_CONTAINER_DIR: &str = "/usr/local/sandcage/spoof";
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum SpoofError {
|
||||
#[error("Failed to write spoof script {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::spoof::write_failed))]
|
||||
WriteFailed(std::path::PathBuf, #[source] std::io::Error),
|
||||
}
|
||||
|
||||
/// Generate spoof / host-stub scripts for the given tools into `dir`.
|
||||
/// Returns the number of scripts written. `in-cage` tools produce nothing.
|
||||
pub fn generate_spoof_scripts(
|
||||
tools: &HashMap<String, ToolConfig>,
|
||||
dir: &Path,
|
||||
) -> Result<usize, SpoofError> {
|
||||
let mut written = 0;
|
||||
for (name, cfg) in tools {
|
||||
let mode = cfg.mode.as_deref().map(ToolMode::parse).unwrap_or(ToolMode::InCage);
|
||||
let body = match mode {
|
||||
ToolMode::InCage => continue,
|
||||
ToolMode::Spoof => {
|
||||
let message = cfg.message.clone().unwrap_or_else(|| {
|
||||
format!("`{name}` is intercepted by sandcage for this project.")
|
||||
});
|
||||
spoof_script(&message)
|
||||
}
|
||||
ToolMode::Host => host_forwarding_script(name),
|
||||
};
|
||||
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, body).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?;
|
||||
set_executable(&path).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?;
|
||||
written += 1;
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_executable(_path: &Path) -> std::io::Result<()> {
|
||||
Ok(()) // execute bit is meaningless on non-unix hosts; the container is Linux
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolMode {
|
||||
InCage,
|
||||
Spoof,
|
||||
Host,
|
||||
}
|
||||
|
||||
impl ToolMode {
|
||||
pub fn parse(s: &str) -> ToolMode {
|
||||
match s {
|
||||
"spoof" => ToolMode::Spoof,
|
||||
"host" => ToolMode::Host,
|
||||
_ => ToolMode::InCage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spoof script: prints `message` to stderr and exits non-zero (never 127).
|
||||
/// The message is embedded in a single-quoted heredoc so backticks, `$`, and
|
||||
/// quotes in it stay literal and are never executed by the shell.
|
||||
pub fn spoof_script(message: &str) -> String {
|
||||
format!(
|
||||
"#!/usr/bin/env bash\ncat >&2 <<'SANDCAGE_SPOOF_EOF'\n{message}\nSANDCAGE_SPOOF_EOF\nexit 1\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// A `host`-mode script that forwards execution to the thumper relay.
|
||||
pub fn host_forwarding_script(tool: &str) -> String {
|
||||
format!("#!/usr/bin/env bash\nexec thumper {tool} \"$@\"\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ToolConfig;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn tool(mode: &str, message: Option<&str>) -> ToolConfig {
|
||||
ToolConfig {
|
||||
mode: Some(mode.to_string()),
|
||||
message: message.map(|s| s.to_string()),
|
||||
allow: vec![],
|
||||
deny: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_writes_only_spoof_and_host_scripts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tools = HashMap::new();
|
||||
tools.insert("cargo".to_string(), tool("spoof", Some("Use `just build`.")));
|
||||
tools.insert("docker".to_string(), tool("host", None));
|
||||
tools.insert("uv".to_string(), tool("in-cage", None));
|
||||
|
||||
let count = generate_spoof_scripts(&tools, dir.path()).expect("generate");
|
||||
assert_eq!(count, 2, "spoof + host produce scripts; in-cage does not");
|
||||
|
||||
let cargo = dir.path().join("cargo");
|
||||
assert!(cargo.exists(), "cargo spoof script written");
|
||||
let body = std::fs::read_to_string(&cargo).unwrap();
|
||||
assert!(body.contains("Use `just build`."));
|
||||
assert!(!dir.path().join("uv").exists(), "in-cage tool has no script");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&cargo).unwrap().permissions().mode();
|
||||
assert!(mode & 0o111 != 0, "script should be executable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_uses_default_message_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tools = HashMap::new();
|
||||
tools.insert("npm".to_string(), tool("spoof", None));
|
||||
generate_spoof_scripts(&tools, dir.path()).expect("generate");
|
||||
let body = std::fs::read_to_string(dir.path().join("npm")).unwrap();
|
||||
assert!(body.contains("npm"), "default message mentions the tool name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_maps_strings() {
|
||||
assert_eq!(ToolMode::parse("in-cage"), ToolMode::InCage);
|
||||
assert_eq!(ToolMode::parse("spoof"), ToolMode::Spoof);
|
||||
assert_eq!(ToolMode::parse("host"), ToolMode::Host);
|
||||
// unknown defaults to in-cage (least surprising: tool runs normally)
|
||||
assert_eq!(ToolMode::parse("wat"), ToolMode::InCage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoof_script_embeds_message_safely_and_is_not_127() {
|
||||
let msg = "Use `just build` to compile this $PROJECT.";
|
||||
let script = spoof_script(msg);
|
||||
assert!(script.starts_with("#!/usr/bin/env bash\n"));
|
||||
assert!(script.contains(msg), "message embedded verbatim");
|
||||
// single-quoted heredoc → backticks and $ stay literal (not executed)
|
||||
assert!(script.contains("<<'SANDCAGE_SPOOF_EOF'"));
|
||||
assert!(script.contains("exit 1"));
|
||||
assert!(!script.contains("exit 127"), "must never imply command-not-found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_forwarding_script_invokes_thumper() {
|
||||
let script = host_forwarding_script("docker");
|
||||
assert!(script.starts_with("#!/usr/bin/env bash\n"));
|
||||
assert!(script.contains("thumper"), "should invoke thumper");
|
||||
assert!(script.contains("docker"), "should pass the tool name");
|
||||
assert!(script.contains("\"$@\""), "should forward all args");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A parsed SSH config `Host` block.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SshHostBlock {
|
||||
/// The patterns listed on the `Host` line (e.g. `["github.com", "gh"]`).
|
||||
pub aliases: Vec<String>,
|
||||
/// Key-value directives in the order they appear in the config.
|
||||
pub directives: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SshHostBlock {
|
||||
/// Return the value of the first directive matching `key` (case-insensitive).
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
let key_lower = key.to_lowercase();
|
||||
self.directives
|
||||
.iter()
|
||||
.find(|(k, _)| k.to_lowercase() == key_lower)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Return the `User` directive value, if present.
|
||||
pub fn user(&self) -> Option<&str> {
|
||||
self.get("User")
|
||||
}
|
||||
|
||||
/// Return the `IdentityFile` directive value, if present.
|
||||
pub fn identity_file(&self) -> Option<&str> {
|
||||
self.get("IdentityFile")
|
||||
}
|
||||
|
||||
/// Return the `HostName` directive value, if present.
|
||||
pub fn hostname(&self) -> Option<&str> {
|
||||
self.get("HostName")
|
||||
}
|
||||
|
||||
/// Returns `true` if this block's `User` is `git` (common for GitHub/GitLab host entries).
|
||||
pub fn is_git_user(&self) -> bool {
|
||||
self.user().map(|u| u == "git").unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an SSH `IdentityFile` path to an absolute [`PathBuf`].
|
||||
///
|
||||
/// - `~/...` paths expand `~` via [`dirs::home_dir`], falling back to `ssh_dir.parent()`.
|
||||
/// - Relative paths are joined onto `ssh_dir`.
|
||||
/// - Absolute paths are returned as-is.
|
||||
pub fn resolve_identity_file(identity_file: &str, ssh_dir: &Path) -> PathBuf {
|
||||
if let Some(suffix) = identity_file.strip_prefix("~/") {
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_else(|| ssh_dir.parent().unwrap_or(ssh_dir).to_path_buf());
|
||||
home.join(suffix)
|
||||
} else if Path::new(identity_file).is_absolute() {
|
||||
PathBuf::from(identity_file)
|
||||
} else {
|
||||
ssh_dir.join(identity_file)
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a valid SSH config file string from a slice of [`SshHostBlock`]s.
|
||||
///
|
||||
/// Blocks are separated by blank lines; directives are indented with 4 spaces.
|
||||
pub fn synthesize_ssh_config(blocks: &[SshHostBlock]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, block) in blocks.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("Host ");
|
||||
out.push_str(&block.aliases.join(" "));
|
||||
out.push('\n');
|
||||
for (key, value) in &block.directives {
|
||||
out.push_str(" ");
|
||||
out.push_str(key);
|
||||
out.push(' ');
|
||||
out.push_str(value);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse SSH config text into a list of [`SshHostBlock`]s, supporting `Include` directives.
|
||||
///
|
||||
/// When an `Include <path>` line is encountered:
|
||||
/// - The current block is flushed.
|
||||
/// - `read_include(path)` is called; if it returns `Some(content)`, that content is parsed
|
||||
/// recursively and its blocks are appended.
|
||||
/// - Parsing of the main content then continues.
|
||||
///
|
||||
/// Pass `&|_| None` to disable Include handling (identical to [`parse_ssh_config`]).
|
||||
pub fn parse_ssh_config_with_includes(
|
||||
content: &str,
|
||||
read_include: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<SshHostBlock> {
|
||||
let mut blocks: Vec<SshHostBlock> = Vec::new();
|
||||
// None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip)
|
||||
let mut current_aliases: Option<Vec<String>> = None;
|
||||
let mut current_directives: Vec<(String, String)> = Vec::new();
|
||||
let mut in_host_block = false;
|
||||
|
||||
let flush = |blocks: &mut Vec<SshHostBlock>,
|
||||
aliases: &mut Option<Vec<String>>,
|
||||
directives: &mut Vec<(String, String)>,
|
||||
in_host: &mut bool| {
|
||||
if *in_host {
|
||||
if let Some(a) = aliases.take() {
|
||||
blocks.push(SshHostBlock {
|
||||
aliases: a,
|
||||
directives: std::mem::take(directives),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
aliases.take();
|
||||
directives.clear();
|
||||
}
|
||||
*in_host = false;
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip blank lines and comments
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split keyword from the rest on first whitespace or '='
|
||||
let (keyword, rest) = split_keyword(trimmed);
|
||||
|
||||
match keyword.to_lowercase().as_str() {
|
||||
"host" => {
|
||||
// Flush previous block
|
||||
flush(
|
||||
&mut blocks,
|
||||
&mut current_aliases,
|
||||
&mut current_directives,
|
||||
&mut in_host_block,
|
||||
);
|
||||
let aliases: Vec<String> = rest
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
current_aliases = Some(aliases);
|
||||
in_host_block = true;
|
||||
}
|
||||
"match" => {
|
||||
// Flush previous block, then enter skip mode
|
||||
flush(
|
||||
&mut blocks,
|
||||
&mut current_aliases,
|
||||
&mut current_directives,
|
||||
&mut in_host_block,
|
||||
);
|
||||
current_aliases = Some(Vec::new()); // placeholder so flush knows there's a block
|
||||
in_host_block = false;
|
||||
}
|
||||
"include" => {
|
||||
// Flush the current block before processing the include
|
||||
flush(
|
||||
&mut blocks,
|
||||
&mut current_aliases,
|
||||
&mut current_directives,
|
||||
&mut in_host_block,
|
||||
);
|
||||
if let Some(included_content) = read_include(rest.trim()) {
|
||||
let included_blocks =
|
||||
parse_ssh_config_with_includes(&included_content, read_include);
|
||||
blocks.extend(included_blocks);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if in_host_block && current_aliases.is_some() {
|
||||
current_directives.push((keyword.to_string(), rest.to_string()));
|
||||
}
|
||||
// In Match block or before any block → ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last block
|
||||
flush(
|
||||
&mut blocks,
|
||||
&mut current_aliases,
|
||||
&mut current_directives,
|
||||
&mut in_host_block,
|
||||
);
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Parse SSH config text into a list of [`SshHostBlock`]s.
|
||||
///
|
||||
/// - Only `Host` blocks are returned; `Match` blocks are skipped entirely.
|
||||
/// - Comments (`#`) and blank lines are ignored.
|
||||
/// - Directive lines are split on the first run of whitespace or `=`.
|
||||
/// - Multiple aliases on a single `Host` line are all captured.
|
||||
/// - `Include` directives are ignored (use [`parse_ssh_config_with_includes`] to handle them).
|
||||
pub fn parse_ssh_config(content: &str) -> Vec<SshHostBlock> {
|
||||
parse_ssh_config_with_includes(content, &|_| None)
|
||||
}
|
||||
|
||||
/// Split a trimmed SSH config line into (keyword, value).
|
||||
///
|
||||
/// The separator is the first `=` (with optional surrounding whitespace) or the first
|
||||
/// run of whitespace — whichever comes first.
|
||||
fn split_keyword(line: &str) -> (String, String) {
|
||||
// Find position of first '=' or first whitespace character
|
||||
let eq_pos = line.find('=');
|
||||
let ws_pos = line.find(|c: char| c.is_ascii_whitespace());
|
||||
|
||||
let sep_pos = match (eq_pos, ws_pos) {
|
||||
(Some(e), Some(w)) => Some(e.min(w)),
|
||||
(Some(e), None) => Some(e),
|
||||
(None, Some(w)) => Some(w),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
match sep_pos {
|
||||
None => (line.to_string(), String::new()),
|
||||
Some(pos) => {
|
||||
let keyword = line[..pos].to_string();
|
||||
// Skip separator characters (whitespace and '=')
|
||||
let rest = line[pos..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace());
|
||||
(keyword, rest.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_single_host_block() {
|
||||
let config = "\
|
||||
Host github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
HostName github.com
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let b = &blocks[0];
|
||||
assert_eq!(b.aliases, vec!["github.com"]);
|
||||
assert_eq!(b.user(), Some("git"));
|
||||
assert_eq!(b.identity_file(), Some("~/.ssh/id_ed25519"));
|
||||
assert_eq!(b.hostname(), Some("github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_blocks_filter_git_user() {
|
||||
let config = "\
|
||||
Host work-gitlab
|
||||
HostName gitlab.mycompany.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_work
|
||||
|
||||
Host personal
|
||||
HostName github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_personal
|
||||
|
||||
Host bastion
|
||||
HostName bastion.mycompany.com
|
||||
User admin
|
||||
IdentityFile ~/.ssh/id_bastion
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 3);
|
||||
|
||||
let git_blocks: Vec<_> = blocks.iter().filter(|b| b.is_git_user()).collect();
|
||||
assert_eq!(git_blocks.len(), 2);
|
||||
let git_aliases: Vec<&str> = git_blocks.iter().map(|b| b.aliases[0].as_str()).collect();
|
||||
assert!(git_aliases.contains(&"work-gitlab"));
|
||||
assert!(git_aliases.contains(&"personal"));
|
||||
|
||||
let non_git: Vec<_> = blocks.iter().filter(|b| !b.is_git_user()).collect();
|
||||
assert_eq!(non_git.len(), 1);
|
||||
assert_eq!(non_git[0].aliases[0], "bastion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_with_multiple_aliases() {
|
||||
let config = "\
|
||||
Host github.com gh
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_github
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].aliases, vec!["github.com", "gh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_match_blocks() {
|
||||
let config = "\
|
||||
Host first
|
||||
User alice
|
||||
|
||||
Match host internal.example.com
|
||||
ProxyJump bastion
|
||||
|
||||
Host second
|
||||
User bob
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0].aliases, vec!["first"]);
|
||||
assert_eq!(blocks[0].user(), Some("alice"));
|
||||
assert_eq!(blocks[1].aliases, vec!["second"]);
|
||||
assert_eq!(blocks[1].user(), Some("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_tabs_and_varied_indentation() {
|
||||
let config = "Host tabbed\n\tUser git\n\tIdentityFile\t~/.ssh/id_tab\n";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].user(), Some("git"));
|
||||
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_comments_and_blank_lines() {
|
||||
let config = "\
|
||||
# Global settings comment
|
||||
|
||||
# Host section
|
||||
Host example.com
|
||||
# inline comment
|
||||
User git
|
||||
|
||||
IdentityFile ~/.ssh/id_example
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].aliases, vec!["example.com"]);
|
||||
assert_eq!(blocks[0].user(), Some("git"));
|
||||
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_example"));
|
||||
// The inline comment line should not appear as a directive
|
||||
assert!(!blocks[0].directives.iter().any(|(k, _)| k.starts_with('#')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config() {
|
||||
let blocks = parse_ssh_config("");
|
||||
assert!(blocks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_only() {
|
||||
let config = "\
|
||||
# This is a comment
|
||||
# Another comment
|
||||
|
||||
# Yet another
|
||||
";
|
||||
let blocks = parse_ssh_config(config);
|
||||
assert!(blocks.is_empty());
|
||||
}
|
||||
|
||||
// --- Task 2: identity file resolution ---
|
||||
|
||||
#[test]
|
||||
fn resolve_identity_file_tilde() {
|
||||
let ssh_dir = Path::new("/home/user/.ssh");
|
||||
let path = resolve_identity_file("~/.ssh/id_ed25519", ssh_dir);
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(path, home.join(".ssh/id_ed25519"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_identity_file_relative() {
|
||||
let path = resolve_identity_file("id_ed25519", Path::new("/home/user/.ssh"));
|
||||
assert_eq!(path, PathBuf::from("/home/user/.ssh/id_ed25519"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_identity_file_absolute() {
|
||||
let path = resolve_identity_file("/etc/ssh/key", Path::new("/home/user/.ssh"));
|
||||
assert_eq!(path, PathBuf::from("/etc/ssh/key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_config_from_blocks() {
|
||||
let blocks = vec![
|
||||
SshHostBlock {
|
||||
aliases: vec!["github.com".into()],
|
||||
directives: vec![
|
||||
("User".into(), "git".into()),
|
||||
("IdentityFile".into(), "~/.ssh/id_ed25519".into()),
|
||||
("HostName".into(), "github.com".into()),
|
||||
],
|
||||
},
|
||||
SshHostBlock {
|
||||
aliases: vec!["gitea".into()],
|
||||
directives: vec![
|
||||
("User".into(), "git".into()),
|
||||
("HostName".into(), "gitea.example.com".into()),
|
||||
("IdentityFile".into(), "~/.ssh/work_gitea".into()),
|
||||
("Port".into(), "2222".into()),
|
||||
],
|
||||
},
|
||||
];
|
||||
let config = synthesize_ssh_config(&blocks);
|
||||
let expected = "\
|
||||
Host github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
HostName github.com
|
||||
|
||||
Host gitea
|
||||
User git
|
||||
HostName gitea.example.com
|
||||
IdentityFile ~/.ssh/work_gitea
|
||||
Port 2222
|
||||
";
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
// --- Task 3: Include directive support ---
|
||||
|
||||
#[test]
|
||||
fn parse_with_include_callback() {
|
||||
let main_config = "\
|
||||
Include extra_config
|
||||
|
||||
Host github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
";
|
||||
let extra_config = "\
|
||||
Host gitea.local
|
||||
User git
|
||||
IdentityFile ~/.ssh/gitea_key
|
||||
";
|
||||
let blocks = parse_ssh_config_with_includes(main_config, &|path: &str| {
|
||||
if path == "extra_config" {
|
||||
Some(extra_config.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0].aliases, vec!["gitea.local"]);
|
||||
assert_eq!(blocks[1].aliases, vec!["github.com"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
pub fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
let mut pos = 0;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match text[pos..].find(part) {
|
||||
Some(found) => {
|
||||
if i == 0 && found != 0 && !pattern.starts_with('*') {
|
||||
return false;
|
||||
}
|
||||
pos += found + part.len();
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
|
||||
if !pattern.ends_with('*') && pos != text.len() {
|
||||
if let Some(last) = parts.last() {
|
||||
if !last.is_empty() {
|
||||
return text.ends_with(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn check_patterns(allow: &[String], deny: &[String], args_joined: &str) -> Result<(), String> {
|
||||
for pattern in deny {
|
||||
if glob_match(pattern, args_joined) {
|
||||
return Err(format!("denied by pattern: {pattern}"));
|
||||
}
|
||||
}
|
||||
|
||||
if !allow.is_empty() {
|
||||
let allowed = allow.iter().any(|p| glob_match(p, args_joined));
|
||||
if !allowed {
|
||||
return Err("not matched by any allow pattern".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glob_matches_wildcard_prefix() {
|
||||
assert!(glob_match("* prune *", "docker system prune -af"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matches_wildcard_suffix() {
|
||||
assert!(glob_match("compose up *", "compose up -d"));
|
||||
assert!(!glob_match("compose up *", "compose down"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matches_exact() {
|
||||
assert!(glob_match("compose down", "compose down"));
|
||||
assert!(!glob_match("compose down", "compose up"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allow_deny_with_empty_lists() {
|
||||
assert!(check_patterns(&[], &[], "anything goes").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_deny_blocks_matching() {
|
||||
let deny = vec!["* prune *".to_string(), "* rm -f *".to_string()];
|
||||
assert!(check_patterns(&[], &deny, "docker system prune -af").is_err());
|
||||
assert!(check_patterns(&[], &deny, "docker rm -f abc").is_err());
|
||||
assert!(check_patterns(&[], &deny, "docker ps").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allow_is_fail_closed() {
|
||||
let allow = vec!["compose up *".to_string(), "compose down".to_string()];
|
||||
assert!(check_patterns(&allow, &[], "compose up -d").is_ok());
|
||||
assert!(check_patterns(&allow, &[], "compose down").is_ok());
|
||||
assert!(check_patterns(&allow, &[], "compose rm").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_overrides_allow() {
|
||||
let allow = vec!["compose *".to_string()];
|
||||
let deny = vec!["compose rm *".to_string()];
|
||||
assert!(check_patterns(&allow, &deny, "compose up -d").is_ok());
|
||||
assert!(check_patterns(&allow, &deny, "compose rm -f").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum WorkspaceError {
|
||||
#[error("Failed to resolve absolute path for {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::workspace::canonicalize_failed))]
|
||||
CanonicalizeFailed(PathBuf, #[source] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, WorkspaceError>;
|
||||
|
||||
pub fn resolve_workspace(path: Option<&Path>) -> Result<PathBuf> {
|
||||
// Determine starting path
|
||||
let raw = match path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => std::env::current_dir()
|
||||
.map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?,
|
||||
};
|
||||
|
||||
// Resolve to absolute, canonical path (follows symlinks, checks existence)
|
||||
let absolute = raw
|
||||
.canonicalize()
|
||||
.map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?;
|
||||
|
||||
// Ask git for the worktree root; silently fall back if not a git repo
|
||||
let git_root = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(&absolute)
|
||||
.output();
|
||||
|
||||
match git_root {
|
||||
Ok(output) if output.status.success() => {
|
||||
let root = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(PathBuf::from(root))
|
||||
}
|
||||
// Not a git repo, or git not installed — return the absolute path as-is
|
||||
_ => Ok(absolute),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a temporary directory and initialise it as a git repository.
|
||||
/// Returns the `TempDir` guard (keep it alive for the test) and the path.
|
||||
fn make_git_repo() -> (TempDir, PathBuf) {
|
||||
let dir = TempDir::new().expect("create tempdir");
|
||||
let path = dir.path().to_path_buf();
|
||||
|
||||
// git init
|
||||
let status = Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&path)
|
||||
.status()
|
||||
.expect("run git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_repo_root_when_inside_git_repo() {
|
||||
let (_guard, repo_root) = make_git_repo();
|
||||
|
||||
// Create a nested directory inside the repo
|
||||
let nested = repo_root.join("a").join("b");
|
||||
std::fs::create_dir_all(&nested).expect("create nested dirs");
|
||||
|
||||
// resolve_workspace from the nested dir should return the repo root
|
||||
let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace");
|
||||
assert_eq!(
|
||||
resolved.canonicalize().unwrap(),
|
||||
repo_root.canonicalize().unwrap(),
|
||||
"expected repo root, got {resolved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_directory_itself_when_not_in_git_repo() {
|
||||
// Create a temp dir that is NOT a git repo (no git init)
|
||||
let dir = TempDir::new().expect("create tempdir");
|
||||
|
||||
// Make sure there is no ancestor git repo that would pollute the result.
|
||||
// tempfile creates dirs under the system temp folder, which is typically
|
||||
// outside any user repo, so this should be safe.
|
||||
let path = dir.path().to_path_buf();
|
||||
let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace");
|
||||
|
||||
assert_eq!(
|
||||
resolved.canonicalize().unwrap(),
|
||||
path.canonicalize().unwrap(),
|
||||
"expected the directory itself, got {resolved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_path_uses_current_directory() {
|
||||
// When None is passed the function should not error; it should return
|
||||
// something (either the cwd or its git root).
|
||||
let result = resolve_workspace(None);
|
||||
assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}");
|
||||
let resolved = result.unwrap();
|
||||
assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum WorktreeError {
|
||||
#[error("not a git repository: {0}")]
|
||||
#[diagnostic(code(sandcage::worktree::not_git))]
|
||||
NotGitRepo(PathBuf),
|
||||
|
||||
#[error("git worktree add failed: {0}")]
|
||||
#[diagnostic(code(sandcage::worktree::create_failed))]
|
||||
CreateFailed(String),
|
||||
|
||||
#[error("git worktree remove failed: {0}")]
|
||||
#[diagnostic(code(sandcage::worktree::remove_failed))]
|
||||
RemoveFailed(String),
|
||||
}
|
||||
|
||||
pub fn worktree_dir(sandcage_home: &Path, project_slug: &str, timestamp: &str) -> PathBuf {
|
||||
sandcage_home
|
||||
.join("worktrees")
|
||||
.join(format!("{project_slug}-{timestamp}"))
|
||||
}
|
||||
|
||||
pub fn project_slug(project_path: &Path) -> String {
|
||||
project_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "project".to_string())
|
||||
}
|
||||
|
||||
pub fn generate_timestamp() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let d = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}", d.as_secs())
|
||||
}
|
||||
|
||||
pub fn create_worktree(
|
||||
repo_root: &Path,
|
||||
target_path: &Path,
|
||||
branch: Option<&str>,
|
||||
) -> Result<(), WorktreeError> {
|
||||
let check = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.map_err(|_| WorktreeError::NotGitRepo(repo_root.to_path_buf()))?;
|
||||
|
||||
if !check.status.success() {
|
||||
return Err(WorktreeError::NotGitRepo(repo_root.to_path_buf()));
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(["worktree", "add"]);
|
||||
|
||||
if let Some(b) = branch {
|
||||
cmd.args([&target_path.to_string_lossy().to_string(), b]);
|
||||
} else {
|
||||
cmd.args(["--detach", &target_path.to_string_lossy().to_string()]);
|
||||
}
|
||||
|
||||
cmd.current_dir(repo_root);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::CreateFailed(format!("failed to run git: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::CreateFailed(stderr.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorktreeEntry {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn list_worktrees(sandcage_home: &Path) -> Vec<WorktreeEntry> {
|
||||
let wt_dir = sandcage_home.join("worktrees");
|
||||
if !wt_dir.exists() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
if let Ok(read_dir) = std::fs::read_dir(&wt_dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
entries.push(WorktreeEntry {
|
||||
name,
|
||||
path: entry.path(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn remove_worktree(path: &Path) -> Result<(), WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", &path.to_string_lossy()])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(path).map_err(|e| {
|
||||
WorktreeError::RemoveFailed(format!("failed to remove {}: {e}", path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clean_all(sandcage_home: &Path, dry_run: bool) -> Vec<(String, Result<(), WorktreeError>)> {
|
||||
let entries = list_worktrees(sandcage_home);
|
||||
let mut results = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
if dry_run {
|
||||
results.push((entry.name, Ok(())));
|
||||
} else {
|
||||
let result = remove_worktree(&entry.path);
|
||||
results.push((entry.name, result));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worktree_dir_path_format() {
|
||||
let home = PathBuf::from("/home/user/.sandcage");
|
||||
let dir = worktree_dir(&home, "myapp", "20260602-140000");
|
||||
assert_eq!(
|
||||
dir,
|
||||
PathBuf::from("/home/user/.sandcage/worktrees/myapp-20260602-140000")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_slug_extracts_basename() {
|
||||
assert_eq!(
|
||||
project_slug(Path::new("/home/user/projects/my-app")),
|
||||
"my-app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_worktrees_finds_managed() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let wt_dir = home.path().join("worktrees");
|
||||
std::fs::create_dir_all(&wt_dir).unwrap();
|
||||
std::fs::create_dir_all(wt_dir.join("myapp-1717300000")).unwrap();
|
||||
std::fs::create_dir_all(wt_dir.join("myapp-1717300001")).unwrap();
|
||||
|
||||
let entries = list_worktrees(home.path());
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_worktrees_empty_when_no_dir() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let entries = list_worktrees(home.path());
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_worktree_produces_valid_checkout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo_root = dir.path().to_path_buf();
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&repo_root)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(&repo_root)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.expect("git commit");
|
||||
|
||||
let wt_dir = tempfile::tempdir().unwrap();
|
||||
let wt_path = wt_dir.path().join("test-wt");
|
||||
|
||||
create_worktree(&repo_root, &wt_path, None).unwrap();
|
||||
|
||||
assert!(wt_path.exists(), "worktree dir should exist");
|
||||
let status = Command::new("git")
|
||||
.args(["status"])
|
||||
.current_dir(&wt_path)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git status should work in worktree");
|
||||
|
||||
// Cleanup
|
||||
Command::new("git")
|
||||
.args(["worktree", "remove", "--force", &wt_path.to_string_lossy()])
|
||||
.current_dir(&repo_root)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "thumper"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "In-container forwarding client for sandcage"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,311 @@
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::protocol::{ForwardRequest, ResponseFrame, StreamKind};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Connect(io::Error),
|
||||
Send(io::Error),
|
||||
Receive(io::Error),
|
||||
Parse(serde_json::Error),
|
||||
UnexpectedEof,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientError::Connect(e) => write!(f, "failed to connect to forwarding socket: {e}"),
|
||||
ClientError::Send(e) => write!(f, "failed to send request: {e}"),
|
||||
ClientError::Receive(e) => write!(f, "failed to read response: {e}"),
|
||||
ClientError::Parse(e) => write!(f, "malformed response frame: {e}"),
|
||||
ClientError::UnexpectedEof => write!(f, "connection closed without exit frame"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
|
||||
pub fn forward(socket_path: &Path, request: &ForwardRequest) -> Result<i32, ClientError> {
|
||||
let stream = UnixStream::connect(socket_path).map_err(ClientError::Connect)?;
|
||||
|
||||
// Send request as a single JSON line.
|
||||
let mut writer = io::BufWriter::new(&stream);
|
||||
serde_json::to_writer(&mut writer, request).map_err(|e| ClientError::Send(e.into()))?;
|
||||
writer.write_all(b"\n").map_err(ClientError::Send)?;
|
||||
writer.flush().map_err(ClientError::Send)?;
|
||||
|
||||
// Read response frames line by line.
|
||||
let reader = BufReader::new(&stream);
|
||||
for line_result in reader.lines() {
|
||||
let line = line_result.map_err(ClientError::Receive)?;
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let frame: ResponseFrame = serde_json::from_str(&line).map_err(ClientError::Parse)?;
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
|
||||
print!("{data}");
|
||||
}
|
||||
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
|
||||
eprint!("{data}");
|
||||
}
|
||||
ResponseFrame::Exit { exit } => {
|
||||
return Ok(exit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ClientError::UnexpectedEof)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use imp::*;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
mod imp {
|
||||
use std::path::Path;
|
||||
|
||||
use crate::protocol::ForwardRequest;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "thumper is only supported on Unix platforms")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
|
||||
pub fn forward(_socket_path: &Path, _request: &ForwardRequest) -> Result<i32, ClientError> {
|
||||
Err(ClientError::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub use imp::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::RequestKind;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
|
||||
fn mock_server(
|
||||
socket_path: &Path,
|
||||
handler: impl FnOnce(UnixStream) + Send + 'static,
|
||||
) -> thread::JoinHandle<()> {
|
||||
let listener = UnixListener::bind(socket_path).unwrap();
|
||||
thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
handler(stream);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_request_as_single_json_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(parsed["kind"], "task");
|
||||
assert_eq!(parsed["name"], "build");
|
||||
assert_eq!(parsed["args"][0], "--release");
|
||||
assert_eq!(parsed["cwd"], "/workspace/app");
|
||||
|
||||
// Send exit frame so forward() returns
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_stdout_and_stderr_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap(); // consume request
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"out1\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"stream":"stderr","data":"err1\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"out2\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "test".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_exit_is_relayed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stderr","data":"error: build failed\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"exit":1}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 1);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_eof_when_no_exit_frame() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"partial"}}"#).unwrap();
|
||||
let _ = writer; // close without exit frame
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "test".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let err = forward(&sock, &req).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ClientError::UnexpectedEof),
|
||||
"expected UnexpectedEof, got {err}"
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_roundtrip_simulating_cargo_build() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut req_line = String::new();
|
||||
reader.read_line(&mut req_line).unwrap();
|
||||
|
||||
// Validate the request
|
||||
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
|
||||
assert_eq!(req["kind"], "task");
|
||||
assert_eq!(req["name"], "build");
|
||||
assert_eq!(req["args"], serde_json::json!(["--release"]));
|
||||
|
||||
// Simulate cargo build output
|
||||
let writer = reader.into_inner();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling sandcage v0.1.0\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling thumper v0.1.0\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Finished release [optimized]\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_request_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut req_line = String::new();
|
||||
reader.read_line(&mut req_line).unwrap();
|
||||
|
||||
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
|
||||
assert_eq!(req["kind"], "tool");
|
||||
assert_eq!(req["name"], "docker");
|
||||
|
||||
let writer = reader.into_inner();
|
||||
writeln!(&*writer, r#"{{"stream":"stdout","data":"container-id-abc123\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Tool,
|
||||
name: "docker".to_string(),
|
||||
args: vec!["compose".to_string(), "up".to_string(), "-d".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_error_on_missing_socket() {
|
||||
let err = forward(
|
||||
Path::new("/tmp/sandcage-nonexistent-test.sock"),
|
||||
&crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "x".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ClientError::Connect(_)), "expected Connect, got {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
mod client;
|
||||
mod protocol;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use protocol::{ForwardRequest, RequestKind};
|
||||
|
||||
const DEFAULT_SOCKET: &str = "/run/sandcage/cmd.sock";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 || args[1] == "--help" || args[1] == "-h" {
|
||||
eprintln!("usage: thumper <task-name> [args...]");
|
||||
eprintln!();
|
||||
eprintln!("Forwards a named task to the sandcage host daemon.");
|
||||
eprintln!("The task is executed on the host and output is streamed back.");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
|
||||
let socket_path = std::env::var("SANDCAGE_SOCKET")
|
||||
.map(|s| std::path::PathBuf::from(s))
|
||||
.unwrap_or_else(|_| Path::new(DEFAULT_SOCKET).to_path_buf());
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "/workspace".to_string());
|
||||
|
||||
let request = ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: args[1].clone(),
|
||||
args: args[2..].to_vec(),
|
||||
cwd,
|
||||
};
|
||||
|
||||
match client::forward(&socket_path, &request) {
|
||||
Ok(code) => ExitCode::from(code as u8),
|
||||
Err(e) => {
|
||||
eprintln!("thumper: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ForwardRequest {
|
||||
pub kind: RequestKind,
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum RequestKind {
|
||||
Task,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// Response frames from the host-side daemon.
|
||||
///
|
||||
/// IMPORTANT: `Exit` MUST come before `Output`. Serde's `untagged` tries variants in
|
||||
/// declaration order — `{"exit":0}` would match `Output` if `Output` were first.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ResponseFrame {
|
||||
Exit { exit: i32 },
|
||||
Output { stream: StreamKind, data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum StreamKind {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_serializes_to_expected_json() {
|
||||
let req = ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["kind"], "task");
|
||||
assert_eq!(json["name"], "build");
|
||||
assert_eq!(json["args"][0], "--release");
|
||||
assert_eq!(json["cwd"], "/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_request_kind_serializes() {
|
||||
let req = ForwardRequest {
|
||||
kind: RequestKind::Tool,
|
||||
name: "grep".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["kind"], "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdout_frame_deserializes() {
|
||||
let frame: ResponseFrame =
|
||||
serde_json::from_str(r#"{"stream":"stdout","data":"hello\n"}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
|
||||
assert_eq!(data, "hello\n");
|
||||
}
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_frame_deserializes() {
|
||||
let frame: ResponseFrame =
|
||||
serde_json::from_str(r#"{"stream":"stderr","data":"warning: unused var"}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
|
||||
assert_eq!(data, "warning: unused var");
|
||||
}
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_frame_deserializes() {
|
||||
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":42}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Exit { exit } => assert_eq!(exit, 42),
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_zero_frame() {
|
||||
// Regression guard: serde untagged ordering — Exit must come before Output
|
||||
// so {"exit":0} does not accidentally match the Output variant.
|
||||
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":0}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Exit { exit } => assert_eq!(exit, 0),
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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?
|
||||
|
||||

|
||||
|
||||
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
@@ -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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Justfile for Sandcage
|
||||
|
||||
# Default: list available recipes.
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Run cargo check.
|
||||
check:
|
||||
cargo check
|
||||
|
||||
# Run cargo build.
|
||||
build:
|
||||
cargo build
|
||||
|
||||
# Run unit tests.
|
||||
test:
|
||||
cargo test --workspace
|
||||
|
||||
# Install sandcage and build images.
|
||||
install:
|
||||
cargo install --path crates/sandcage
|
||||
sandcage build --force
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
@@ -0,0 +1 @@
|
||||
{}
|
||||
+113
@@ -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 (selected)</text>
|
||||
<text class="sublabel" x="140" y="392" text-anchor="middle">sandcage-ssh volume</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 (from sandcage-ssh volume)</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">volume</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 Project and home are bind-mounted; SSH keys via named volume</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 |
Reference in New Issue
Block a user