🛰️ export from upstream (e9f3e38)

This commit is contained in:
Gabor Koerber
2026-05-30 01:48:46 +02:00
parent 9f2e1d7266
commit 077ccabd53
14 changed files with 1236 additions and 47 deletions
Generated
+119 -5
View File
@@ -345,6 +345,31 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -973,6 +998,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -985,6 +1016,15 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@@ -1050,6 +1090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1102,6 +1143,29 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]] [[package]]
name = "pear" name = "pear"
version = "0.2.9" version = "0.2.9"
@@ -1322,6 +1386,15 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -1426,6 +1499,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -1435,7 +1521,7 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1493,6 +1579,7 @@ dependencies = [
"async-trait", "async-trait",
"bollard", "bollard",
"clap", "clap",
"crossterm",
"dialoguer", "dialoguer",
"dirs", "dirs",
"figment", "figment",
@@ -1528,6 +1615,12 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@@ -1645,6 +1738,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -1773,7 +1887,7 @@ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1783,7 +1897,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [ dependencies = [
"rustix", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2284,7 +2398,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [ dependencies = [
"either", "either",
"env_home", "env_home",
"rustix", "rustix 1.1.4",
"winsafe", "winsafe",
] ]
@@ -2529,7 +2643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [ dependencies = [
"libc", "libc",
"rustix", "rustix 1.1.4",
] ]
[[package]] [[package]]
+2 -2
View File
@@ -123,6 +123,8 @@ See **[Configuration Reference](docs/configuration.md)** for all available optio
|----------|-------------| |----------|-------------|
| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples | | [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples |
| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern | | [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 | | [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 | | [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers |
@@ -130,8 +132,6 @@ See **[Configuration Reference](docs/configuration.md)** for all available optio
- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI - **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 - **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
- **Full bollard integration** — replace docker compose with direct Docker API calls for faster startup and richer container control
- **Interactive TTY mode** — full terminal emulation for agents that need a PTY
## Cross-Platform ## Cross-Platform
+2
View File
@@ -14,6 +14,7 @@ bollard = [
"dep:reqwest", "dep:reqwest",
"dep:flate2", "dep:flate2",
"dep:tar", "dep:tar",
"dep:crossterm",
] ]
[dependencies] [dependencies]
@@ -44,3 +45,4 @@ futures-util = { version = "0.3", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
flate2 = { version = "1", optional = true } flate2 = { version = "1", optional = true }
tar = { version = "0.4", optional = true } tar = { version = "0.4", optional = true }
crossterm = { version = "0.28", optional = true }
+339 -10
View File
@@ -4,6 +4,7 @@ use async_trait::async_trait;
use bollard::container::LogOutput; use bollard::container::LogOutput;
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType}; use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
use bollard::Docker; use bollard::Docker;
use crossterm::terminal;
use futures_util::StreamExt; use futures_util::StreamExt;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
@@ -14,20 +15,49 @@ use crate::service::{ComposeContext, ComposeServiceDef, Service};
use super::Result; 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 { pub struct BollardBackend {
docker: Docker, docker: Docker,
runtime_info: tokio::sync::OnceCell<super::runtime::RuntimeInfo>,
} }
impl BollardBackend { impl BollardBackend {
pub fn new() -> Self { pub fn try_new() -> super::Result<Self> {
let docker = Docker::connect_with_local_defaults() let docker = Docker::connect_with_local_defaults().map_err(|e| {
.expect("failed to connect to Docker daemon"); DockerError::SpawnFailed(std::io::Error::new(
Self { docker } 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(),
})
} }
} }
#[async_trait] #[async_trait]
impl super::ContainerBackend for BollardBackend { 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( async fn run_interactive(
&self, &self,
service: &dyn Service, service: &dyn Service,
@@ -37,9 +67,18 @@ impl super::ContainerBackend for BollardBackend {
shell_override: bool, shell_override: bool,
extra_args: &[String], extra_args: &[String],
) -> Result<i32> { ) -> Result<i32> {
let compose = super::compose::ComposeBackend; if !self.can_use_bollard_tty() {
compose let compose = super::compose::ComposeBackend;
.run_interactive(service, ctx, config, registry, shell_override, extra_args) 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, extra_args)
.await .await
} }
@@ -71,6 +110,14 @@ impl super::ContainerBackend for BollardBackend {
})?; })?;
Ok(()) 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 { impl BollardBackend {
@@ -79,6 +126,7 @@ impl BollardBackend {
compose_def: &ComposeServiceDef, compose_def: &ComposeServiceDef,
config: &SandcageConfig, config: &SandcageConfig,
extra_args: &[String], extra_args: &[String],
interactive: bool,
) -> ContainerCreateBody { ) -> ContainerCreateBody {
let mut env: Vec<String> = compose_def.environment.clone(); let mut env: Vec<String> = compose_def.environment.clone();
if let Some(ref env_map) = config.env { if let Some(ref env_map) = config.env {
@@ -122,7 +170,10 @@ impl BollardBackend {
let mut labels = HashMap::new(); let mut labels = HashMap::new();
labels.insert("sandcage.managed".to_string(), "true".to_string()); labels.insert("sandcage.managed".to_string(), "true".to_string());
labels.insert("sandcage.mode".to_string(), "acp".to_string()); labels.insert(
"sandcage.mode".to_string(),
if interactive { "interactive" } else { "acp" }.to_string(),
);
ContainerCreateBody { ContainerCreateBody {
image: Some(compose_def.image.clone()), image: Some(compose_def.image.clone()),
@@ -132,7 +183,7 @@ impl BollardBackend {
user: Some(compose_def.user.clone()), user: Some(compose_def.user.clone()),
env: Some(env), env: Some(env),
labels: Some(labels), labels: Some(labels),
tty: Some(false), tty: Some(interactive),
open_stdin: Some(true), open_stdin: Some(true),
attach_stdin: Some(true), attach_stdin: Some(true),
attach_stdout: Some(true), attach_stdout: Some(true),
@@ -148,11 +199,288 @@ impl BollardBackend {
} }
} }
/// 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,
extra_args: &[String],
) -> 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 container_config =
self.build_container_config(compose_def, config, extra_args, true);
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( async fn acp_relay(
&self, &self,
compose_def: &ComposeServiceDef, compose_def: &ComposeServiceDef,
config: &SandcageConfig, config: &SandcageConfig,
extra_args: &[String], extra_args: &[String],
) -> Result<i32> {
self.run_container(compose_def, config, extra_args, false).await
}
async fn run_container(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
extra_args: &[String],
interactive: bool,
) -> Result<i32> { ) -> Result<i32> {
self.cleanup_orphans().await; self.cleanup_orphans().await;
@@ -163,7 +491,8 @@ impl BollardBackend {
} }
})?; })?;
let container_config = self.build_container_config(compose_def, config, extra_args); let container_config =
self.build_container_config(compose_def, config, extra_args, interactive);
let container = self let container = self
.docker .docker
+4
View File
@@ -100,4 +100,8 @@ impl super::ContainerBackend for ComposeBackend {
docker::require_compose(&docker_path).await?; docker::require_compose(&docker_path).await?;
Ok(()) Ok(())
} }
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
Ok(super::runtime::RuntimeInfo::default())
}
} }
+10 -3
View File
@@ -1,6 +1,11 @@
pub mod compose; pub mod compose;
pub mod runtime;
#[cfg(feature = "bollard")] #[cfg(feature = "bollard")]
pub mod bollard; pub mod bollard;
#[cfg(feature = "bollard")]
pub mod network;
#[cfg(feature = "bollard")]
pub mod orchestrator;
use async_trait::async_trait; use async_trait::async_trait;
@@ -35,16 +40,18 @@ pub trait ContainerBackend: Send + Sync {
async fn volume_exists(&self, name: &str) -> Result<bool>; async fn volume_exists(&self, name: &str) -> Result<bool>;
async fn check_availability(&self) -> Result<()>; async fn check_availability(&self) -> Result<()>;
async fn runtime_info(&self) -> Result<runtime::RuntimeInfo>;
} }
pub fn default_backend() -> Box<dyn ContainerBackend> { pub fn default_backend() -> Result<Box<dyn ContainerBackend>> {
#[cfg(feature = "bollard")] #[cfg(feature = "bollard")]
{ {
Box::new(bollard::BollardBackend::new()) Ok(Box::new(bollard::BollardBackend::try_new()?))
} }
#[cfg(not(feature = "bollard"))] #[cfg(not(feature = "bollard"))]
{ {
Box::new(compose::ComposeBackend) Ok(Box::new(compose::ComposeBackend))
} }
} }
+103
View File
@@ -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);
}
}
+147
View File
@@ -0,0 +1,147 @@
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![],
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");
}
}
+187
View File
@@ -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());
}
}
+76 -1
View File
@@ -82,6 +82,10 @@ struct RawConfig {
default_services: Option<Vec<String>>, default_services: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
image: Option<String>, image: Option<String>,
#[serde(default)]
runtime: Option<String>,
#[serde(default)]
compose_backend: Option<String>,
/// Absorb everything else so we can warn about unknown fields. /// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)] #[serde(flatten)]
@@ -125,13 +129,17 @@ pub struct SandcageConfig {
pub default_services: Option<Vec<String>>, pub default_services: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>, pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compose_backend: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map // Known keys — used to filter the flattened `extra` map
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image"]; const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image", "runtime", "compose_backend"];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Validation helpers // Validation helpers
@@ -155,6 +163,30 @@ fn validate_and_warn(raw: &RawConfig) {
} }
} }
// Validate runtime value
if let Some(rt) = &raw.runtime {
const VALID_RUNTIMES: &[&str] = &["auto", "rootless", "rootful"];
if !VALID_RUNTIMES.contains(&rt.as_str()) {
eprintln!(
"sandcage: warning: unrecognized runtime value '{rt}'. \
Valid values: {}",
VALID_RUNTIMES.join(", ")
);
}
}
// Validate compose_backend value
if let Some(cb) = &raw.compose_backend {
const VALID_COMPOSE_BACKENDS: &[&str] = &["auto", "bollard", "compose"];
if !VALID_COMPOSE_BACKENDS.contains(&cb.as_str()) {
eprintln!(
"sandcage: warning: unrecognized compose_backend value '{cb}'. \
Valid values: {}",
VALID_COMPOSE_BACKENDS.join(", ")
);
}
}
// Validate mount strings // Validate mount strings
if let Some(mounts) = &raw.mounts { if let Some(mounts) = &raw.mounts {
for mount in mounts { for mount in mounts {
@@ -189,6 +221,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
services: raw.services, services: raw.services,
default_services: raw.default_services, default_services: raw.default_services,
image: raw.image, image: raw.image,
runtime: raw.runtime,
compose_backend: raw.compose_backend,
} }
} }
@@ -736,4 +770,45 @@ services:
let cfg = load_from_str("").expect("parse empty"); let cfg = load_from_str("").expect("parse empty");
assert!(cfg.image.is_none()); assert!(cfg.image.is_none());
} }
#[test]
fn parse_runtime_and_compose_backend() {
let yaml = "runtime: rootless\ncompose_backend: bollard\n";
let cfg = load_from_str(yaml).expect("parse runtime and compose_backend");
assert_eq!(cfg.runtime.as_deref(), Some("rootless"));
assert_eq!(cfg.compose_backend.as_deref(), Some("bollard"));
}
#[test]
fn runtime_and_compose_backend_default_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.runtime.is_none());
assert!(cfg.compose_backend.is_none());
}
#[test]
fn unrecognized_runtime_and_compose_backend_still_parse() {
let yaml = "runtime: podman\ncompose_backend: swarm\n";
let cfg = load_from_str(yaml).expect("unrecognized values should not hard-error");
assert_eq!(cfg.runtime.as_deref(), Some("podman"));
assert_eq!(cfg.compose_backend.as_deref(), Some("swarm"));
}
#[test]
fn parse_all_valid_runtime_values() {
for val in &["auto", "rootless", "rootful"] {
let yaml = format!("runtime: {val}\n");
let cfg = load_from_str(&yaml).expect("parse valid runtime");
assert_eq!(cfg.runtime.as_deref(), Some(*val));
}
}
#[test]
fn parse_all_valid_compose_backend_values() {
for val in &["auto", "bollard", "compose"] {
let yaml = format!("compose_backend: {val}\n");
let cfg = load_from_str(&yaml).expect("parse valid compose_backend");
assert_eq!(cfg.compose_backend.as_deref(), Some(*val));
}
}
} }
+16 -25
View File
@@ -350,9 +350,6 @@ pub async fn run_service(
shell_override: bool, shell_override: bool,
extra_args: &[String], extra_args: &[String],
) -> Result<()> { ) -> Result<()> {
let docker = require_docker()?;
require_compose(&docker).await?;
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService { let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
service: service.to_string(), service: service.to_string(),
available: registry.names().collect::<Vec<_>>().join(", "), available: registry.names().collect::<Vec<_>>().join(", "),
@@ -365,40 +362,34 @@ pub async fn run_service(
} }
let ctx = build_compose_context(workspace, config).await?; let ctx = build_compose_context(workspace, config).await?;
let backend: Box<dyn crate::backend::ContainerBackend> = match config.compose_backend.as_deref() {
Some("compose") => Box::new(crate::backend::compose::ComposeBackend),
_ => crate::backend::default_backend()?,
};
backend.check_availability().await?;
let image_tag = format!("{}:latest", ctx.image); let image_tag = format!("{}:latest", ctx.image);
require_image(&docker, &image_tag).await?; if !backend.image_exists(&image_tag).await? {
return Err(DockerError::ImageNotFound { image: image_tag });
}
if config.ssh_mode.as_deref() == Some("volume") { if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker) if !backend.volume_exists("sandcage-ssh").await? {
.args(["volume", "inspect", "sandcage-ssh"])
.output()
.await;
if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
eprintln!( eprintln!(
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it" "sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
); );
} }
} }
let compose_content = crate::service::compose::generate_compose(registry, &ctx); let exit_code = backend
let compose_file = write_compose_tempfile(&compose_content)?; .run_interactive(svc, &ctx, config, registry, shell_override, extra_args)
let compose_path = compose_file.path().to_string_lossy().into_owned(); .await?;
let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args); if exit_code != 0 {
let mut cmd = Command::new(&docker);
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)?;
if !status.success() {
return Err(DockerError::ServiceFailed { return Err(DockerError::ServiceFailed {
service: service.to_string(), service: service.to_string(),
code: status.code().unwrap_or(-1), code: exit_code,
}); });
} }
+52 -1
View File
@@ -137,6 +137,55 @@ enum AppError {
Setup(#[from] setup::SetupError), Setup(#[from] setup::SetupError),
} }
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, agent_args: Vec<String>) -> std::result::Result<(), AppError> { async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
let workspace = workspace::resolve_workspace(path.as_deref())?; let workspace = workspace::resolve_workspace(path.as_deref())?;
eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
@@ -153,6 +202,8 @@ async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool,
Some(&local_config), Some(&local_config),
)?; )?;
print_runtime_info(&cfg).await;
let registry = service::registry::build_default_registry(&cfg); let registry = service::registry::build_default_registry(&cfg);
eprintln!("sandcage: service \u{2192} {service}"); eprintln!("sandcage: service \u{2192} {service}");
@@ -190,7 +241,7 @@ async fn main() -> miette::Result<()> {
}, },
#[cfg(feature = "bollard")] #[cfg(feature = "bollard")]
Commands::Acp { action } => { Commands::Acp { action } => {
let backend = sandcage::backend::default_backend(); let backend = sandcage::backend::default_backend()?;
backend.check_availability().await backend.check_availability().await
.map_err(|e| AppError::Docker(e))?; .map_err(|e| AppError::Docker(e))?;
sandcage::acp::handle(action, backend.as_ref(), None).await sandcage::acp::handle(action, backend.as_ref(), None).await
+88
View File
@@ -0,0 +1,88 @@
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="520" viewBox="0 0 700 520">
<style>
text { font-family: system-ui, -apple-system, sans-serif; }
.title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.label { font-size: 13px; fill: #1a1a1a; }
.small { font-size: 11px; fill: #555; }
.edge { font-size: 11px; font-weight: 600; }
.yes { fill: #2d7d46; }
.no { fill: #c0392b; }
.arrow { stroke: #666; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
.diamond { fill: #fff3cd; stroke: #e0c36a; stroke-width: 1.5; }
.result-native { fill: #d4edda; stroke: #7bc88f; stroke-width: 1.5; rx: 8; }
.result-compose { fill: #cce5ff; stroke: #6cb3f5; stroke-width: 1.5; rx: 8; }
.platform { fill: #f0f0f0; stroke: #ccc; stroke-width: 1; stroke-dasharray: 4 2; rx: 6; }
</style>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" fill="#666"/>
</marker>
</defs>
<text x="350" y="30" text-anchor="middle" class="title">Interactive Mode: Backend Selection</text>
<!-- Config check -->
<rect x="225" y="50" width="250" height="44" class="diamond" rx="6"/>
<text x="350" y="69" text-anchor="middle" class="label">compose_backend = "compose"</text>
<text x="350" y="84" text-anchor="middle" class="small">in sandcage.toml?</text>
<!-- Windows-only box -->
<rect x="30" y="140" width="340" height="290" class="platform"/>
<text x="50" y="160" class="small" font-style="italic">Windows only</text>
<!-- MSYSTEM check -->
<rect x="100" y="175" width="200" height="44" class="diamond" rx="6"/>
<text x="200" y="194" text-anchor="middle" class="label">MSYSTEM env var set?</text>
<text x="200" y="209" text-anchor="middle" class="small">(Git Bash / MSYS2)</text>
<!-- Console handle check -->
<rect x="100" y="275" width="200" height="44" class="diamond" rx="6"/>
<text x="200" y="294" text-anchor="middle" class="label">Console handle valid?</text>
<text x="200" y="309" text-anchor="middle" class="small">(GetConsoleMode)</text>
<!-- Raw mode check -->
<rect x="225" y="380" width="250" height="44" class="diamond" rx="6"/>
<text x="350" y="399" text-anchor="middle" class="label">Raw mode supported?</text>
<text x="350" y="414" text-anchor="middle" class="small">(crossterm probe)</text>
<!-- Result: Native TTY -->
<rect x="195" y="468" width="200" height="38" class="result-native"/>
<text x="295" y="491" text-anchor="middle" class="label" font-weight="600">Native TTY</text>
<!-- Result: Compose fallback -->
<rect x="500" y="200" width="170" height="38" class="result-compose"/>
<text x="585" y="223" text-anchor="middle" class="label" font-weight="600">Compose fallback</text>
<!-- Arrows -->
<!-- Config → MSYSTEM (No) -->
<path d="M350,94 L350,120 L200,120 L200,175" class="arrow"/>
<text x="280" y="115" class="edge no">no</text>
<!-- Config → Compose (Yes) -->
<path d="M475,72 L585,72 L585,200" class="arrow"/>
<text x="510" y="65" class="edge yes">yes</text>
<!-- MSYSTEM → Console (No) -->
<path d="M200,219 L200,275" class="arrow"/>
<text x="210" y="252" class="edge no">no</text>
<!-- MSYSTEM → Compose (Yes) -->
<path d="M300,197 L430,197 L430,219 L500,219" class="arrow"/>
<text x="340" y="190" class="edge yes">yes</text>
<!-- Console → Raw mode (Yes) -->
<path d="M200,319 L200,355 L350,355 L350,380" class="arrow"/>
<text x="210" y="345" class="edge yes">yes</text>
<!-- Console → Compose (No) -->
<path d="M300,297 L430,297 L430,219 L500,219" class="arrow"/>
<text x="340" y="290" class="edge no">no</text>
<!-- Raw mode → Native (Yes) -->
<path d="M295,424 L295,468" class="arrow"/>
<text x="275" y="450" class="edge yes">yes</text>
<!-- Raw mode → Compose (No) -->
<path d="M475,402 L585,402 L585,238" class="arrow"/>
<text x="510" y="395" class="edge no">no</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+91
View File
@@ -0,0 +1,91 @@
# Interactive Mode
When you run `sandcage shell` or pass `--shell` to an agent command, sandcage opens an
interactive terminal session inside the container. How that session is wired up depends on
your terminal — sandcage picks the best available method automatically.
## Two backends
### Native TTY (default)
Sandcage talks directly to the Docker API, puts your terminal into raw mode, and relays
keystrokes and output between your terminal and the container. This gives you:
- **Terminal resize tracking** — the container shell adjusts when you resize the window
- **Full TTY fidelity** — colors, cursor movement, and interactive programs (vim, htop, etc.)
work as expected
- **Graceful shutdown** — the container stops cleanly when the session ends
This is the same mechanism `docker run -it` uses under the hood.
### Compose fallback
When native TTY isn't available, sandcage delegates to `docker compose run`, which manages
the terminal connection itself. You get the same interactive shell — the difference is
invisible in normal use. The fallback activates automatically; no configuration needed.
## Which backend am I using?
Sandcage prints a diagnostic line to stderr when it makes its choice:
```
sandcage: tty probe → crossterm raw mode OK — using bollard TTY
```
or
```
sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose
```
These messages appear once at session start and don't affect your shell.
## When does the fallback activate?
![TTY backend decision tree](./images/tty-decision-tree.svg)
The detection runs through these checks in order:
| Check | Triggers fallback when… |
|---|---|
| `compose_backend` config | Set to `"compose"` in `sandcage.toml` — forces compose unconditionally |
| MSYSTEM environment variable (Windows) | Present — indicates Git Bash or MSYS2, whose terminal emulator can't handle raw console mode |
| Console handle (Windows) | Invalid — stdin is piped or redirected, not a real terminal |
| Raw mode probe (all platforms) | Fails — terminal doesn't support raw mode |
If none of these trigger, sandcage uses native TTY.
## Exiting the session
In native TTY mode, your keystrokes are relayed directly to the container shell. This means:
- **Ctrl+D** or typing `exit` ends the session (same as any shell)
- **Ctrl+C** interrupts the current command inside the container — it does *not* kill the
session. This matches `docker run -it` behavior.
In compose fallback mode, Ctrl+C ends the session immediately.
## Forcing compose mode
If you run into rendering issues in an unusual terminal, you can force the compose backend
by adding this to your `sandcage.toml`:
```toml
compose_backend = "compose"
```
This bypasses all detection and always uses `docker compose run` for interactive sessions.
## Windows terminal compatibility
| Terminal | Backend used |
|---|---|
| Windows Terminal / PowerShell | Native TTY |
| CMD (Command Prompt) | Native TTY |
| Git Bash (MinTTY) | Compose fallback |
| MSYS2 / MinGW shells | Compose fallback |
| WSL | Native TTY |
On macOS and Linux, native TTY is used in all standard terminals (iTerm2, Terminal.app,
GNOME Terminal, Alacritty, kitty, etc.). The compose fallback would only activate if stdin
is piped or the terminal is otherwise non-interactive.