diff --git a/Cargo.lock b/Cargo.lock index 390e6aa99..f4a81c3f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2108,6 +2108,7 @@ dependencies = [ "fabro-manifest", "fabro-mcp-server", "fabro-oauth", + "fabro-pebble-sandbox", "fabro-petri", "fabro-proc", "fabro-redact", @@ -2145,6 +2146,8 @@ dependencies = [ "ring", "rmcp", "rustls", + "sandbox-driver", + "sandbox-driver-host", "scopeguard", "semver", "serde", @@ -2512,6 +2515,26 @@ dependencies = [ "serde_json", ] +[[package]] +name = "fabro-pebble-sandbox" +version = "0.361.0-nightly.0" +dependencies = [ + "async-trait", + "fabro-redact", + "fabro-types", + "fabro-util", + "pebble-coding-agent", + "sandbox-driver", + "sandbox-driver-host", + "sandbox-driver-testing", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "fabro-petri" version = "0.361.0-nightly.0" @@ -2650,6 +2673,7 @@ dependencies = [ "fabro-macros", "fabro-manifest", "fabro-mcp-store", + "fabro-pebble-sandbox", "fabro-petri", "fabro-proc", "fabro-redact", diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index 345f5ac5e..9ac693f86 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -34,6 +34,9 @@ fabro-petri = { path = "../../components/fabro-petri" } fabro-manifest = { path = "../../components/fabro-manifest" } fabro-proc = { path = "../../foundation/fabro-proc" } fabro-sandbox = { path = "../../components/fabro-sandbox" } +fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" } +sandbox-driver.workspace = true +sandbox-driver-host.workspace = true fabro-graphviz = { path = "../../components/fabro-graphviz" } fabro-workflow = { path = "../../components/fabro-workflow" } fabro-server = { path = "../fabro-server" } diff --git a/lib/apps/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs index 190b48b4a..1c81f26f5 100644 --- a/lib/apps/fabro-cli/src/commands/exec.rs +++ b/lib/apps/fabro-cli/src/commands/exec.rs @@ -22,7 +22,7 @@ use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport}; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::middleware::{Call, Middleware, Next, Output}; use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind}; -use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox}; +use fabro_pebble_sandbox::{PebbleSandbox, SecretRedactor}; use fabro_static::EnvVars; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::{McpServerSettings, ResolvedMcpEntry}; @@ -38,6 +38,8 @@ use pebble_coding_agent::environment::Environment; use pebble_coding_agent::subagents::SubagentOptions; use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware}; use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery}; +use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec, WaitOptions}; +use sandbox_driver_host::HostProvider; use tokio::signal; use tokio_util::sync::CancellationToken; @@ -428,11 +430,11 @@ async fn run_session( eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let sandbox: Arc = Arc::new( - local_sandbox(cwd) - .await - .context("failed to create the local sandbox")?, - ); + // The provider stays alive beside the sandbox: the session's processes + // are its process groups. + let (_provider, sandbox) = host_sandbox(cwd) + .await + .context("failed to create the local sandbox")?; let permissions = args.permission_level(); #[expect( @@ -515,6 +517,31 @@ async fn run_session( .map_err(|error| anyhow::Error::new(SessionError::from(error))) } +/// The host directory `working_directory` as the sandbox the session runs +/// in, over the sandbox driver's Host provider: designated in place, never +/// removed, brought to `Running` with its Bash verified. The provider is +/// returned beside the sandbox because the session's processes are the +/// provider's process groups; it must outlive the session. +async fn host_sandbox(working_directory: PathBuf) -> AnyResult<(HostProvider, Arc)> { + let provider = HostProvider::new(); + let handle = provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(working_directory.display().to_string()), + None, + ) + .await + .with_context(|| format!("failed to designate {}", working_directory.display()))?; + sandbox_driver::activate(handle.as_ref(), &WaitOptions::default()) + .await + .context("failed to start the local sandbox")?; + let working_directory = handle.working_directory().to_string(); + let sandbox = PebbleSandbox::attach(handle, working_directory) + .await + .context("failed to read the local sandbox's platform")?; + Ok((provider, Arc::new(sandbox))) +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -522,6 +549,7 @@ mod tests { use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; use fabro_types::settings::run::{McpServerRef, McpServerSettings, ResolvedMcpEntry}; use lithos_llm::catalog::builtin; + use pebble_coding_agent::environment::{Environment, ExecRequest}; use super::{AgentArgs, resolve_provider_id, run_mcp_servers_for_exec, summarizer_model}; use crate::args::{ExecOutputFormat, PermissionsArg}; @@ -602,4 +630,54 @@ mod tests { assert!(selector.starts_with("anthropic/"), "{selector}"); assert_ne!(selector, "anthropic/claude-opus-4-6"); } + + #[tokio::test] + async fn the_session_sandbox_designates_the_directory_on_the_host_provider() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let (_provider, sandbox) = super::host_sandbox(directory.path().to_path_buf()) + .await + .expect("a host sandbox over the directory"); + + assert_eq!( + std::path::Path::new(Environment::working_directory(&*sandbox)), + directory.path().canonicalize().expect("canonical path") + ); + assert_ne!(Environment::platform(&*sandbox), "unknown"); + let outcome = Environment::exec(&*sandbox, ExecRequest { + command: "pwd", + timeout_ms: Some(10_000), + working_dir: None, + env_vars: None, + cancel_token: None, + output_bytes_cap: None, + output_sink: None, + }) + .await + .expect("a command runs in the sandbox"); + assert_eq!(outcome.result.exit_code, Some(0)); + assert_eq!( + std::path::Path::new(outcome.result.stdout.trim()) + .canonicalize() + .expect("the reported directory exists"), + directory.path().canonicalize().expect("canonical path") + ); + assert!( + directory.path().is_dir(), + "a designated directory is never removed" + ); + } + + #[tokio::test] + async fn a_missing_directory_is_refused_before_the_session_starts() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let missing = directory.path().join("absent"); + let error = super::host_sandbox(missing) + .await + .err() + .expect("a directory that does not exist cannot be designated"); + assert!( + error.to_string().contains("failed to designate"), + "{error:#}" + ); + } } diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index 2dee109a4..2bc09d2e7 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -34,6 +34,7 @@ fabro-slack = { path = "../../components/fabro-slack" } fabro-workflow = { path = "../../components/fabro-workflow" } fabro-workflow-version = { path = "../../components/fabro-workflow-version" } fabro-sandbox = { path = "../../components/fabro-sandbox" } +fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" } sandbox-driver.workspace = true fabro-github = { path = "../../components/fabro-github" } pebble-agent.workspace = true diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 82781d8a0..75cc62613 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -15,7 +15,7 @@ use fabro_api::types::{ }; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::{FabroClient, ModelSelectionError, selection}; -use fabro_sandbox::SecretRedactor; +use fabro_pebble_sandbox::{PebbleSandbox, SecretRedactor}; use fabro_sandbox::reconnect::reconnect_for_run; use fabro_store::{ProjectedRunSession, project_run_session, project_run_sessions}; use fabro_tool::fabro_client::ClientBackend; @@ -737,7 +737,16 @@ async fn build_agent( .activate() .await .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?; - let environment: Arc = Arc::new(sandbox); + let handle = Arc::clone( + sandbox + .handle() + .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?, + ); + let environment: Arc = Arc::new( + PebbleSandbox::attach(handle, sandbox.working_directory()) + .await + .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?, + ); // Give the Ask Fabro agent access to read-only run-inspection tools scoped // to its owning run. The session reaches the local HTTP API via a same-run diff --git a/lib/components/fabro-pebble-sandbox/Cargo.toml b/lib/components/fabro-pebble-sandbox/Cargo.toml new file mode 100644 index 000000000..ba66a9315 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "fabro-pebble-sandbox" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "A sandbox-driver handle as the Environment pebble's coding agent runs in" + +[features] +test-support = ["dep:sandbox-driver-testing"] + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +sandbox-driver.workspace = true +sandbox-driver-testing = { workspace = true, optional = true } +pebble-coding-agent.workspace = true +async-trait.workspace = true +tokio-util.workspace = true +tracing.workspace = true +fabro-redact.workspace = true +fabro-util = { path = "../../foundation/fabro-util" } +fabro-types = { path = "../../foundation/fabro-types" } + +[dev-dependencies] +pebble-coding-agent = { workspace = true, features = ["test-util"] } +sandbox-driver-host.workspace = true +sandbox-driver-testing.workspace = true +serde_json.workspace = true +tempfile = "3" +thiserror.workspace = true +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/components/fabro-pebble-sandbox/src/environment.rs b/lib/components/fabro-pebble-sandbox/src/environment.rs new file mode 100644 index 000000000..2e6f8dcdf --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/environment.rs @@ -0,0 +1,895 @@ +//! A sandbox-driver handle as the [`Environment`] pebble's coding agent +//! runs in. +//! +//! Pebble's tools speak the `Environment` contract; the sandbox driver +//! speaks facets. [`PebbleSandbox`] is the mapping between the two, and +//! nothing else: every path resolves the way Fabro resolves it (a relative +//! path against the run's working directory, which may sit below the +//! provider's own), every command runs through [`SandboxExec`] with Fabro's +//! exec policy, and every failure keeps its driver cause. +//! +//! Where the two contracts differ, pebble's wins here because the model reads +//! pebble's: a glob that pebble rejects is rejected before the driver sees it, +//! a directory listing is in tree order, and a command with no retention cap +//! still drains under the driver's default buffer rather than without bound. +//! Output a provider lost on its own transport +//! ([`ExecStreamingResult::output_loss`]) has no slot in pebble's contract, +//! so it is written where the model already reads: one line at the end of +//! stderr. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use fabro_util::workspace_glob::WorkspaceGlob; +use pebble_coding_agent::environment::support::{capture_stats, tree_order, validate_glob}; +use pebble_coding_agent::environment::{ + DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome, + ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions, +}; +use pebble_coding_agent::mcp::PortRoutes; +use sandbox_driver::{ + ExecControls, ExecSpec, ExecStreamingResult, FileKind, OutputLoss, OutputSink, OutputStream, + Sandbox, Search as _, WalkOptions, +}; +use tracing::warn; + +use crate::exec::{ExecResultExt as _, SandboxExec, command_termination, program_exit_code}; +use crate::path::{join_sandbox_path, resolve_path}; +use crate::ports; + +/// A sandbox-driver handle working in one directory, as pebble's +/// [`Environment`]. +/// +/// The handle is a sandbox someone else brought to `Running`: Petri for a +/// run's sandbox, `fabro exec` for the host directory it starts in. The +/// working directory is the run's, which may sit below the handle's own. +pub struct PebbleSandbox { + handle: Arc, + working_dir: String, + platform: String, + os_version: String, +} + +impl PebbleSandbox { + /// Wraps a running `handle` working in `working_dir`, asking the sandbox + /// for its platform once. + pub async fn attach( + handle: Arc, + working_dir: impl Into, + ) -> sandbox_driver::Result { + let info = handle.platform_info().await?; + let platform = fabro_platform_name(&info.os).to_string(); + let os_version = if info.version.is_empty() { + platform.clone() + } else { + format!("{platform} {}", info.version) + }; + Ok(Self::with_platform( + handle, + working_dir, + platform, + os_version, + )) + } + + /// Wraps `handle` with a platform already known, so no round trip to + /// the sandbox is needed before pebble reads it. + #[must_use] + pub fn with_platform( + handle: Arc, + working_dir: impl Into, + platform: impl Into, + os_version: impl Into, + ) -> Self { + Self { + handle, + working_dir: working_dir.into(), + platform: platform.into(), + os_version: os_version.into(), + } + } + + /// The driver handle underneath, for the facets pebble's contract does + /// not carry. + #[must_use] + pub fn handle(&self) -> &Arc { + &self.handle + } + + /// The directory the agent works in. + #[must_use] + pub fn working_directory(&self) -> &str { + &self.working_dir + } + + /// Fabro's exec policy over the handle's exec facet, working in the + /// agent's directory. + #[must_use] + pub fn exec(&self) -> SandboxExec<'_> { + SandboxExec::new(self.handle.exec()).with_working_dir(self.working_dir.clone()) + } + + /// Pebble's port routes over the handle's preview URLs, when the + /// provider has them; see [`ports::port_routes`]. + #[must_use] + pub fn port_routes(&self) -> Option> { + ports::port_routes(&self.handle) + } + + /// A caller path as the driver will see it. + fn resolve(&self, path: &str) -> String { + resolve_path(path, &self.working_dir) + } + + async fn file_exists(&self, path: &str) -> EnvResult { + self.handle + .fs() + .exists(&self.resolve(path)) + .await + .map_err(|error| environment_error(&format!("Failed to stat {path}"), error)) + } + + /// The traversal base the driver walks. A base at the working directory + /// walks relative to it so every path component of `relative_start` is + /// checked against symlinks; any other base is walked as given. + fn walk_base(&self, base: &str, relative_start: &str) -> String { + if base == self.working_dir || base.is_empty() || base == "." { + if relative_start.is_empty() { + ".".to_string() + } else { + relative_start.to_string() + } + } else { + join_sandbox_path(&self.resolve(base), relative_start) + } + } +} + +/// Fabro names the macOS platform `darwin`, as `uname -s` does. +fn fabro_platform_name(os: &str) -> &str { + match os { + "macos" => "darwin", + other => other, + } +} + +#[async_trait] +impl Environment for PebbleSandbox { + fn working_directory(&self) -> &str { + &self.working_dir + } + + fn platform(&self) -> &str { + &self.platform + } + + fn os_version(&self) -> String { + self.os_version.clone() + } + + async fn read_file_bytes(&self, path: &str) -> EnvResult> { + self.handle + .fs() + .read(&self.resolve(path)) + .await + .map_err(|error| environment_error(&format!("Failed to read {path}"), error)) + } + + async fn write_file(&self, path: &str, content: &str) -> EnvResult<()> { + self.handle + .fs() + .write(&self.resolve(path), content.as_bytes()) + .await + .map_err(|error| environment_error(&format!("Failed to write {path}"), error)) + } + + async fn rename_file(&self, source: &str, destination: &str) -> EnvResult<()> { + let resolved_source = self.resolve(source); + let resolved_destination = self.resolve(destination); + if !self.file_exists(source).await? { + return Err(EnvironmentError::new( + EnvironmentErrorKind::NotFound, + format!("Failed to move {source}: file does not exist"), + )); + } + // The same path spelled twice is a move to itself, which must leave + // the file where it is. Aliases the sandbox's own filesystem would + // resolve (a symlinked parent, a hard link) are not checked: Fabro has + // no remote `realpath`, and a driver `mv a a` is a no-op anyway. + if normalize(&resolved_source) == normalize(&resolved_destination) { + return Ok(()); + } + // The destination's parent is created first, and a parent that is a + // file fails here, before anything has moved, so the source stays + // intact as the contract requires. + if let Some(parent) = parent_directory(&resolved_destination) { + self.handle.fs().create_dir(parent).await.map_err(|error| { + environment_error( + &format!("Failed to create the parent directory of {destination}"), + error, + ) + })?; + } + self.handle + .fs() + .rename(&resolved_source, &resolved_destination) + .await + .map_err(|error| { + environment_error(&format!("Failed to move {source} to {destination}"), error) + }) + } + + async fn delete_file(&self, path: &str) -> EnvResult<()> { + // The driver's delete is idempotent; pebble's is a `remove_file`, which + // reports a path that is not there. + if !self.file_exists(path).await? { + return Err(EnvironmentError::new( + EnvironmentErrorKind::NotFound, + format!("Failed to delete {path}: file does not exist"), + )); + } + self.handle + .fs() + .delete(&self.resolve(path), false) + .await + .map_err(|error| environment_error(&format!("Failed to delete {path}"), error)) + } + + async fn file_exists(&self, path: &str) -> EnvResult { + Self::file_exists(self, path).await + } + + async fn list_directory(&self, path: &str, depth: Option) -> EnvResult> { + let mut entries: Vec = self + .handle + .fs() + .list_dir(&self.resolve(path), depth.unwrap_or(1)) + .await + .map_err(|error| environment_error(&format!("Failed to list {path}"), error))? + .into_iter() + .map(|entry| DirEntry { + is_dir: entry.kind == FileKind::Directory, + size: (entry.kind == FileKind::File) + .then_some(entry.size) + .flatten(), + name: entry.path, + }) + .collect(); + // The driver lists in flat lexicographic order of the whole relative + // path, where `foo-bar` sorts between `foo` and `foo/x`. Pebble lists + // in tree order, and says how. + tree_order(&mut entries); + Ok(entries) + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> EnvResult> { + let search = self.handle.search().ok_or_else(|| { + EnvironmentError::new( + EnvironmentErrorKind::Unsupported, + "Sandbox provider does not support search", + ) + })?; + let mut driver_options = sandbox_driver::GrepOptions::default(); + driver_options.case_insensitive = options.case_insensitive; + driver_options.max_matches = options.max_results; + driver_options.include = options.glob_filter.clone(); + let matches = search + .grep(pattern, &self.resolve(path), &driver_options) + .await + .map_err(|error| environment_error("Failed to search file contents", error))?; + Ok(matches + .into_iter() + .map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line)) + .collect()) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> EnvResult> { + // Validated by pebble's own grammar before the driver sees the + // pattern, so the reason reaches the model in pebble's words and the + // patterns pebble rejects are rejected even where Fabro's glob would + // accept them. + validate_glob(pattern)?; + let glob = WorkspaceGlob::try_new(pattern).map_err(|error| { + EnvironmentError::with_source(EnvironmentErrorKind::Io, "Invalid glob pattern", error) + })?; + let search = self.handle.search().ok_or_else(|| { + EnvironmentError::new( + EnvironmentErrorKind::Unsupported, + "Sandbox provider does not support search", + ) + })?; + let base = path.unwrap_or(&self.working_dir); + let relative_start = glob.traversal_root(); + let walked = search + .walk( + &self.walk_base(base, relative_start), + &WalkOptions::default(), + ) + .await + .map_err(|error| environment_error("Failed to match files", error))?; + let mut relative_paths: Vec = walked + .into_iter() + .map(|file| join_sandbox_path(relative_start, &file.path)) + .filter(|relative_path| glob.is_match(relative_path)) + .collect(); + relative_paths.sort(); + Ok(relative_paths + .into_iter() + .map(|relative_path| join_sandbox_path(base, &relative_path)) + .collect()) + } + + async fn exec(&self, request: ExecRequest<'_>) -> EnvResult { + let ExecRequest { + command, + timeout_ms, + working_dir, + env_vars, + cancel_token, + output_bytes_cap, + output_sink, + } = request; + let mut spec = ExecSpec::bash(command).no_timeout(); + if let Some(timeout_ms) = timeout_ms { + spec = spec.timeout(Duration::from_millis(timeout_ms)); + } + if let Some(dir) = working_dir { + spec = spec.working_dir(dir); + } + for (key, value) in env_vars.into_iter().flatten() { + spec = spec.env_var(key, value); + } + let controls = ExecControls { + term: cancel_token, + sink: output_sink.map(adapt_output_sink), + // `None` asks pebble for no cap at all. Fabro's exec policy fills + // its default buffer when the cap is unset, so a command with no + // cap drains under that default rather than without bound; the + // capture counts still say what was dropped. + retained_output_limit: output_bytes_cap, + ..ExecControls::default() + }; + let streaming = self + .exec() + .run_streaming(spec, controls) + .await + .map_err(|error| { + let kind = match &error { + sandbox_driver::Error::Transport(_) => EnvironmentErrorKind::Io, + sandbox_driver::Error::Unsupported { .. } => EnvironmentErrorKind::Unsupported, + _ => EnvironmentErrorKind::Spawn, + }; + EnvironmentError::with_source(kind, "Failed to run the command", error) + })?; + Ok(exec_outcome( + streaming, + output_bytes_cap, + program_name(command), + )) + } +} + +/// Pebble's outcome for a finished command: the driver's result read the way +/// Fabro reads it, plus the provider's own output loss written where the +/// model reads stderr. +/// +/// A provider whose transport tore (Daytona's text-only toolbox) completes +/// the command and reports what it discarded in +/// [`ExecStreamingResult::output_loss`] rather than failing it. The frames +/// are gone, the stream they belonged to is unknown, and the counts are of +/// encoded bytes, so they cannot be folded into either stream's capture +/// accounting without guessing; the loss is one line at the end of stderr, +/// where the model and the run log see it, and one log event for the +/// operator. The driver's `truncated` flags on the captures already say the +/// counts undercount. +fn exec_outcome( + streaming: ExecStreamingResult, + output_bytes_cap: Option, + program: &str, +) -> ExecOutcome { + let loss = streaming.output_loss; + let result = streaming.result; + let mut stderr = result.stderr_lossy(); + if loss.is_lossy() { + warn!( + program = %program, + dropped_frames = loss.dropped_frames, + dropped_bytes = loss.dropped_bytes, + "Sandbox provider dropped command output" + ); + if !stderr.is_empty() && !stderr.ends_with('\n') { + stderr.push('\n'); + } + stderr.push_str(&output_loss_line(loss)); + } + ExecOutcome { + result: ExecResult { + stdout: result.stdout_lossy(), + stderr, + exit_code: program_exit_code(result.termination, result.exit_code), + termination: command_termination(result.termination), + duration_ms: result.duration_ms(), + }, + streams_separated: streaming.streams_separated, + stdout_capture: capture_stats(streaming.stdout_capture.observed_bytes, output_bytes_cap), + stderr_capture: capture_stats(streaming.stderr_capture.observed_bytes, output_bytes_cap), + } +} + +/// The line stderr ends with when the provider dropped output. +fn output_loss_line(loss: OutputLoss) -> String { + format!( + "[sandbox] {} output frame(s), {} bytes dropped by the provider\n", + loss.dropped_frames, loss.dropped_bytes + ) +} + +/// Bytes of a command's first word a log event carries. +const PROGRAM_NAME_BYTES: usize = 64; + +/// The word a command starts with, bounded, for a log event that must not +/// carry the command itself. +fn program_name(command: &str) -> &str { + let word = command.split_whitespace().next().unwrap_or_default(); + &word[..word.floor_char_boundary(PROGRAM_NAME_BYTES)] +} + +/// A path with its redundant separators and `.` segments removed, for +/// deciding whether two spellings name the same file. +fn normalize(path: &str) -> String { + let absolute = path.starts_with('/'); + let joined = path + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>() + .join("/"); + if absolute { + format!("/{joined}") + } else { + joined + } +} + +/// The directory a path is in, when the path names one. +fn parent_directory(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + let (parent, _) = trimmed.rsplit_once('/')?; + if parent.is_empty() { + return Some("/"); + } + Some(parent) +} + +/// Feeds the driver's asynchronous chunk callback into pebble's synchronous +/// sink. +fn adapt_output_sink(sink: ExecOutputSink) -> OutputSink { + Arc::new(move |stream, chunk: Vec| { + let stream = match stream { + OutputStream::Stdout => ExecOutputStream::Stdout, + OutputStream::Stderr => ExecOutputStream::Stderr, + }; + sink(stream, &chunk); + Box::pin(async { Ok(()) }) + }) +} + +/// A sandbox failure as pebble classifies it, keeping the driver cause. +fn environment_error(message: &str, error: sandbox_driver::Error) -> EnvironmentError { + let kind = match &error { + sandbox_driver::Error::NotFound { .. } => EnvironmentErrorKind::NotFound, + sandbox_driver::Error::Unsupported { .. } => EnvironmentErrorKind::Unsupported, + _ => EnvironmentErrorKind::Io, + }; + EnvironmentError::with_source(kind, message, error) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use pebble_coding_agent::test_support::EnvironmentContract; + use sandbox_driver::{ + Capabilities, Exec, Filesystem, PlatformInfo, SandboxId, SandboxProvider as _, + SandboxSource, SandboxSpec, SandboxStatus, Search, SpawnSpec, StdioProcess, Termination, + }; + use sandbox_driver_host::HostProvider; + use sandbox_driver_testing::ScriptedSandbox; + use tokio::fs; + + use super::*; + use crate::test_support::{MockSandbox, exec_result}; + + /// The environment over the driver's Host provider, in a directory that + /// goes away with the test. + async fn host_environment() -> (tempfile::TempDir, HostProvider, PebbleSandbox) { + let directory = tempfile::tempdir().expect("a temporary directory"); + let provider = HostProvider::new(); + let handle = provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(directory.path().display().to_string()), + None, + ) + .await + .expect("a host sandbox"); + let working_dir = handle.working_directory().to_string(); + let sandbox = PebbleSandbox::attach(handle, working_dir) + .await + .expect("the host platform"); + (directory, provider, sandbox) + } + + #[tokio::test] + async fn host_files_satisfy_pebbles_environment_contract() { + let (_directory, _provider, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_files() + .await + .expect("file contract"); + } + + #[tokio::test] + async fn host_search_satisfies_pebbles_environment_contract() { + let (_directory, _provider, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_search() + .await + .expect("search contract"); + } + + #[tokio::test] + async fn host_commands_satisfy_pebbles_environment_contract() { + let (_directory, _provider, sandbox) = host_environment().await; + EnvironmentContract::new(&sandbox, "contract") + .verify_commands() + .await + .expect("command contract"); + } + + #[tokio::test] + async fn the_platform_is_learned_from_the_sandbox() { + let (_directory, _provider, sandbox) = host_environment().await; + let expected = if cfg!(target_os = "macos") { + "darwin" + } else { + std::env::consts::OS + }; + assert_eq!(Environment::platform(&sandbox), expected); + assert!(sandbox.os_version().starts_with(expected)); + } + + #[tokio::test] + async fn a_directory_listing_is_in_tree_order() { + let (directory, provider, sandbox) = host_environment().await; + for name in ["foo/x.txt", "foo-bar/y.txt", "foo.txt"] { + Environment::write_file(&sandbox, name, "content") + .await + .expect("fixture"); + } + let names: Vec = Environment::list_directory(&sandbox, ".", Some(2)) + .await + .expect("listing") + .into_iter() + .map(|entry| entry.name) + .collect(); + assert_eq!(names, [ + "foo", + "foo/x.txt", + "foo-bar", + "foo-bar/y.txt", + "foo.txt" + ]); + drop((directory, provider)); + } + + #[tokio::test] + async fn glob_reports_paths_under_the_declared_base_and_skips_symlinks() { + let (directory, provider, sandbox) = host_environment().await; + let root = directory.path(); + fs::create_dir_all(root.join(".ai/reports")).await.unwrap(); + fs::create_dir_all(root.join(".ai/target")).await.unwrap(); + fs::write(root.join(".ai/reports/result.md"), "report") + .await + .unwrap(); + fs::write(root.join(".ai/reports/empty.md"), "") + .await + .unwrap(); + fs::write(root.join(".ai/target/ignored.md"), "ignored") + .await + .unwrap(); + + let working_dir = sandbox.working_directory().to_string(); + let globbed = Environment::glob(&sandbox, "**/*.md", None).await.unwrap(); + assert_eq!(globbed, vec![ + format!("{working_dir}/.ai/reports/empty.md"), + format!("{working_dir}/.ai/reports/result.md"), + format!("{working_dir}/.ai/target/ignored.md"), + ]); + let scoped = Environment::glob(&sandbox, "*.md", Some(".ai/reports")) + .await + .unwrap(); + assert_eq!(scoped.len(), 2); + + #[cfg(unix)] + { + let target = root.join("elsewhere"); + fs::create_dir_all(&target).await.unwrap(); + fs::write(target.join("lib.rs"), "").await.unwrap(); + std::os::unix::fs::symlink(&target, root.join("linked")).unwrap(); + let results = Environment::glob(&sandbox, "linked/**/*.rs", None) + .await + .unwrap(); + assert!(results.is_empty(), "{results:?}"); + } + drop((directory, provider)); + } + + #[tokio::test] + async fn grep_returns_path_line_content_triples() { + let (directory, provider, sandbox) = host_environment().await; + fs::write( + directory.path().join("test.rs"), + "fn main() {\n println!(\"hello\");\n}\n", + ) + .await + .unwrap(); + let results = Environment::grep(&sandbox, "println", "test.rs", &GrepOptions::default()) + .await + .unwrap(); + assert_eq!(results, ["test.rs:2: println!(\"hello\");"]); + drop((directory, provider)); + } + + #[test] + fn a_path_spelled_two_ways_is_one_path() { + assert_eq!(normalize("/work//a/./b.txt"), "/work/a/b.txt"); + assert_eq!(parent_directory("/work/a/b.txt"), Some("/work/a")); + assert_eq!(parent_directory("/b.txt"), Some("/")); + assert_eq!(parent_directory("b.txt"), None); + } + + fn request(command: &str) -> ExecRequest<'_> { + ExecRequest { + command, + timeout_ms: Some(10_000), + working_dir: None, + env_vars: None, + cancel_token: None, + output_bytes_cap: None, + output_sink: None, + } + } + + fn output_loss(dropped_frames: u64, dropped_bytes: u64) -> OutputLoss { + let mut loss = OutputLoss::default(); + loss.dropped_frames = dropped_frames; + loss.dropped_bytes = dropped_bytes; + loss + } + + #[tokio::test] + async fn a_lossless_command_hands_back_stderr_as_the_provider_wrote_it() { + let mock = MockSandbox { + exec_result: exec_result( + "built\n", + "warning: unused\n", + Some(0), + Termination::Exited, + 7, + ), + ..MockSandbox::linux() + }; + let outcome = Environment::exec(&*mock.sandbox(), request("cargo build")) + .await + .expect("a scripted command"); + assert_eq!(outcome.result.stdout, "built\n"); + assert_eq!(outcome.result.stderr, "warning: unused\n"); + assert_eq!(outcome.result.exit_code, Some(0)); + assert_eq!( + outcome.stderr_capture.observed_bytes, + "warning: unused\n".len() + ); + // The command ran in the mock's working directory under Fabro's + // stop grace. + let spec = mock.driver().scripted_exec().recorded().pop().unwrap(); + assert_eq!(spec.working_dir.as_deref(), Some("/home/test")); + assert_eq!(spec.stop_grace, Some(crate::DEFAULT_STOP_GRACE)); + } + + #[test] + fn a_provider_output_loss_ends_stderr_with_one_line() { + let mut streaming = ExecStreamingResult::new(exec_result( + "built\n", + "warning: torn", + Some(1), + Termination::Exited, + 7, + )); + streaming.output_loss = output_loss(2, 4096); + + let outcome = exec_outcome(streaming, Some(1024), "cargo"); + + assert_eq!(outcome.result.stdout, "built\n"); + assert_eq!( + outcome.result.stderr, + "warning: torn\n[sandbox] 2 output frame(s), 4096 bytes dropped by the provider\n" + ); + assert_eq!(outcome.result.exit_code, Some(1)); + assert_eq!(outcome.result.duration_ms, 7); + // The loss is not folded into either stream's accounting. + assert_eq!(outcome.stdout_capture.observed_bytes, "built\n".len()); + assert_eq!(outcome.stderr_capture.observed_bytes, "warning: torn".len()); + } + + #[test] + fn a_provider_output_loss_with_no_stderr_is_the_line_alone() { + let mut streaming = + ExecStreamingResult::new(exec_result("", "", Some(0), Termination::Exited, 1)); + streaming.output_loss = output_loss(1, 80); + let outcome = exec_outcome(streaming, None, "sh"); + assert_eq!( + outcome.result.stderr, + "[sandbox] 1 output frame(s), 80 bytes dropped by the provider\n" + ); + } + + #[test] + fn a_log_event_names_the_first_word_of_a_command_bounded() { + assert_eq!(program_name("cargo build --release"), "cargo"); + assert_eq!(program_name(" \n ls"), "ls"); + assert_eq!(program_name(""), ""); + let long = "x".repeat(PROGRAM_NAME_BYTES + 10); + assert_eq!(program_name(&long).len(), PROGRAM_NAME_BYTES); + let multibyte = "é".repeat(PROGRAM_NAME_BYTES); + assert!(program_name(&multibyte).len() <= PROGRAM_NAME_BYTES); + } + + /// The driver's scripted sandbox with an exec facet that reports a + /// provider output loss on every command, as Daytona does after a torn + /// frame. The scripted double itself has no knob for the loss. + struct LossySandbox { + inner: Arc, + exec: LossyExec, + } + + struct LossyExec { + inner: Arc, + loss: OutputLoss, + } + + impl LossySandbox { + fn new(inner: Arc, loss: OutputLoss) -> Self { + Self { + exec: LossyExec { + inner: Arc::clone(&inner), + loss, + }, + inner, + } + } + } + + #[async_trait] + impl Exec for LossyExec { + async fn run(&self, spec: &ExecSpec) -> sandbox_driver::Result { + self.inner.scripted_exec().run(spec).await + } + + async fn run_streaming( + &self, + spec: &ExecSpec, + controls: ExecControls, + ) -> sandbox_driver::Result { + let mut streaming = self + .inner + .scripted_exec() + .run_streaming(spec, controls) + .await?; + streaming.output_loss = self.loss; + streaming.stdout_capture.truncated = true; + streaming.stderr_capture.truncated = true; + Ok(streaming) + } + + async fn spawn_stdio(&self, spec: &SpawnSpec) -> sandbox_driver::Result { + self.inner.scripted_exec().spawn_stdio(spec).await + } + } + + #[async_trait] + impl Sandbox for LossySandbox { + fn id(&self) -> &SandboxId { + self.inner.id() + } + + fn capabilities(&self) -> &Capabilities { + // The scripted sandbox's builder method of the same name shadows + // the trait's. + Sandbox::capabilities(&*self.inner) + } + + async fn describe(&self) -> sandbox_driver::Result { + self.inner.describe().await + } + + fn working_directory(&self) -> &str { + self.inner.working_directory() + } + + async fn environment(&self) -> sandbox_driver::Result> { + self.inner.environment().await + } + + fn runtime_directory(&self) -> Option<&str> { + Sandbox::runtime_directory(&*self.inner) + } + + async fn platform_info(&self) -> sandbox_driver::Result { + self.inner.platform_info().await + } + + async fn start(&self) -> sandbox_driver::Result<()> { + self.inner.start().await + } + + async fn stop(&self) -> sandbox_driver::Result<()> { + self.inner.stop().await + } + + async fn delete(&self) -> sandbox_driver::Result<()> { + self.inner.delete().await + } + + fn exec(&self) -> &dyn Exec { + &self.exec + } + + fn fs(&self) -> &dyn Filesystem { + self.inner.fs() + } + + fn provider_search(&self) -> Option<&dyn Search> { + self.inner.provider_search() + } + } + + #[tokio::test] + async fn a_lossy_command_tells_the_model_what_the_provider_dropped() { + let scripted = + Arc::new( + ScriptedSandbox::with_id_and_working_dir("lossy", "/work") + .platform(PlatformInfo::new("linux", "x86_64", "Linux 6.1.0")), + ); + scripted.scripted_exec().set_default(exec_result( + "built\n", + "warning: torn", + Some(0), + Termination::Exited, + 7, + )); + let sandbox = PebbleSandbox::with_platform( + Arc::new(LossySandbox::new(scripted, output_loss(3, 512))), + "/work", + "linux", + "Linux 6.1.0", + ); + + let outcome = Environment::exec(&sandbox, request("cargo build")) + .await + .expect("a lossy command completes rather than fails"); + + assert_eq!(outcome.result.stdout, "built\n"); + assert_eq!( + outcome.result.stderr, + "warning: torn\n[sandbox] 3 output frame(s), 512 bytes dropped by the provider\n" + ); + assert_eq!(outcome.result.exit_code, Some(0)); + assert!(outcome.streams_separated); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/exec.rs b/lib/components/fabro-pebble-sandbox/src/exec.rs new file mode 100644 index 000000000..a86e5e262 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/exec.rs @@ -0,0 +1,641 @@ +//! Fabro's command execution policy over the sandbox-driver [`Exec`] facet. +//! +//! The vocabulary is the driver's own: an [`ExecSpec`] and [`ExecControls`] +//! go in, an [`ExecResult`] or [`ExecStreamingResult`] comes out. This +//! module adds Fabro's policy on the way in and Fabro's reading of a result +//! on the way out. +//! +//! A command runs as Bash source under `bash -c` with `BASH_ENV` blanked by +//! the driver whatever the caller passed, and ends in one of three ways: +//! +//! - **timeout**: the spec's timeout fires and the provider runs the stop +//! ladder Fabro asks for: `TERM`, then `KILL` after +//! [`SandboxExec::stop_grace`]. The result reports [`Termination::TimedOut`]. +//! - **cancellation**: the caller's [`CancellationToken`] is the `term` stop; +//! the provider escalates to `KILL` after the same grace. The result reports +//! [`Termination::Cancelled`]. +//! - **exit**: the process ended on its own. +//! +//! Output is drained regardless of the retention cap and delivered live +//! through the caller's [`sandbox_driver::OutputSink`]. Fabro reads command +//! output as text, so the policy asks the driver for +//! [`OutputSanitization::StripAll`]: terminal escape sequences and stray +//! control characters never reach a result, a sink chunk, or a tail. Secret +//! redaction stays Fabro's job and happens only when a tail is rendered for +//! events or logs ([`redacted_output_tail`]). The explicit environment +//! reaches the provider as the caller composed it: the driver filters +//! credential-shaped names out of the *inherited* host environment itself +//! and treats the spec's own variables as the deliberate channel for +//! secrets, so Fabro adds no filter of its own. + +use std::collections::HashMap; +use std::time::Duration; + +use fabro_types::{CommandTermination, ExecOutputTail}; +use sandbox_driver::{ + Exec, ExecControls, ExecResult, ExecSpec, ExecStreamingResult, OutputSanitization, Termination, +}; +use tokio_util::sync::CancellationToken; + +/// Time between `TERM` and `KILL` when Fabro stops a command. +pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2); + +/// Retention when a caller sets no cap: enough for any build log Fabro +/// renders, bounded so a runaway command cannot exhaust memory. +pub const DEFAULT_RETAINED_OUTPUT_BYTES: usize = sandbox_driver::DEFAULT_BUFFER_BYTES; + +/// How much of each output stream a redacted tail keeps by default. +pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024; + +/// Fabro's exec policy bound to one driver [`Exec`] facet. +pub struct SandboxExec<'a> { + exec: &'a dyn Exec, + stop_grace: Duration, + /// Where a command runs when the caller names no directory. `None` + /// leaves the choice to the provider's own working directory. + working_dir: Option, +} + +impl<'a> SandboxExec<'a> { + #[must_use] + pub fn new(exec: &'a dyn Exec) -> Self { + Self { + exec, + stop_grace: DEFAULT_STOP_GRACE, + working_dir: None, + } + } + + /// The directory commands run in when the caller names none. Fabro's + /// working directory can sit below the provider's, so it is passed + /// explicitly. + #[must_use] + pub fn with_working_dir(mut self, working_dir: impl Into) -> Self { + self.working_dir = Some(working_dir.into()); + self + } + + /// Time between `TERM` and `KILL` when a command is stopped; the + /// provider runs the ladder. + #[must_use] + pub fn with_stop_grace(mut self, stop_grace: Duration) -> Self { + self.stop_grace = stop_grace; + self + } + + #[must_use] + pub fn stop_grace(&self) -> Duration { + self.stop_grace + } + + /// Runs Bash source to completion and returns its captured output. + /// + /// Equivalent to `bash -c ` with a clean, non-login shell: no + /// `errexit`, no `pipefail`, `BASH_ENV` blanked. A caller that wants + /// different semantics writes them into the command. `None` for + /// `timeout` runs without a deadline. + pub async fn run( + &self, + command: &str, + timeout: Option, + working_dir: Option<&str>, + env_vars: Option<&HashMap>, + cancel_token: Option, + ) -> sandbox_driver::Result { + let mut spec = ExecSpec::bash(command).no_timeout(); + if let Some(timeout) = timeout { + spec = spec.timeout(timeout); + } + if let Some(dir) = working_dir { + spec = spec.working_dir(dir); + } + for (key, value) in env_vars.into_iter().flatten() { + spec = spec.env_var(key, value); + } + let controls = ExecControls { + term: cancel_token, + ..ExecControls::default() + }; + Ok(self.run_streaming(spec, controls).await?.result) + } + + /// Runs `spec` under Fabro's policy, delivering output through + /// `controls.sink` as it arrives. + /// + /// The policy fills what the spec leaves open: the stop grace, the + /// working directory, and the text output policy. The spec's environment + /// goes to the provider as the caller composed it. The caller's + /// `controls.term` is the `term` stop; the provider runs the grace and + /// the `kill` itself. Output beyond `controls.retained_output_limit` + /// (Fabro's default when unset) is drained and counted, not kept. + pub async fn run_streaming( + &self, + spec: ExecSpec, + mut controls: ExecControls, + ) -> sandbox_driver::Result { + let spec = self.apply_policy(spec); + if controls.retained_output_limit.is_none() { + controls.retained_output_limit = Some(DEFAULT_RETAINED_OUTPUT_BYTES); + } + self.exec.run_streaming(&spec, controls).await + } + + /// Fills what a spec leaves open. The output policy has no "unset" + /// state: the driver's default is raw, and Fabro reads command output + /// as text, so a spec still at that default gets + /// [`OutputSanitization::StripAll`]; a caller that chose another policy + /// keeps it. + fn apply_policy(&self, mut spec: ExecSpec) -> ExecSpec { + if spec.stop_grace.is_none() { + spec.stop_grace = Some(self.stop_grace); + } + if spec.working_dir.is_none() { + spec.working_dir.clone_from(&self.working_dir); + } + if spec.output_sanitization == OutputSanitization::default() { + spec.output_sanitization = OutputSanitization::StripAll; + } + spec + } +} + +/// The driver says how the command ended; Fabro's event vocabulary has two +/// stops. A timeout is the provider's deadline (the ladder ran for it); a +/// cancelled or killed command was stopped by the caller's token, by a +/// foreign `kill`, or by a provider-side abort: it did not finish and no +/// deadline passed. `Exited`, or a provider that could not tell, is a +/// completed process; nothing asserts success here. +#[must_use] +pub fn command_termination(termination: Termination) -> CommandTermination { + match termination { + Termination::TimedOut => CommandTermination::TimedOut, + Termination::Cancelled | Termination::Killed => CommandTermination::Cancelled, + _ => CommandTermination::Exited, + } +} + +/// An exit code is only the command's own when it exited on its own. A +/// stopped command may still report the shell's `128 + signal` (143 for a +/// trapped `TERM`), which events must not present as a program result. +#[must_use] +pub fn program_exit_code(termination: Termination, exit_code: Option) -> Option { + // `CommandTermination` is pebble's and non-exhaustive: only a command + // that exited on its own owns its exit code. + match command_termination(termination) { + CommandTermination::Exited => exit_code, + _ => None, + } +} + +/// Fabro's reading of a driver [`ExecResult`]: the event-facing numbers. +pub trait ExecResultExt { + /// The provider's measured run time in whole milliseconds. + fn duration_ms(&self) -> u64; + + /// The exit code when the command ended on its own; see + /// [`program_exit_code`]. + fn program_exit_code(&self) -> Option; +} + +impl ExecResultExt for ExecResult { + fn duration_ms(&self) -> u64 { + u64::try_from(self.duration.as_millis()).unwrap_or(u64::MAX) + } + + fn program_exit_code(&self) -> Option { + program_exit_code(self.termination, self.exit_code) + } +} + +/// A redacted [`ExecOutputTail`] from stdout/stderr text. Each stream is +/// redacted, then capped to its newest `max_bytes_per_stream`. Terminal +/// control sequences are not stripped here: command output reaches Fabro +/// with them already removed by the driver under [`SandboxExec`]'s output +/// policy. Pass `""` for either stream that isn't relevant. Returns `None` +/// when both streams are empty. +#[must_use] +pub fn redacted_output_tail( + stdout: &str, + stderr: &str, + max_bytes_per_stream: usize, +) -> Option { + let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream); + let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream); + let tail = ExecOutputTail { + stdout, + stderr, + stdout_truncated, + stderr_truncated, + }; + (!tail.is_empty()).then_some(tail) +} + +fn redacted_tail(text: &str, max_bytes: usize) -> (Option, bool) { + if text.is_empty() || max_bytes == 0 { + return (None, !text.is_empty()); + } + + let redacted = fabro_redact::redact_string(text); + let truncated = redacted.len() > max_bytes; + let start = if truncated { + redacted.floor_char_boundary(redacted.len() - max_bytes) + } else { + 0 + }; + let tail = redacted[start..].to_string(); + ((!tail.is_empty()).then_some(tail), truncated) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Instant; + + use sandbox_driver::{ + BASH_ENV_VAR, OutputSink, OutputStream, SandboxProvider as _, SandboxSource, SandboxSpec, + TransportError, + }; + use sandbox_driver_host::HostProvider; + use tokio::{fs, time}; + + use super::*; + + struct HostFixture { + workspace: tempfile::TempDir, + _provider: HostProvider, + sandbox: Arc, + } + + impl HostFixture { + async fn new() -> Self { + let workspace = tempfile::tempdir().unwrap(); + let provider = HostProvider::new(); + let sandbox = provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(workspace.path().display().to_string()), + None, + ) + .await + .unwrap(); + Self { + workspace, + _provider: provider, + sandbox, + } + } + + fn exec(&self) -> SandboxExec<'_> { + SandboxExec::new(self.sandbox.exec()) + } + } + + async fn run(fixture: &HostFixture, command: &str) -> ExecResult { + fixture + .exec() + .run(command, Some(Duration::from_secs(10)), None, None, None) + .await + .unwrap() + } + + fn exec_result(stdout: &str, exit_code: Option, duration_ms: u64) -> ExecResult { + let mut result = ExecResult::new( + Termination::Exited, + exit_code, + Duration::from_millis(duration_ms), + ); + result.stdout = stdout.as_bytes().to_vec(); + result + } + + #[tokio::test] + async fn runs_bash_source_and_reports_exit_code_and_streams() { + let fixture = HostFixture::new().await; + let result = run(&fixture, "echo out; echo err >&2; exit 3").await; + assert_eq!(result.stdout_lossy(), "out\n"); + assert_eq!(result.stderr_lossy(), "err\n"); + assert_eq!(result.exit_code, Some(3)); + assert_eq!(result.termination, Termination::Exited); + assert!(!result.success()); + assert!(run(&fixture, "true").await.success()); + } + + #[tokio::test] + async fn runs_bash_only_syntax_in_a_clean_non_login_shell() { + let fixture = HostFixture::new().await; + let result = run( + &fixture, + "[[ -n ${BASH_VERSION:-} ]] && shopt -q login_shell && echo login || echo nonlogin; \ + set -o | grep -E '^(errexit|pipefail)' | awk '{print $2}' | sort -u", + ) + .await; + assert_eq!(result.stdout_lossy(), "nonlogin\noff\n", "{result:?}"); + } + + #[tokio::test] + async fn a_caller_supplied_bash_env_never_runs() { + let fixture = HostFixture::new().await; + let startup = fixture.workspace.path().join("startup.sh"); + fs::write(&startup, "echo startup-source-loaded\n") + .await + .unwrap(); + let env = HashMap::from([(BASH_ENV_VAR.to_string(), startup.display().to_string())]); + let result = fixture + .exec() + .run( + "echo body", + Some(Duration::from_secs(10)), + None, + Some(&env), + None, + ) + .await + .unwrap(); + assert_eq!(result.stdout_lossy(), "body\n"); + } + + #[tokio::test] + async fn explicit_variables_reach_the_command_as_composed() { + let fixture = HostFixture::new().await; + let env = HashMap::from([ + ("FABRO_WORKER_TOKEN".to_string(), "deliberate".to_string()), + ("MY_VAR".to_string(), "ok".to_string()), + ]); + let stdout = fixture + .exec() + .run("env", Some(Duration::from_secs(10)), None, Some(&env), None) + .await + .unwrap() + .stdout_lossy(); + assert!(stdout.contains("FABRO_WORKER_TOKEN=deliberate"), "{stdout}"); + assert!(stdout.contains("MY_VAR=ok"), "{stdout}"); + } + + #[tokio::test] + async fn the_working_directory_applies_when_the_caller_names_none() { + let fixture = HostFixture::new().await; + let nested = fixture.workspace.path().join("nested"); + fs::create_dir_all(&nested).await.unwrap(); + let stdout = SandboxExec::new(fixture.sandbox.exec()) + .with_working_dir(nested.display().to_string()) + .run("pwd", Some(Duration::from_secs(10)), None, None, None) + .await + .unwrap() + .stdout_lossy(); + assert_eq!( + std::path::Path::new(stdout.trim()).canonicalize().unwrap(), + nested.canonicalize().unwrap() + ); + } + + #[tokio::test] + async fn timeout_runs_the_ladder_and_reports_timed_out() { + let fixture = HostFixture::new().await; + let started = Instant::now(); + let result = fixture + .exec() + .run( + "sleep 10", + Some(Duration::from_millis(200)), + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(result.termination, Termination::TimedOut); + assert_eq!(result.program_exit_code(), None); + assert!( + started.elapsed() < Duration::from_secs(5), + "sleep honours TERM, so KILL should not have been needed" + ); + } + + #[tokio::test] + async fn a_command_that_ignores_term_is_killed_after_the_grace_period() { + let fixture = HostFixture::new().await; + let started = Instant::now(); + let result = fixture + .exec() + .with_stop_grace(Duration::from_millis(300)) + .run( + "trap '' TERM; sleep 10", + Some(Duration::from_millis(100)), + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(result.termination, Termination::TimedOut); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(400), "{elapsed:?}"); + assert!(elapsed < Duration::from_secs(5), "{elapsed:?}"); + } + + #[tokio::test] + async fn cancellation_reports_cancelled() { + let fixture = HostFixture::new().await; + let token = CancellationToken::new(); + let cancel = token.clone(); + tokio::spawn(async move { + time::sleep(Duration::from_millis(100)).await; + cancel.cancel(); + }); + let result = fixture + .exec() + .run( + "sleep 10", + Some(Duration::from_secs(30)), + None, + None, + Some(token), + ) + .await + .unwrap(); + assert_eq!(result.termination, Termination::Cancelled); + assert_eq!(result.program_exit_code(), None); + } + + #[tokio::test] + async fn streaming_delivers_live_chunks_and_drains_past_the_retention_cap() { + let fixture = HostFixture::new().await; + let seen = Arc::new(Mutex::new(Vec::::new())); + let sink_seen = Arc::clone(&seen); + let sink: OutputSink = Arc::new(move |stream, chunk| { + let seen = Arc::clone(&sink_seen); + Box::pin(async move { + assert_eq!(stream, OutputStream::Stdout); + seen.lock().unwrap().extend_from_slice(&chunk); + Ok(()) + }) + }); + let streaming = fixture + .exec() + .run_streaming( + ExecSpec::bash("for i in $(seq 1 200); do echo line-$i; done") + .timeout(Duration::from_secs(10)), + ExecControls { + sink: Some(sink), + retained_output_limit: Some(64), + ..ExecControls::default() + }, + ) + .await + .unwrap(); + assert!(streaming.result.success()); + assert!(streaming.live_streaming); + assert!(streaming.streams_separated); + let delivered = seen.lock().unwrap().len(); + assert_eq!(streaming.stdout_capture.observed_bytes, delivered); + assert!(streaming.stdout_capture.omitted_bytes > 0); + assert!(streaming.result.stdout.len() <= 64); + assert!(streaming.result.stdout.starts_with(b"line-1\n")); + assert!(streaming.result.stdout.ends_with(b"line-200\n")); + } + + #[tokio::test] + async fn stdin_bytes_are_written_exactly_then_closed() { + let fixture = HostFixture::new().await; + let stdin = b"first line\n$(touch must-not-run)\nlast line".to_vec(); + let streaming = fixture + .exec() + .run_streaming( + ExecSpec::bash("cat; test -e must-not-run && echo RAN") + .timeout(Duration::from_secs(10)) + .stdin(stdin.clone()), + ExecControls::default(), + ) + .await + .unwrap(); + assert_eq!(streaming.result.stdout, stdin); + } + + #[tokio::test] + async fn a_failing_output_sink_stops_the_command_with_an_error() { + let fixture = HostFixture::new().await; + let sink: OutputSink = Arc::new(|_, _| { + Box::pin(async { + Err(sandbox_driver::Error::Transport(TransportError::new( + "consumer gave up", + ))) + }) + }); + let error = fixture + .exec() + .run_streaming( + ExecSpec::bash("echo hello; sleep 5").timeout(Duration::from_secs(10)), + ExecControls { + sink: Some(sink), + ..ExecControls::default() + }, + ) + .await + .map(|streaming| streaming.result.termination); + // The driver either surfaces the sink failure or reports the command + // cancelled by it; both keep the consumer's error visible. + match error { + Ok(termination) => assert_eq!(termination, Termination::Cancelled), + Err(error) => assert!(error.to_string().contains("consumer gave up"), "{error}"), + } + } + + #[test] + fn termination_mapping_reads_the_drivers_verdict() { + assert_eq!( + command_termination(Termination::TimedOut), + CommandTermination::TimedOut + ); + assert_eq!( + command_termination(Termination::Cancelled), + CommandTermination::Cancelled + ); + assert_eq!( + command_termination(Termination::Killed), + CommandTermination::Cancelled + ); + assert_eq!( + command_termination(Termination::Exited), + CommandTermination::Exited + ); + } + + #[test] + fn program_exit_code_is_the_commands_own_only_when_it_exited() { + assert_eq!(program_exit_code(Termination::Exited, Some(3)), Some(3)); + assert_eq!(program_exit_code(Termination::TimedOut, Some(143)), None); + assert_eq!(program_exit_code(Termination::Cancelled, Some(143)), None); + assert_eq!(program_exit_code(Termination::Killed, Some(137)), None); + assert_eq!(exec_result("", Some(3), 42).duration_ms(), 42); + } + + #[test] + fn output_tail_redacts_before_truncating() { + let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + let tail = + redacted_output_tail(&format!("{} {secret} done", "context ".repeat(20)), "", 32) + .expect("redacted output tail"); + let stdout = tail.stdout.expect("stdout tail"); + assert!(stdout.contains("REDACTED"), "{stdout}"); + assert!(!stdout.contains("F0gH3jE6pA"), "{stdout}"); + assert!(tail.stdout_truncated); + assert!(redacted_output_tail("", "", 32).is_none()); + } + + #[tokio::test] + async fn command_output_arrives_stripped_of_terminal_control_sequences() { + let fixture = HostFixture::new().await; + let result = run( + &fixture, + "printf '\\033[31mred\\033[0m \\033]0;window-title\\007shown \\033(Bset \\033Mtwo-byte \ + \\bbackspace'", + ) + .await; + assert!(result.success(), "{result:?}"); + assert_eq!(result.stdout_lossy(), "red shown set two-byte backspace"); + } + + #[tokio::test] + async fn policy_strips_output_unless_the_caller_chose_another_policy() { + let fixture = HostFixture::new().await; + let exec = fixture.exec(); + assert_eq!( + exec.apply_policy(ExecSpec::bash("true")) + .output_sanitization, + OutputSanitization::StripAll + ); + assert_eq!( + exec.apply_policy( + ExecSpec::bash("true").output_sanitization(OutputSanitization::StripAnsi) + ) + .output_sanitization, + OutputSanitization::StripAnsi + ); + } + + #[test] + fn default_output_tail_serialized_budget_stays_below_40_kib() { + let tail = redacted_output_tail( + &"o".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), + &"e".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128), + DEFAULT_EXEC_OUTPUT_TAIL_BYTES, + ) + .expect("tail present"); + assert_eq!( + tail.stdout.as_deref().map(str::len), + Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) + ); + assert_eq!( + tail.stderr.as_deref().map(str::len), + Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) + ); + assert!(tail.stdout_truncated); + assert!(tail.stderr_truncated); + let serialized = serde_json::to_vec(&tail).expect("serialize tail"); + assert!( + serialized.len() < 40 * 1024, + "tail JSON was {} bytes", + serialized.len() + ); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/lib.rs b/lib/components/fabro-pebble-sandbox/src/lib.rs new file mode 100644 index 000000000..8262840f7 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/lib.rs @@ -0,0 +1,35 @@ +//! A sandbox-driver handle as the [`Environment`] pebble's coding agent runs +//! in. +//! +//! Petri creates and owns every run sandbox through the sandbox driver; +//! Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host +//! sandbox of its own. Pebble's tools speak the `Environment` contract; the +//! driver speaks facets. This crate is the mapping between the two, and +//! Fabro's policy on the way through: [`PebbleSandbox`] resolves paths the +//! way Fabro resolves them and runs commands under [`SandboxExec`]'s exec +//! policy; [`SandboxPortRoutes`] answers pebble's port routing with the +//! driver's preview URLs; [`SecretRedactor`] is Fabro's secret scanner on +//! the text pebble hands the model; [`display_for_log`] renders a driver +//! failure with its redacted output tail. +//! +//! [`Environment`]: pebble_coding_agent::environment::Environment + +mod environment; +mod exec; +mod log; +mod path; +mod ports; +mod redact; + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use environment::PebbleSandbox; +pub use exec::{ + DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DEFAULT_RETAINED_OUTPUT_BYTES, DEFAULT_STOP_GRACE, + ExecResultExt, SandboxExec, command_termination, program_exit_code, redacted_output_tail, +}; +pub use log::{default_redacted_output_tail, display_for_log}; +pub use path::{join_sandbox_path, resolve_path}; +pub use ports::{SandboxPortRoutes, port_routes}; +pub use redact::SecretRedactor; diff --git a/lib/components/fabro-pebble-sandbox/src/log.rs b/lib/components/fabro-pebble-sandbox/src/log.rs new file mode 100644 index 000000000..2436f14e7 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/log.rs @@ -0,0 +1,151 @@ +//! A sandbox failure rendered for a log or an error response: the cause +//! chain, and the redacted tail of the output a failed command or git +//! operation left behind. + +use std::fmt::Write as _; + +use fabro_types::ExecOutputTail; +use fabro_util::error::{collect_causes, render_with_causes}; + +use crate::exec::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail}; + +/// The redacted output tail of the first sandbox-driver failure in `err`'s +/// cause chain that carries command output: a command that ran and failed, +/// or a git operation whose command output the driver kept as evidence. +#[must_use] +pub fn default_redacted_output_tail( + err: &(dyn std::error::Error + 'static), +) -> Option { + let mut current = Some(err); + while let Some(err) = current { + if let Some(driver) = err.downcast_ref::() { + if let Some(tail) = driver_output_tail(driver) { + return Some(tail); + } + } + current = err.source(); + } + None +} + +fn driver_output_tail(error: &sandbox_driver::Error) -> Option { + let failure = match error { + sandbox_driver::Error::Exec(failure) => failure, + sandbox_driver::Error::Git(git) => git.output()?, + _ => return None, + }; + redacted_output_tail( + &String::from_utf8_lossy(failure.stdout()), + &String::from_utf8_lossy(failure.stderr()), + DEFAULT_EXEC_OUTPUT_TAIL_BYTES, + ) +} + +/// `err` with its causes, followed by the redacted output tail when a +/// driver failure in the chain carries one. +#[must_use] +pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String { + let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err)); + if let Some(tail) = default_redacted_output_tail(err) { + append_tail_for_log( + &mut rendered, + "stderr", + tail.stderr.as_deref(), + tail.stderr_truncated, + ); + append_tail_for_log( + &mut rendered, + "stdout", + tail.stdout.as_deref(), + tail.stdout_truncated, + ); + } + rendered +} + +fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, truncated: bool) { + let tail = tail.unwrap_or(""); + let _ = write!( + rendered, + "\n--- {stream} (truncated={truncated}, bytes={}) ---\n{tail}", + tail.len() + ); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use sandbox_driver::{ExecFailure, Termination}; + + use super::*; + + const SECRET: &str = "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + + fn failed_push(stdout: &str, stderr: &str) -> sandbox_driver::Error { + sandbox_driver::Error::from( + ExecFailure::new( + "git push origin refs/heads/run", + Termination::Exited, + Some(128), + stdout.as_bytes().to_vec(), + stderr.as_bytes().to_vec(), + ) + .with_duration(Duration::from_millis(210)), + ) + } + + #[derive(Debug, thiserror::Error)] + #[error("{message}")] + struct Wrapped { + message: String, + #[source] + source: sandbox_driver::Error, + } + + #[test] + fn display_for_log_walks_the_chain_and_emits_the_tail() { + let error = Wrapped { + message: "metadata push failed".to_string(), + source: failed_push("last stdout line", "last stderr line"), + }; + + let rendered = display_for_log(&error); + + assert!(rendered.contains("metadata push failed")); + assert!(rendered.contains("git push origin refs/heads/run")); + assert!(rendered.contains("--- stderr (truncated=false, bytes=16) ---")); + assert!(rendered.contains("last stderr line")); + assert!(rendered.contains("--- stdout (truncated=false, bytes=16) ---")); + assert!(rendered.contains("last stdout line")); + } + + #[test] + fn display_for_log_redacts_secrets() { + let error = failed_push( + &format!("stdout secret {SECRET}"), + &format!("stderr secret {SECRET}"), + ); + + let rendered = display_for_log(&error); + + assert!( + !rendered.contains(SECRET), + "log rendering leaked raw secret: {rendered}" + ); + assert!(rendered.contains("REDACTED")); + } + + #[test] + fn display_for_log_for_a_plain_error_is_the_chain_alone() { + let error = + sandbox_driver::Error::io("reading the file", std::io::Error::other("leaf failure")); + + let rendered = display_for_log(&error); + + assert!(rendered.contains("leaf failure"), "{rendered}"); + assert!(!rendered.contains("--- stderr")); + assert!(!rendered.contains("--- stdout")); + assert!(default_redacted_output_tail(&error).is_none()); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/path.rs b/lib/components/fabro-pebble-sandbox/src/path.rs new file mode 100644 index 000000000..d26134754 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/path.rs @@ -0,0 +1,50 @@ +//! Paths as Fabro resolves them inside a sandbox: a relative path is +//! against the run's working directory, which may sit below the provider's +//! own. + +/// `path` as the driver will see it: absolute as given, relative against +/// `working_dir`. +#[must_use] +pub fn resolve_path(path: &str, working_dir: &str) -> String { + if std::path::Path::new(path).is_absolute() { + path.to_string() + } else { + join_sandbox_path(working_dir, path) + } +} + +/// `relative_path` under `base` with one separator between them; either +/// side empty yields the other. +#[must_use] +pub fn join_sandbox_path(base: &str, relative_path: &str) -> String { + if relative_path.is_empty() { + return base.to_string(); + } + if base.is_empty() { + return relative_path.to_string(); + } + if base == "/" { + return format!("/{relative_path}"); + } + format!("{}/{relative_path}", base.trim_end_matches('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + assert_eq!(resolve_path("src/main.rs", "/work"), "/work/src/main.rs"); + assert_eq!(resolve_path("/etc/hosts", "/work"), "/etc/hosts"); + assert_eq!(resolve_path("", "/work"), "/work"); + } + + #[test] + fn joins_keep_one_separator() { + assert_eq!(join_sandbox_path("/work/", "a"), "/work/a"); + assert_eq!(join_sandbox_path("/", "a"), "/a"); + assert_eq!(join_sandbox_path("", "a"), "a"); + assert_eq!(join_sandbox_path("/work", ""), "/work"); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/ports.rs b/lib/components/fabro-pebble-sandbox/src/ports.rs new file mode 100644 index 000000000..fb26e4954 --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/ports.rs @@ -0,0 +1,84 @@ +//! Pebble's port routing over the driver's preview URLs. + +use std::sync::Arc; + +use pebble_coding_agent::mcp::{PortRoute, PortRouteError, PortRoutes}; +use sandbox_driver::{PreviewUrls, Sandbox}; + +/// The route from Fabro to a port inside `handle`'s sandbox, as pebble's +/// MCP support takes it: pebble's [`PortRoutes`] over the driver's preview +/// URLs, when the provider has them. `None` for a provider without +/// forwarding, which is where pebble reaches the port on the loopback +/// address instead. +#[must_use] +pub fn port_routes(handle: &Arc) -> Option> { + handle.preview_urls()?; + Some(Arc::new(SandboxPortRoutes(Arc::clone(handle)))) +} + +/// Pebble's [`PortRoutes`] over a sandbox handle: the driver's preview-URL +/// facet answers with the URL and headers that reach a port. +pub struct SandboxPortRoutes(Arc); + +impl SandboxPortRoutes { + /// The driver's facet, present whenever [`port_routes`] handed this out. + /// A missing facet is the environment routing to none of its ports. + fn facet(&self) -> Result<&dyn PreviewUrls, PortRouteError> { + self.0.preview_urls().ok_or(PortRouteError::Unsupported) + } +} + +#[async_trait::async_trait] +impl PortRoutes for SandboxPortRoutes { + async fn route(&self, port: u16) -> Result { + let preview = self.facet()?.preview_url(port).await.map_err(|error| { + PortRouteError::failed_with_source( + format!("Failed to open a route to sandbox port {port}"), + error, + ) + })?; + Ok(PortRoute { + url: preview.url, + headers: preview.headers, + }) + } + + async fn release(&self, port: u16) -> Result<(), PortRouteError> { + self.facet()? + .release_preview_url(port) + .await + .map_err(|error| { + PortRouteError::failed_with_source( + format!("Failed to release the route to sandbox port {port}"), + error, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec}; + use sandbox_driver_host::HostProvider; + + use super::*; + + #[tokio::test] + async fn port_routes_answer_pebble_with_the_access_facets_preview_url() { + let dir = tempfile::tempdir().unwrap(); + let provider = HostProvider::new(); + let handle = provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(dir.path().display().to_string()), + None, + ) + .await + .unwrap(); + let routes = port_routes(&handle).expect("the host provider routes to its ports"); + let route = routes.route(8080).await.unwrap(); + assert_eq!(route, PortRoute::new("http://127.0.0.1:8080")); + routes.release(8080).await.unwrap(); + drop((dir, provider)); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/redact.rs b/lib/components/fabro-pebble-sandbox/src/redact.rs new file mode 100644 index 000000000..2798243eb --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/redact.rs @@ -0,0 +1,45 @@ +//! Fabro's secret scanner on the text seams pebble exposes. + +use std::borrow::Cow; + +use pebble_coding_agent::extensions::Redactor; + +/// Fabro's secret scanner as pebble's [`Redactor`]. +/// +/// Pebble calls it where text a process or the operating system wrote leaves +/// a session: the output tail a shell tool puts on the event stream and the +/// model-facing message of a failed tool call. It runs the same +/// `fabro_redact::redact_string` pass the run's stored events go through, so +/// what the model reads back matches what the log keeps. The final pass over +/// every stored `RunEvent` stays in place: this one covers the text pebble +/// hands the model and does not replace redaction of the stored event. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SecretRedactor; + +impl Redactor for SecretRedactor { + fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> { + let redacted = fabro_redact::redact_string(text); + if redacted == text { + Cow::Borrowed(text) + } else { + Cow::Owned(redacted) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_secret_redactor_borrows_clean_text_and_masks_secrets() { + let redactor = SecretRedactor; + assert!(matches!( + redactor.redact("plain stderr"), + Cow::Borrowed("plain stderr") + )); + let redacted = redactor.redact("key=AKIAYRWQG5EJLPZLBYNP"); + assert!(matches!(redacted, Cow::Owned(_))); + assert_eq!(redacted, "key=REDACTED"); + } +} diff --git a/lib/components/fabro-pebble-sandbox/src/test_support.rs b/lib/components/fabro-pebble-sandbox/src/test_support.rs new file mode 100644 index 000000000..cf863121e --- /dev/null +++ b/lib/components/fabro-pebble-sandbox/src/test_support.rs @@ -0,0 +1,259 @@ +//! Test doubles for Fabro's Pebble sandbox glue. +//! +//! [`MockSandbox`] is a configuration over the sandbox driver's scripted +//! double: a test writes down the files, the command answer, and the +//! failures it wants, and takes a [`PebbleSandbox`] or the bare driver +//! handle from it. What the code under test ran or wrote is read back from +//! the driver double itself, through [`MockSandbox::driver`]; the few +//! accessors here convert what a spec records into the shape Fabro's tests +//! assert on. Nothing here fakes Fabro's own logic: every call goes through +//! the real [`PebbleSandbox`] and Fabro's exec policy, down to the scripted +//! driver. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use sandbox_driver::{ExecResult, GrepMatch, PlatformInfo, Sandbox, Termination}; +pub use sandbox_driver_testing::{ScriptedExec, ScriptedProvider, ScriptedSandbox}; + +use crate::environment::PebbleSandbox; + +/// A driver [`ExecResult`] with the given streams, for scripting a mock +/// sandbox's answers. +#[must_use] +pub fn exec_result( + stdout: &str, + stderr: &str, + exit_code: Option, + termination: Termination, + duration_ms: u64, +) -> ExecResult { + let mut result = ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms)); + result.stdout = stdout.as_bytes().to_vec(); + result.stderr = stderr.as_bytes().to_vec(); + result +} + +/// What a test wants its sandbox to be, and what the code under test did +/// with it. +/// +/// Build it with a struct literal over [`MockSandbox::default`] (or +/// [`MockSandbox::linux`]), then take the sandbox with +/// [`MockSandbox::sandbox`] or its driver handle with +/// [`MockSandbox::handle`]. Every command answers with `exec_result` unless +/// `exec_error` is set, in which case every command fails as a transport +/// error. Files seed an in-memory filesystem under `working_dir`; absolute +/// paths are kept as given. +pub struct MockSandbox { + pub files: HashMap, + pub exec_result: ExecResult, + /// Fails every command before any process runs, so callers see a + /// transport error rather than an `ExecResult`. + pub exec_error: Option, + pub working_dir: &'static str, + pub platform_str: &'static str, + pub os_version_str: String, + /// Lines every grep returns, as `path:line:content`. + pub grep_results: Vec, + /// Reported by streaming execution. Set to `false` to model a provider + /// that cannot separate stdout from stderr. + pub streams_separated: bool, + /// The sandbox once built. Public only so `..Default::default()` works + /// from other crates; leave it at its default. + pub built: OnceLock, +} + +/// The lazily built sandbox and its scripted driver. +pub struct Built { + sandbox: Arc, + driver: Arc, +} + +impl Default for MockSandbox { + fn default() -> Self { + Self { + files: HashMap::new(), + exec_result: exec_result("mock output", "", Some(0), Termination::Exited, 10), + exec_error: None, + working_dir: "/work", + platform_str: "darwin", + os_version_str: "Darwin 24.0.0".into(), + grep_results: Vec::new(), + streams_separated: true, + built: OnceLock::new(), + } + } +} + +impl MockSandbox { + #[must_use] + pub fn linux() -> Self { + Self { + working_dir: "/home/test", + platform_str: "linux", + os_version_str: "Linux 6.1.0".into(), + ..Self::default() + } + } + + /// The Pebble sandbox this configuration describes, built once: + /// repeated calls return the same sandbox over the same recorder. + pub fn sandbox(&self) -> Arc { + Arc::clone(&self.built().sandbox) + } + + /// The scripted driver as a bare sandbox handle, for code that takes + /// `&dyn Sandbox` beside a working directory. + pub fn handle(&self) -> Arc { + Arc::clone(&self.built().driver) as Arc + } + + /// The scripted driver double behind [`MockSandbox::sandbox`], for + /// scripting beyond what the fields express. + pub fn driver(&self) -> Arc { + Arc::clone(&self.built().driver) + } + + /// Answers commands by their Bash source, ahead of the queue and + /// `exec_result`: a responder that returns `Some` decides the result, + /// `None` falls through. For tests that interleave different commands + /// and want each answered by what it is rather than by its position. + pub fn respond_with( + &self, + responder: impl Fn(&str) -> Option + Send + Sync + 'static, + ) -> &Self { + self.driver().scripted_exec().respond_with(move |spec| { + let command = spec.args.last().map(String::as_str).unwrap_or_default(); + responder(command) + }); + self + } + + fn built(&self) -> &Built { + self.built.get_or_init(|| { + let driver = Arc::new(self.build_driver()); + let sandbox = PebbleSandbox::with_platform( + Arc::clone(&driver) as Arc, + self.working_dir, + self.platform_str, + self.os_version_str.clone(), + ); + Built { + sandbox: Arc::new(sandbox), + driver, + } + }) + } + + fn build_driver(&self) -> ScriptedSandbox { + let mut driver = + ScriptedSandbox::with_id_and_working_dir("mock-sandbox", self.working_dir).platform( + PlatformInfo::new(self.platform_str, "x86_64", self.os_version_str.clone()), + ); + for (path, content) in &self.files { + driver = driver.file(path, content); + } + let exec = driver.scripted_exec(); + match &self.exec_error { + Some(message) => exec.fail_by_default(message.clone()), + None => exec.set_default(self.exec_result.clone()), + }; + exec.set_streams_separated(self.streams_separated); + driver.scripted_search().set_grep( + self.grep_results + .iter() + .map(|line| { + let mut parts = line.splitn(3, ':'); + let path = parts.next().unwrap_or_default(); + let line_number = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); + GrepMatch::new(path, line_number, parts.next().unwrap_or_default()) + }) + .collect(), + ); + driver + } + + fn recorded(&self) -> Vec { + self.built + .get() + .map(|built| built.driver.scripted_exec().recorded()) + .unwrap_or_default() + } + + /// The last command's Bash source. Every command, in order, is + /// `driver().scripted_exec().commands()`. + pub fn captured_command(&self) -> Option { + self.recorded() + .last() + .and_then(|spec| spec.args.last().cloned()) + } + + /// The explicit variables of the last command as the caller passed them. + /// The driver's Bash helper records its own `BASH_ENV` blank on the + /// spec; that is not the caller's. + pub fn captured_env_vars(&self) -> Option> { + self.recorded().last().map(|spec| { + spec.env + .iter() + .filter(|(key, _)| key.as_str() != sandbox_driver::BASH_ENV_VAR) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + } + + /// Every file written so far as `(path, content)`, in order. + pub fn written_files(&self) -> Vec<(String, String)> { + self.built + .get() + .map(|built| { + built + .driver + .memory_fs() + .writes() + .into_iter() + .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use pebble_coding_agent::environment::Environment; + + use super::*; + + #[tokio::test] + async fn the_mock_answers_commands_and_records_what_ran() { + let mock = MockSandbox { + files: HashMap::from([("README.md".to_string(), "hello".to_string())]), + ..MockSandbox::default() + }; + let sandbox = mock.sandbox(); + assert_eq!(Environment::platform(&*sandbox), "darwin"); + assert_eq!( + Environment::read_file_bytes(&*sandbox, "README.md") + .await + .unwrap(), + b"hello" + ); + Environment::write_file(&*sandbox, "notes.txt", "written") + .await + .unwrap(); + assert_eq!(mock.written_files(), vec![( + "/work/notes.txt".to_string(), + "written".to_string() + )]); + let env = HashMap::from([("KEY".to_string(), "value".to_string())]); + let result = sandbox + .exec() + .run("echo hi", None, None, Some(&env), None) + .await + .unwrap(); + assert_eq!(result.stdout_lossy(), "mock output"); + assert_eq!(mock.captured_command().as_deref(), Some("echo hi")); + assert_eq!(mock.captured_env_vars(), Some(env)); + } +}