diff --git a/Cargo.lock b/Cargo.lock index 1deda62..5fc57e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,31 @@ dependencies = [ "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]] name = "crypto-common" version = "0.1.7" @@ -973,6 +998,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -985,6 +1016,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "log" version = "0.4.29" @@ -1050,6 +1090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1102,6 +1143,29 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pear" version = "0.2.9" @@ -1322,6 +1386,15 @@ dependencies = [ "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]] name = "redox_users" version = "0.5.2" @@ -1426,6 +1499,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rustix" version = "1.1.4" @@ -1435,7 +1521,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1493,6 +1579,7 @@ dependencies = [ "async-trait", "bollard", "clap", + "crossterm", "dialoguer", "dirs", "figment", @@ -1528,6 +1615,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1645,6 +1738,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "signal-hook-registry" version = "1.4.8" @@ -1773,7 +1887,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1783,7 +1897,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2284,7 +2398,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix", + "rustix 1.1.4", "winsafe", ] @@ -2529,7 +2643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/README.md b/README.md index b38c75b..587ad9b 100644 --- a/README.md +++ b/README.md @@ -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 | | [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 | @@ -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 - **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 diff --git a/crates/sandcage/Cargo.toml b/crates/sandcage/Cargo.toml index 3a349b8..faabbed 100644 --- a/crates/sandcage/Cargo.toml +++ b/crates/sandcage/Cargo.toml @@ -14,6 +14,7 @@ bollard = [ "dep:reqwest", "dep:flate2", "dep:tar", + "dep:crossterm", ] [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 } flate2 = { version = "1", optional = true } tar = { version = "0.4", optional = true } +crossterm = { version = "0.28", optional = true } diff --git a/crates/sandcage/src/backend/bollard.rs b/crates/sandcage/src/backend/bollard.rs index 94d718a..c45a3fb 100644 --- a/crates/sandcage/src/backend/bollard.rs +++ b/crates/sandcage/src/backend/bollard.rs @@ -4,6 +4,7 @@ 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}; @@ -14,20 +15,49 @@ 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 { + 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, } impl BollardBackend { - pub fn new() -> Self { - let docker = Docker::connect_with_local_defaults() - .expect("failed to connect to Docker daemon"); - Self { docker } + pub fn try_new() -> super::Result { + let docker = Docker::connect_with_local_defaults().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(), + }) } } #[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, @@ -37,9 +67,18 @@ impl super::ContainerBackend for BollardBackend { shell_override: bool, extra_args: &[String], ) -> Result { - let compose = super::compose::ComposeBackend; - compose - .run_interactive(service, ctx, config, registry, shell_override, extra_args) + 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, extra_args) .await } @@ -71,6 +110,14 @@ impl super::ContainerBackend for BollardBackend { })?; Ok(()) } + + async fn runtime_info(&self) -> Result { + let info = self + .runtime_info + .get_or_init(|| super::runtime::detect_runtime(&self.docker)) + .await; + Ok(info.clone()) + } } impl BollardBackend { @@ -79,6 +126,7 @@ impl BollardBackend { compose_def: &ComposeServiceDef, config: &SandcageConfig, extra_args: &[String], + interactive: bool, ) -> ContainerCreateBody { let mut env: Vec = compose_def.environment.clone(); if let Some(ref env_map) = config.env { @@ -122,7 +170,10 @@ impl BollardBackend { let mut labels = HashMap::new(); 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 { image: Some(compose_def.image.clone()), @@ -132,7 +183,7 @@ impl BollardBackend { user: Some(compose_def.user.clone()), env: Some(env), labels: Some(labels), - tty: Some(false), + tty: Some(interactive), open_stdin: Some(true), attach_stdin: 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 { + 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::, + 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::, + ) + .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, extra_args: &[String], + ) -> Result { + 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 { 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 .docker diff --git a/crates/sandcage/src/backend/compose.rs b/crates/sandcage/src/backend/compose.rs index cb96a23..3a088fe 100644 --- a/crates/sandcage/src/backend/compose.rs +++ b/crates/sandcage/src/backend/compose.rs @@ -100,4 +100,8 @@ impl super::ContainerBackend for ComposeBackend { docker::require_compose(&docker_path).await?; Ok(()) } + + async fn runtime_info(&self) -> Result { + Ok(super::runtime::RuntimeInfo::default()) + } } diff --git a/crates/sandcage/src/backend/mod.rs b/crates/sandcage/src/backend/mod.rs index b35e195..bdb84b0 100644 --- a/crates/sandcage/src/backend/mod.rs +++ b/crates/sandcage/src/backend/mod.rs @@ -1,6 +1,11 @@ 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; @@ -35,16 +40,18 @@ pub trait ContainerBackend: Send + Sync { async fn volume_exists(&self, name: &str) -> Result; async fn check_availability(&self) -> Result<()>; + + async fn runtime_info(&self) -> Result; } -pub fn default_backend() -> Box { +pub fn default_backend() -> Result> { #[cfg(feature = "bollard")] { - Box::new(bollard::BollardBackend::new()) + Ok(Box::new(bollard::BollardBackend::try_new()?)) } #[cfg(not(feature = "bollard"))] { - Box::new(compose::ComposeBackend) + Ok(Box::new(compose::ComposeBackend)) } } diff --git a/crates/sandcage/src/backend/network.rs b/crates/sandcage/src/backend/network.rs new file mode 100644 index 0000000..8fc06fb --- /dev/null +++ b/crates/sandcage/src/backend/network.rs @@ -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 { + let name = network_name(workspace); + + // Check if it already exists + if docker.inspect_network(&name, None::).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::) + .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); + } +} diff --git a/crates/sandcage/src/backend/orchestrator.rs b/crates/sandcage/src/backend/orchestrator.rs new file mode 100644 index 0000000..52ac4eb --- /dev/null +++ b/crates/sandcage/src/backend/orchestrator.rs @@ -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, + /// 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, +} + +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, + 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"); + } +} diff --git a/crates/sandcage/src/backend/runtime.rs b/crates/sandcage/src/backend/runtime.rs new file mode 100644 index 0000000..7c98031 --- /dev/null +++ b/crates/sandcage/src/backend/runtime.rs @@ -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, + pub server_version: Option, +} + +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()); + } +} diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index d36ff17..36791e3 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -82,6 +82,10 @@ struct RawConfig { default_services: Option>, #[serde(default)] image: Option, + #[serde(default)] + runtime: Option, + #[serde(default)] + compose_backend: Option, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -125,13 +129,17 @@ pub struct SandcageConfig { pub default_services: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compose_backend: Option, } // --------------------------------------------------------------------------- // 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 @@ -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 if let Some(mounts) = &raw.mounts { for mount in mounts { @@ -189,6 +221,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { services: raw.services, default_services: raw.default_services, image: raw.image, + runtime: raw.runtime, + compose_backend: raw.compose_backend, } } @@ -736,4 +770,45 @@ services: let cfg = load_from_str("").expect("parse empty"); 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)); + } + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index df6e31e..587148f 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -350,9 +350,6 @@ pub async fn run_service( shell_override: bool, extra_args: &[String], ) -> Result<()> { - let docker = require_docker()?; - require_compose(&docker).await?; - let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService { service: service.to_string(), available: registry.names().collect::>().join(", "), @@ -365,40 +362,34 @@ pub async fn run_service( } let ctx = build_compose_context(workspace, config).await?; + + let backend: Box = 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); - 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") { - let vol_check = Command::new(&docker) - .args(["volume", "inspect", "sandcage-ssh"]) - .output() - .await; - if !vol_check.map(|o| o.status.success()).unwrap_or(false) { + if !backend.volume_exists("sandcage-ssh").await? { 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 = write_compose_tempfile(&compose_content)?; - let compose_path = compose_file.path().to_string_lossy().into_owned(); + let exit_code = backend + .run_interactive(svc, &ctx, config, registry, shell_override, extra_args) + .await?; - let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args); - - 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() { + if exit_code != 0 { return Err(DockerError::ServiceFailed { service: service.to_string(), - code: status.code().unwrap_or(-1), + code: exit_code, }); } diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index 45a42c8..5f027fa 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -137,6 +137,55 @@ enum AppError { 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, shell_override: bool, agent_args: Vec) -> std::result::Result<(), AppError> { let workspace = workspace::resolve_workspace(path.as_deref())?; eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); @@ -153,6 +202,8 @@ async fn run_service(service: &str, path: Option, shell_override: bool, Some(&local_config), )?; + print_runtime_info(&cfg).await; + let registry = service::registry::build_default_registry(&cfg); eprintln!("sandcage: service \u{2192} {service}"); @@ -190,7 +241,7 @@ async fn main() -> miette::Result<()> { }, #[cfg(feature = "bollard")] Commands::Acp { action } => { - let backend = sandcage::backend::default_backend(); + 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 diff --git a/docs/images/tty-decision-tree.svg b/docs/images/tty-decision-tree.svg new file mode 100644 index 0000000..38fd5f0 --- /dev/null +++ b/docs/images/tty-decision-tree.svg @@ -0,0 +1,88 @@ + + + + + + + + + Interactive Mode: Backend Selection + + + + compose_backend = "compose" + in sandcage.toml? + + + + Windows only + + + + MSYSTEM env var set? + (Git Bash / MSYS2) + + + + Console handle valid? + (GetConsoleMode) + + + + Raw mode supported? + (crossterm probe) + + + + Native TTY + + + + Compose fallback + + + + + no + + + + yes + + + + no + + + + yes + + + + yes + + + + no + + + + yes + + + + no + diff --git a/docs/interactive-mode.md b/docs/interactive-mode.md new file mode 100644 index 0000000..41d4769 --- /dev/null +++ b/docs/interactive-mode.md @@ -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.