Files
optimclaw/src/sandbox/manager.rs
T
3a2989d009 feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#821)
* feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#751, #674)

- Add upsert_bootstrap_vars() to preserve user-added .env vars on re-onboarding
- Add --quick mode: auto-defaults DB + security, asks only LLM provider (2 steps)
- Auto-triggered onboarding uses quick mode for near-instant first run
- Fix NEAR AI model fetch to use cloud-api.near.ai when API key is set
- Handle missing WASM tools/channels directories gracefully
- Downgrade all boot/shutdown tracing::info! to debug (boot screen shows user output)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(setup): gate env_backend variable behind postgres feature flag [skip-regression-check]

Clippy lint fix — not a behavioral change, just moving a variable declaration
inside the cfg(feature = "postgres") block where it's used.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(review): address PR review comments

- WASM loaders: use tokio::fs::metadata, only treat NotFound as empty,
  propagate other IO errors, handle TOCTOU in read_dir
- bootstrap: only ignore NotFound in read_to_string, propagate other errors
- wizard: restore print_info/print_success for migrations in interactive
  mode (gated by !config.quick), keep tracing::debug for diagnostics
- tests: use shared crate::config::helpers::ENV_MUTEX instead of separate
  NEARAI_ENV_MUTEX to prevent cross-test env var races
- README: fix quick mode description to mention model selection, clarify
  auto_setup_database may prompt when DATABASE_URL is set

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(setup): skip prompts for DATABASE_URL in quick mode [skip-regression-check]

auto_setup_database() now uses DATABASE_URL directly without calling
step_database_postgres() (which prompts for confirmation). Quick mode
should be fully non-interactive when env vars are already set.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(cli): update --quick help text to mention model selection [skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 05:02:33 +00:00

525 lines
17 KiB
Rust

//! Main sandbox manager coordinating proxy and containers.
//!
//! The `SandboxManager` is the primary entry point for sandboxed execution.
//! It coordinates:
//! - Docker container creation and lifecycle
//! - HTTP proxy for network access control
//! - Credential injection for API calls
//! - Resource limits and timeouts
//!
//! # Architecture
//!
//! ```text
//! ┌───────────────────────────────────────────────────────────────────────────┐
//! │ SandboxManager │
//! │ │
//! │ execute(cmd, cwd, policy) │
//! │ │ │
//! │ ▼ │
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
//! │ │ Start Proxy │────▶│ Create │────▶│ Execute & Collect Output │ │
//! │ │ (if needed) │ │ Container │ │ │ │
//! │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
//! │ │ │
//! │ ▼ │
//! │ ┌──────────────────────────┐ │
//! │ │ Cleanup Container │ │
//! │ └──────────────────────────┘ │
//! └───────────────────────────────────────────────────────────────────────────┘
//! ```
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use bollard::Docker;
use crate::sandbox::config::{ResourceLimits, SandboxConfig, SandboxPolicy};
use crate::sandbox::container::{ContainerOutput, ContainerRunner, connect_docker};
use crate::sandbox::error::{Result, SandboxError};
use crate::sandbox::proxy::{HttpProxy, NetworkProxyBuilder};
/// Output from sandbox execution.
#[derive(Debug, Clone)]
pub struct ExecOutput {
/// Exit code from the command.
pub exit_code: i64,
/// Standard output.
pub stdout: String,
/// Standard error.
pub stderr: String,
/// Combined output (stdout + stderr).
pub output: String,
/// How long the command ran.
pub duration: Duration,
/// Whether output was truncated.
pub truncated: bool,
}
impl From<ContainerOutput> for ExecOutput {
fn from(c: ContainerOutput) -> Self {
let output = if c.stderr.is_empty() {
c.stdout.clone()
} else if c.stdout.is_empty() {
c.stderr.clone()
} else {
format!("{}\n\n--- stderr ---\n{}", c.stdout, c.stderr)
};
Self {
exit_code: c.exit_code,
stdout: c.stdout,
stderr: c.stderr,
output,
duration: c.duration,
truncated: c.truncated,
}
}
}
/// Main sandbox manager.
pub struct SandboxManager {
config: SandboxConfig,
proxy: Arc<RwLock<Option<HttpProxy>>>,
docker: Arc<RwLock<Option<Docker>>>,
initialized: std::sync::atomic::AtomicBool,
}
impl SandboxManager {
/// Create a new sandbox manager.
pub fn new(config: SandboxConfig) -> Self {
Self {
config,
proxy: Arc::new(RwLock::new(None)),
docker: Arc::new(RwLock::new(None)),
initialized: std::sync::atomic::AtomicBool::new(false),
}
}
/// Create with default configuration.
pub fn with_defaults() -> Self {
Self::new(SandboxConfig::default())
}
/// Check if the sandbox is available (Docker running, etc.).
pub async fn is_available(&self) -> bool {
if !self.config.enabled {
return false;
}
match connect_docker().await {
Ok(docker) => docker.ping().await.is_ok(),
Err(_) => false,
}
}
/// Initialize the sandbox (connect to Docker, start proxy).
pub async fn initialize(&self) -> Result<()> {
if self.initialized.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(());
}
if !self.config.enabled {
return Err(SandboxError::Config {
reason: "sandbox is disabled".to_string(),
});
}
// Connect to Docker
let docker = connect_docker().await?;
// Check if Docker is responsive
docker
.ping()
.await
.map_err(|e| SandboxError::DockerNotAvailable {
reason: e.to_string(),
})?;
// Check for / pull image using a temporary runner
let checker = ContainerRunner::new(
docker.clone(),
self.config.image.clone(),
self.config.proxy_port,
);
if !checker.image_exists().await {
if self.config.auto_pull_image {
checker.pull_image().await?;
} else {
return Err(SandboxError::ContainerCreationFailed {
reason: format!(
"image {} not found and auto_pull is disabled",
self.config.image
),
});
}
}
*self.docker.write().await = Some(docker);
// Start the network proxy if we're using a sandboxed policy
if self.config.policy.is_sandboxed() {
let proxy = NetworkProxyBuilder::from_config(&self.config)
.build_and_start(self.config.proxy_port)
.await?;
*self.proxy.write().await = Some(proxy);
}
self.initialized
.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!("Sandbox initialized");
Ok(())
}
/// Shutdown the sandbox (stop proxy, clean up).
pub async fn shutdown(&self) {
if let Some(proxy) = self.proxy.write().await.take() {
proxy.stop().await;
}
self.initialized
.store(false, std::sync::atomic::Ordering::SeqCst);
tracing::debug!("Sandbox shut down");
}
/// Execute a command in the sandbox.
pub async fn execute(
&self,
command: &str,
cwd: &Path,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
self.execute_with_policy(command, cwd, self.config.policy, env)
.await
}
/// Execute a command with a specific policy.
pub async fn execute_with_policy(
&self,
command: &str,
cwd: &Path,
policy: SandboxPolicy,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
// FullAccess policy bypasses the sandbox entirely
if policy == SandboxPolicy::FullAccess {
return self.execute_direct(command, cwd, env).await;
}
// Ensure we're initialized
if !self.initialized.load(std::sync::atomic::Ordering::SeqCst) {
self.initialize().await?;
}
// Get proxy port if running
let proxy_port = if let Some(proxy) = self.proxy.read().await.as_ref() {
proxy.addr().await.map(|a| a.port()).unwrap_or(0)
} else {
0
};
// Reuse the stored Docker connection, create a runner with the current proxy port
let docker =
self.docker
.read()
.await
.clone()
.ok_or_else(|| SandboxError::DockerNotAvailable {
reason: "Docker connection not initialized".to_string(),
})?;
let runner = ContainerRunner::new(docker, self.config.image.clone(), proxy_port);
let limits = ResourceLimits {
memory_bytes: self.config.memory_limit_mb * 1024 * 1024,
cpu_shares: self.config.cpu_shares,
timeout: self.config.timeout,
max_output_bytes: 64 * 1024,
};
let container_output = runner.execute(command, cwd, policy, &limits, env).await?;
Ok(container_output.into())
}
/// Execute a command directly on the host (no sandbox).
async fn execute_direct(
&self,
command: &str,
cwd: &Path,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
use tokio::process::Command;
let start = std::time::Instant::now();
let mut cmd = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", command]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", command]);
c
};
cmd.current_dir(cwd);
cmd.envs(env);
let output = tokio::time::timeout(self.config.timeout, cmd.output())
.await
.map_err(|_| SandboxError::Timeout(self.config.timeout))?
.map_err(|e| SandboxError::ExecutionFailed {
reason: e.to_string(),
})?;
let max_output: usize = 64 * 1024; // 64 KB, matching container path
let half_max = max_output / 2;
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
let mut truncated = false;
if stdout.len() > half_max {
let end = crate::util::floor_char_boundary(&stdout, half_max);
stdout.truncate(end);
truncated = true;
}
if stderr.len() > half_max {
let end = crate::util::floor_char_boundary(&stderr, half_max);
stderr.truncate(end);
truncated = true;
}
let combined = if stderr.is_empty() {
stdout.clone()
} else if stdout.is_empty() {
stderr.clone()
} else {
format!("{}\n\n--- stderr ---\n{}", stdout, stderr)
};
Ok(ExecOutput {
exit_code: output.status.code().unwrap_or(-1) as i64,
stdout,
stderr,
output: combined,
duration: start.elapsed(),
truncated,
})
}
/// Execute a build command (convenience method using WorkspaceWrite policy).
pub async fn build(
&self,
command: &str,
project_dir: &Path,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
self.execute_with_policy(command, project_dir, SandboxPolicy::WorkspaceWrite, env)
.await
}
/// Get the current configuration.
pub fn config(&self) -> &SandboxConfig {
&self.config
}
/// Check if the sandbox is initialized.
pub fn is_initialized(&self) -> bool {
self.initialized.load(std::sync::atomic::Ordering::SeqCst)
}
/// Get the proxy port if running.
pub async fn proxy_port(&self) -> Option<u16> {
if let Some(proxy) = self.proxy.read().await.as_ref() {
proxy.addr().await.map(|a| a.port())
} else {
None
}
}
}
impl Drop for SandboxManager {
fn drop(&mut self) {
// Note: async cleanup should be done via shutdown() before dropping
if self.initialized.load(std::sync::atomic::Ordering::SeqCst) {
tracing::warn!("SandboxManager dropped without shutdown(), resources may leak");
}
}
}
/// Builder for creating a sandbox manager.
pub struct SandboxManagerBuilder {
config: SandboxConfig,
}
impl SandboxManagerBuilder {
/// Create a new builder.
pub fn new() -> Self {
Self {
config: SandboxConfig::default(),
}
}
/// Enable the sandbox.
pub fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
/// Set the sandbox policy.
pub fn policy(mut self, policy: SandboxPolicy) -> Self {
self.config.policy = policy;
self
}
/// Set the command timeout.
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
/// Set the memory limit in MB.
pub fn memory_limit_mb(mut self, mb: u64) -> Self {
self.config.memory_limit_mb = mb;
self
}
/// Set the Docker image.
pub fn image(mut self, image: &str) -> Self {
self.config.image = image.to_string();
self
}
/// Add domains to the network allowlist.
pub fn allow_domains(mut self, domains: Vec<String>) -> Self {
self.config.network_allowlist.extend(domains);
self
}
/// Build the sandbox manager.
pub fn build(self) -> SandboxManager {
SandboxManager::new(self.config)
}
/// Build and initialize the sandbox manager.
pub async fn build_and_init(self) -> Result<SandboxManager> {
let manager = self.build();
manager.initialize().await?;
Ok(manager)
}
}
impl Default for SandboxManagerBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exec_output_from_container_output() {
let container = ContainerOutput {
exit_code: 0,
stdout: "hello".to_string(),
stderr: String::new(),
duration: Duration::from_secs(1),
truncated: false,
};
let exec: ExecOutput = container.into();
assert_eq!(exec.exit_code, 0);
assert_eq!(exec.output, "hello");
}
#[test]
fn test_exec_output_combined() {
let container = ContainerOutput {
exit_code: 1,
stdout: "out".to_string(),
stderr: "err".to_string(),
duration: Duration::from_secs(1),
truncated: false,
};
let exec: ExecOutput = container.into();
assert!(exec.output.contains("out"));
assert!(exec.output.contains("err"));
assert!(exec.output.contains("stderr"));
}
#[test]
fn test_builder_defaults() {
let manager = SandboxManagerBuilder::new().build();
assert!(manager.config.enabled); // Enabled by default (startup check disables if Docker unavailable)
}
#[test]
fn test_builder_custom() {
let manager = SandboxManagerBuilder::new()
.enabled(true)
.policy(SandboxPolicy::WorkspaceWrite)
.timeout(Duration::from_secs(60))
.memory_limit_mb(1024)
.image("custom:latest")
.build();
assert!(manager.config.enabled);
assert_eq!(manager.config.policy, SandboxPolicy::WorkspaceWrite);
assert_eq!(manager.config.timeout, Duration::from_secs(60));
assert_eq!(manager.config.memory_limit_mb, 1024);
assert_eq!(manager.config.image, "custom:latest");
}
#[tokio::test]
async fn test_direct_execution() {
let manager = SandboxManager::new(SandboxConfig {
enabled: true,
policy: SandboxPolicy::FullAccess,
..Default::default()
});
let result = manager
.execute("echo hello", Path::new("."), HashMap::new())
.await;
// This should work even without Docker since FullAccess runs directly
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.stdout.contains("hello"));
}
#[tokio::test]
async fn test_direct_execution_truncates_large_output() {
let manager = SandboxManager::new(SandboxConfig {
enabled: true,
policy: SandboxPolicy::FullAccess,
..Default::default()
});
// Generate output larger than 32KB (half of 64KB limit)
// printf repeats a 100-char line 400 times = 40KB
let result = manager
.execute(
"printf 'A%.0s' $(seq 1 40000)",
Path::new("."),
HashMap::new(),
)
.await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.truncated);
assert!(output.stdout.len() <= 32 * 1024);
}
}