From c4ed995b4424b99d97afaa53bd0fa3ede6bfeb31 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 19 Sep 2026 13:37:38 -0400 Subject: [PATCH] Add fabro-pebble-sandbox: a driver handle as pebble's Environment Petri creates and owns every run sandbox through the sandbox driver, so what Fabro still needs around a driver handle is the Pebble glue: the Environment pebble's coding agent runs its tools through, the exec policy (stop grace, working directory, StripAll, the termination mapping, the redacted output tail), pebble's port routes over the driver's preview URLs, the secret redactor, the path helpers, and a log rendering that appends a failed command's redacted tail. This crate holds that glue, moved from fabro-sandbox, over `Arc` plus a working directory instead of `RunSandbox`, with a `MockSandbox` double behind `test-support`. `fabro exec` creates its host sandbox directly on the driver's Host provider and activates it; Ask Fabro wraps the attached handle. Both keep the provider alive beside the sandbox where the session's processes are the provider's process groups. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 24 + lib/apps/fabro-cli/Cargo.toml | 3 + lib/apps/fabro-cli/src/commands/exec.rs | 90 +- lib/apps/fabro-server/Cargo.toml | 1 + .../src/server/handler/sessions.rs | 13 +- .../fabro-pebble-sandbox/Cargo.toml | 36 + .../fabro-pebble-sandbox/src/environment.rs | 895 ++++++++++++++++++ .../fabro-pebble-sandbox/src/exec.rs | 641 +++++++++++++ .../fabro-pebble-sandbox/src/lib.rs | 35 + .../fabro-pebble-sandbox/src/log.rs | 151 +++ .../fabro-pebble-sandbox/src/path.rs | 50 + .../fabro-pebble-sandbox/src/ports.rs | 84 ++ .../fabro-pebble-sandbox/src/redact.rs | 45 + .../fabro-pebble-sandbox/src/test_support.rs | 259 +++++ 14 files changed, 2319 insertions(+), 8 deletions(-) create mode 100644 lib/components/fabro-pebble-sandbox/Cargo.toml create mode 100644 lib/components/fabro-pebble-sandbox/src/environment.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/exec.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/lib.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/log.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/path.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/ports.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/redact.rs create mode 100644 lib/components/fabro-pebble-sandbox/src/test_support.rs 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)); + } +}