diff --git a/Cargo.lock b/Cargo.lock index 720d0b311..beec4cae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3006,8 +3006,8 @@ dependencies = [ "fabro-http", "fabro-llm", "fabro-macros", + "fabro-pebble-sandbox", "fabro-redact", - "fabro-sandbox", "fabro-store", "fabro-test", "fabro-tool", @@ -3022,6 +3022,7 @@ dependencies = [ "lithos-llm", "pebble-coding-agent", "sandbox-driver", + "sandbox-driver-host", "scopeguard", "serde", "serde_json", diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index a7a5bc336..c51f092d2 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -128,6 +128,7 @@ tokio-util.workspace = true tokio-tungstenite.workspace = true fabro-macros = { path = "../../foundation/fabro-macros" } fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] } +fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox", features = ["test-support"] } sandbox-driver-testing.workspace = true fabro-store = { path = "../../components/fabro-store", features = ["test-support"] } fabro-test = { workspace = true } diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index 80e40397c..fc9f7ecff 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -34,8 +34,7 @@ use fabro_api::types::{ RunFilesMeta, RunFilesMetaDegradedReason, RunFilesMetaScope, RunFilesMetaSource, RunFilesMetaToSha, }; -use fabro_sandbox::reconnect::reconnect_for_run; -use fabro_sandbox::{RunSandbox, Termination}; +use fabro_pebble_sandbox::{SandboxExec, display_for_log}; use fabro_types::RunId; use fabro_util::shell; use fabro_workflow::sandbox_git::{ @@ -44,7 +43,8 @@ use fabro_workflow::sandbox_git::{ }; use futures_util::FutureExt; use sandbox_driver::{ - Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange, + Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange, Sandbox, + Termination, }; use serde::Deserialize; use tokio::sync::{Mutex, watch}; @@ -52,6 +52,7 @@ use tokio::sync::{Mutex, watch}; use crate::error::ApiError; use crate::principal_middleware::RequiredUser; use crate::run_files_security::{RunFilesMetrics, is_sensitive}; +use crate::sandbox_access; use crate::server::{AppState, parse_run_id_path}; /// Per-file cap: 256 KiB OR 20k lines (whichever comes first). @@ -62,8 +63,47 @@ pub(crate) const AGGREGATE_BYTES_CAP: u64 = 5 * 1024 * 1024; /// Per-response file-count cap. pub(crate) const FILE_COUNT_CAP: usize = 200; /// Sandbox git timeout. Matches Unit 3 helpers (10 s). -const SANDBOX_GIT_TIMEOUT_MS: u64 = 10_000; -const SANDBOX_GIT_TIMEOUT: Duration = Duration::from_millis(SANDBOX_GIT_TIMEOUT_MS); +const SANDBOX_GIT_TIMEOUT: Duration = Duration::from_secs(10); + +/// A run's sandbox as the Run Files endpoints read it: the driver handle, +/// brought to `Running`, and the directory the run's repository is checked +/// out in. +struct SandboxCheckout { + handle: Arc, + working_directory: String, +} + +impl SandboxCheckout { + fn new(handle: Arc, working_directory: impl Into) -> Self { + Self { + handle, + working_directory: working_directory.into(), + } + } + + fn sandbox(&self) -> &dyn Sandbox { + self.handle.as_ref() + } + + fn working_directory(&self) -> &str { + &self.working_directory + } + + /// The sandbox's git facet; a provider without git cannot serve files. + fn git(&self) -> std::result::Result, ApiError> { + self.handle.git().ok_or_else(|| { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Sandbox provider does not support git.", + ) + }) + } + + /// Fabro's exec policy over the sandbox, in the checkout. + fn exec(&self) -> SandboxExec<'_> { + SandboxExec::new(self.handle.exec()).with_working_dir(self.working_directory.clone()) + } +} /// Below this SHA count the phase-1 `cat-file --batch-check` pre-filter is /// skipped — its ~100 ms round-trip dominates for small diffs, and phase-2 @@ -356,12 +396,12 @@ async fn materialize_run_commits( } async fn git_log_commits( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, base_sha: &str, head_sha: &str, limit: u64, ) -> std::result::Result, ApiError> { - let git = sandbox_git(sandbox)?; + let git = sandbox.git()?; let options = GitLogOptions::new(GitRevisionRange::new(base_sha).to(head_sha)) .first_parent() .reverse() @@ -554,7 +594,7 @@ fn sandbox_read_error_should_fallback(err: &ApiError) -> bool { } async fn materialize_committed_sandbox_path( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, projection: &fabro_store::RunProjection, base_sha: &str, run_id: &RunId, @@ -576,7 +616,7 @@ async fn materialize_committed_sandbox_path( } async fn materialize_committed_range_sandbox_path( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, fallback_projection: Option<&fabro_store::RunProjection>, base_sha: &str, to_sha: &str, @@ -589,8 +629,18 @@ async fn materialize_committed_range_sandbox_path( // traversals are mutually independent once `to_sha` is known, and // running them sequentially would add ~100 ms per request on Daytona. let (raw_res, numstat_res) = tokio::join!( - list_changed_files_raw(sandbox, base_sha, to_sha), - list_diff_numstat(sandbox, base_sha, to_sha), + list_changed_files_raw( + sandbox.sandbox(), + sandbox.working_directory(), + base_sha, + to_sha + ), + list_diff_numstat( + sandbox.sandbox(), + sandbox.working_directory(), + base_sha, + to_sha + ), ); // Permanent errors (bad_sha, missing object) fall through to the @@ -698,14 +748,14 @@ async fn materialize_committed_range_sandbox_path( } async fn materialize_working_tree_sandbox_path( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, base_ref: &str, scope: RunFilesMetaScope, run_id: &RunId, start: Instant, ) -> ListRunFilesResult { let (to_sha, to_sha_committed_at) = resolve_head_sha_and_time(sandbox).await?; - let git = sandbox_git(sandbox)?; + let git = sandbox.git()?; // No head: the driver diffs `base_ref` against the working tree. let options = GitDiffOptions::new(GitRevisionRange::new(base_ref)) .find_renames(50) @@ -735,13 +785,6 @@ async fn materialize_working_tree_sandbox_path( )) } -/// The sandbox's git facet; a provider without git cannot serve files. -fn sandbox_git(sandbox: &RunSandbox) -> std::result::Result, ApiError> { - sandbox - .git() - .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes())) -} - /// A driver git failure as the endpoint's transient 503, so the client /// retries; a command that timed out says so. fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError { @@ -753,7 +796,7 @@ fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError { if timed_out { return transient_503(op, "command timed out"); } - transient_503(op, &fabro_sandbox::display_for_log(error)) + transient_503(op, &display_for_log(error)) } /// Build the degraded response from the stored terminal diff patch. @@ -1165,29 +1208,30 @@ async fn load_projection( state.load_run_projection(run_id).await } +/// The run's sandbox from its record, attached and running. A sandbox the +/// provider no longer has is a 409, which the caller degrades to the stored +/// patch. async fn reconnect_run_sandbox( state: &Arc, run_id: &RunId, projection: &fabro_store::RunProjection, -) -> std::result::Result { +) -> std::result::Result { let record = projection .sandbox .as_ref() .and_then(fabro_types::RunSandbox::instance) - .cloned() .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?; let access = state - .legacy_provider_access() + .provider_access() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let sandbox = reconnect_for_run(&record, &access, Some(*run_id), None) + let handle = sandbox_access::attach_running_run_sandbox(&access, record, *run_id) .await - .map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?; - sandbox - .activate() - .await - .map_err(|err| ApiError::new(StatusCode::CONFLICT, err.display_with_causes()))?; - Ok(sandbox) + .map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err:#}")))?; + Ok(SandboxCheckout::new( + handle, + record.runtime.working_directory.clone(), + )) } /// Resolve HEAD's SHA and its commit time in a single sandbox round-trip. @@ -1195,26 +1239,27 @@ async fn reconnect_run_sandbox( /// a space. The commit time is best-effort — if parsing fails the handler /// still succeeds without the freshness timestamp. async fn resolve_head_sha_and_time( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, ) -> std::result::Result<(String, Option>), ApiError> { resolve_ref_sha_and_time(sandbox, "HEAD").await } async fn resolve_ref_sha_and_time( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, git_ref: &str, ) -> std::result::Result<(String, Option>), ApiError> { let ref_q = shell::shell_quote(git_ref); let res = sandbox - .exec_command( + .exec() + .run( &format!("git -c core.hooksPath=/dev/null show -s --format=%H\\ %cI {ref_q}"), - SANDBOX_GIT_TIMEOUT_MS, + Some(SANDBOX_GIT_TIMEOUT), None, None, None, ) .await - .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?; + .map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, display_for_log(&err)))?; if !res.success() { return Err(ApiError::new( StatusCode::SERVICE_UNAVAILABLE, @@ -1583,7 +1628,7 @@ fn collect_blob_shas(classified: &[ClassifiedEntry]) -> Vec { /// but with a semantically-accurate cause. /// - Phase 2 transient error: 503 to the client. async fn fetch_blob_table( - sandbox: &RunSandbox, + sandbox: &SandboxCheckout, shas: &[String], ) -> std::result::Result>, ApiError> { if shas.is_empty() { @@ -1598,7 +1643,7 @@ async fn fetch_blob_table( // Phase 1 only earns its cost when a single malformed/huge blob could // poison a large batch's parse. let oversized: HashSet = if shas.len() >= METADATA_PHASE_SHA_THRESHOLD { - match stream_blob_metadata(sandbox, shas).await { + match stream_blob_metadata(sandbox.sandbox(), sandbox.working_directory(), shas).await { Ok(metas) => metas .into_iter() .filter_map(|m| { @@ -1632,7 +1677,14 @@ async fn fetch_blob_table( return Ok(table); } - match stream_blobs(sandbox, &shas_to_fetch, PER_FILE_BYTES_CAP).await { + match stream_blobs( + sandbox.sandbox(), + sandbox.working_directory(), + &shas_to_fetch, + PER_FILE_BYTES_CAP, + ) + .await + { Ok(contents) => { for (sha, content) in shas_to_fetch.iter().zip(contents) { table.insert(sha.clone(), content); @@ -1679,13 +1731,18 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use fabro_sandbox::Termination; - use fabro_sandbox::test_support::exec_result; + use fabro_pebble_sandbox::test_support::{MockSandbox, exec_result}; use fabro_types::{PetriAdmission, RunId, test_support}; + use sandbox_driver::ExecResult; use tokio::time::{Duration, sleep}; use super::*; + /// The mock as the Run Files endpoints hold a sandbox. + fn checkout(mock: &MockSandbox) -> SandboxCheckout { + SandboxCheckout::new(mock.handle(), mock.working_dir) + } + fn run_id(_name: &str) -> RunId { // RunIds are ULIDs, not arbitrary strings; each test just needs // distinct values. @@ -1744,7 +1801,7 @@ diff --git a/src/live.rs b/src/live.rs }); let body = materialize_working_tree_sandbox_path( - &sandbox.sandbox(), + &checkout(&sandbox), "HEAD", RunFilesMetaScope::Uncommitted, &RunId::new(), @@ -1785,20 +1842,17 @@ diff --git a/src/live.rs b/src/live.rs "Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f", "external tool update\n\nLonger body.\n\x1e", ); - let sandbox = fabro_sandbox::test_support::MockSandbox::default(); - sandbox - .driver() - .scripted_exec() - .push_result(fabro_sandbox::test_support::exec_result( - stdout, - "", - Some(0), - Termination::Exited, - 1, - )); + let sandbox = MockSandbox::default(); + sandbox.driver().scripted_exec().push_result(exec_result( + stdout, + "", + Some(0), + Termination::Exited, + 1, + )); let commits = git_log_commits( - &sandbox.sandbox(), + &checkout(&sandbox), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "dddddddddddddddddddddddddddddddddddddddd", 50, @@ -2850,9 +2904,6 @@ rename to .env.production // ── fetch_blob_table two-phase error isolation ───────────────────── - use fabro_sandbox::ExecResult; - use fabro_sandbox::test_support::MockSandbox; - /// A sandbox for the two-phase tests: it answers `cat-file --batch-check` /// and `cat-file --batch` differently and fails any other command, since /// `fetch_blob_table` runs nothing else. @@ -2913,7 +2964,7 @@ rename to .env.production let sandbox = blob_sandbox(ok_exec(&batch_check_stdout), ok_exec(&batch_stdout)); - let table = fetch_blob_table(&sandbox.sandbox(), &shas) + let table = fetch_blob_table(&checkout(&sandbox), &shas) .await .expect("transient-only errors should never bubble up for permanent parse fail"); @@ -2952,7 +3003,7 @@ rename to .env.production ok_exec(&batch_stdout), ); - let table = fetch_blob_table(&sandbox.sandbox(), &shas) + let table = fetch_blob_table(&checkout(&sandbox), &shas) .await .expect("small SHA lists skip phase 1 entirely; phase-2 success is the full story"); assert_eq!(table.get(&sha), Some(&Some("hello".to_string()))); diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index dffe828b9..2b9b17159 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -64,8 +64,6 @@ use fabro_mcp_store::McpServerStore; use fabro_petri::controls::{RunControls, SteerError}; use fabro_petri::projector::Projector; use fabro_redact::redact_jsonl_line; -use fabro_sandbox::details::sandbox_details; -use fabro_sandbox::reconnect::reconnect_for_run; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; use fabro_slack::config::{ SlackCredentialResolution, @@ -98,9 +96,7 @@ use fabro_types::{ RunControlAction, RunId, RunRunnableSource, RunStatus, RunStatusKind, RunStreamItem, RunStreamItemKind, SandboxProviderKind, ServerSettings, SuccessReason, }; -use fabro_util::error::{ - SharedError, collect_causes, render_compact_with_causes, render_with_causes, -}; +use fabro_util::error::{SharedError, render_compact_with_causes}; use fabro_util::version::FABRO_VERSION; use fabro_variable::{Error as VariableError, VariableStore}; use fabro_vault::{SecretStore, SecretStoreError, SecretType, Vault}; @@ -111,6 +107,7 @@ use fabro_workflow::{Error as WorkflowError, operations, pull_request}; use futures_util::future::join_all; use lithos_llm::catalog::ProviderId; use lithos_llm::types::Usage; +use sandbox_driver::SandboxId; use tempfile::NamedTempFile; use tokio::fs; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}; @@ -1548,25 +1545,6 @@ impl AppState { }) } - /// The same access in `fabro-sandbox`'s shape, for the callers still on - /// its reconnect path. - pub(crate) async fn legacy_provider_access( - &self, - ) -> Result { - Ok(fabro_sandbox::ProviderAccess { - providers: self.server_settings().server.sandbox.providers.clone(), - daytona: self - .vault_secret(EnvVars::DAYTONA_API_KEY) - .await? - .map(|api_key| { - fabro_sandbox::DaytonaCredentials::from_api_key(api_key, |name| { - self.config_env_lookup(name) - }) - .with_http_client(self.http_client().ok()) - }), - }) - } - pub(crate) async fn check_daytona_api_key( &self, api_key: String, @@ -2869,40 +2847,39 @@ async fn delete_run_sandbox_resource( } let access = state - .legacy_provider_access() + .provider_access() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let sandbox = match reconnect_for_run(&record, &access, Some(id), None).await { - Ok(sandbox) => sandbox, + // Deleted by id through the provider scoped to the run: a sandbox that + // no longer carries the run's `petri.run` label is refused, an id the + // provider no longer knows is already gone, and a designated host + // directory is left in place. + let deleted = async { + let provider = sandbox_access::run_provider(&record.provider, &access, id) + .await + .with_context(|| format!("Failed to connect to the {} provider", record.provider))?; + let sandbox_id = SandboxId::try_new(&runtime.id) + .with_context(|| format!("Invalid {} sandbox id", record.provider))?; + provider.delete(&sandbox_id, None).await.with_context(|| { + format!( + "Failed to delete {} sandbox '{}'", + record.provider, runtime.id + ) + }) + } + .await; + match deleted { + Ok(()) => Ok(SandboxDeleteOutcome::Cleaned), Err(err) if force || delete_started => { tracing::warn!( run_id = %id, - error = %render_with_causes(&err.to_string(), &collect_causes(err.as_ref())), - "Skipping sandbox provider delete during run deletion" - ); - return Ok(SandboxDeleteOutcome::Cleaned); - } - Err(err) => { - let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); - return Err(ApiError::new(StatusCode::CONFLICT, detail)); - } - }; - if let Err(err) = sandbox.delete().await { - if force || delete_started { - tracing::warn!( - run_id = %id, - error = %err.display_with_causes(), + error = %format!("{err:#}"), "Skipping failed sandbox provider delete during run deletion" ); - return Ok(SandboxDeleteOutcome::Cleaned); + Ok(SandboxDeleteOutcome::Cleaned) } - return Err(ApiError::new( - StatusCode::CONFLICT, - err.display_with_causes(), - )); + Err(err) => Err(ApiError::new(StatusCode::CONFLICT, format!("{err:#}"))), } - - Ok(SandboxDeleteOutcome::Cleaned) } async fn reject_active_delete_without_force( diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index 6c9b56dae..03c31a5c0 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -3,23 +3,23 @@ use std::num::NonZeroU64; use std::sync::Arc; use std::time::Duration; +use anyhow::Context as _; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use fabro_sandbox::{ - FileKind, ProviderAccess, PtySize, RunSandbox, open_terminal_for_run, reconnect_for_run, -}; +use fabro_pebble_sandbox::{display_for_log, resolve_path}; use fabro_types::{RunSandboxInstance, SandboxProviderKind}; use futures_util::FutureExt; use futures_util::future::BoxFuture; -use sandbox_driver::{ListeningPort, Services as _}; +use sandbox_driver::{FileKind, ListeningPort, PtyOptions, PtySize, Sandbox, Services as _}; use super::super::{ ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode, - VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post, - reject_if_archived, render_with_causes, sandbox_details, + VncPreviewResponse, fs, get, octet_stream_response, parse_run_id_path, post, + reject_if_archived, }; +use crate::sandbox_access::{self, ProviderAccess}; const MAX_TERMINAL_CONTROL_BYTES: usize = 4096; const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080; @@ -39,19 +39,19 @@ const VNC_VIEWER_RESIZE: (&str, &str) = ("resize", "scale"); trait VncSandbox { /// Starts the desktop and returns the signed viewer URL the provider /// hands out for it. - fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result>; + fn vnc_viewer_url(&self) -> BoxFuture<'_, anyhow::Result>; } -impl VncSandbox for RunSandbox { - fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result> { +impl VncSandbox for Arc { + fn vnc_viewer_url(&self) -> BoxFuture<'_, anyhow::Result> { async move { - let vnc = self.handle()?.vnc().ok_or_else(|| { - fabro_sandbox::Error::message("Sandbox provider does not support VNC previews.") + let vnc = self.vnc().ok_or_else(|| { + anyhow::anyhow!("Sandbox provider does not support VNC previews.") })?; vnc.vnc_connection() .await .map(|connection| connection.url) - .map_err(|err| fabro_sandbox::Error::context("Failed to open a VNC preview", err)) + .context("Failed to open a VNC preview") } .boxed() } @@ -89,17 +89,25 @@ async fn retrieve_run_sandbox( Ok(value) => value, Err(response) => return response, }; - match sandbox_details(&record, &access, Some(id)).await { + // The record and the status the driver reports for it, in whatever + // state the sandbox is: a stopped sandbox is described, not started. + let details = async { + let sandbox = sandbox_access::attach_run_sandbox(&access, &record, id).await?; + let status = sandbox.describe().await.with_context(|| { + format!( + "Failed to describe {} sandbox '{}'", + record.provider, record.runtime.id + ) + })?; + anyhow::Ok(SandboxDetails { + sandbox: record.clone(), + status, + }) + } + .await; + match details { Ok(details) => Json::(details).into_response(), - Err(err) => { - let detail = format!("{err:#}"); - let status = if detail.contains("has no details implementation") { - StatusCode::NOT_IMPLEMENTED - } else { - StatusCode::CONFLICT - }; - ApiError::new(status, detail).into_response() - } + Err(err) => ApiError::new(StatusCode::CONFLICT, format!("{err:#}")).into_response(), } } @@ -219,15 +227,27 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run return; } }; - let session = match open_terminal_for_run(&record, &access, Some(id), PtySize::default()).await - { + // An interactive shell in the run's working directory over the + // driver's Pty facet, on the sandbox brought back to running. The + // session is the driver's own; it is closed below. + let session = async { + let sandbox = sandbox_access::attach_running_run_sandbox(&access, &record, id).await?; + let pty = sandbox + .pty() + .ok_or_else(|| anyhow::anyhow!("Sandbox provider does not support terminals."))?; + let mut options = PtyOptions::default(); + options.size = PtySize::default(); + options.working_dir = Some(record.runtime.working_directory.clone()); + pty.open(&options) + .await + .context("Failed to open sandbox terminal") + } + .await; + let session = match session { Ok(session) => session, Err(err) => { let _ = socket - .send(terminal_server_text( - "error", - Some(&err.display_with_causes()), - )) + .send(terminal_server_text("error", Some(&format!("{err:#}")))) .await; return; } @@ -252,7 +272,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run Ok(WsMessage::Binary(bytes)) => { if let Err(err) = session.write_input(&bytes).await { let _ = socket - .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) + .send(terminal_server_text("error", Some(&display_for_log(&err)))) .await; break; } @@ -262,7 +282,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run Ok(TerminalClientMessage::Resize(size)) => { if let Err(err) = session.resize(size).await { let _ = socket - .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) + .send(terminal_server_text("error", Some(&display_for_log(&err)))) .await; break; } @@ -297,7 +317,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run } Err(err) => { let _ = socket - .send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err)))) + .send(terminal_server_text("error", Some(&display_for_log(&err)))) .await; break; } @@ -306,7 +326,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run } } if let Err(err) = session.close().await { - tracing::warn!(error = %fabro_sandbox::display_for_log(&err), run_id = %id, "failed to close run terminal session"); + tracing::warn!(error = %display_for_log(&err), run_id = %id, "failed to close run terminal session"); } } @@ -342,13 +362,7 @@ async fn generate_preview_url( Ok(sandbox) => sandbox, Err(response) => return response, }; - let handle = match sandbox.handle() { - Ok(handle) => handle, - Err(err) => { - return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); - } - }; - let Some(previews) = handle.preview_urls() else { + let Some(previews) = sandbox.preview_urls() else { return ApiError::new( StatusCode::CONFLICT, "Sandbox provider does not support preview URLs.", @@ -410,21 +424,20 @@ async fn create_ssh_access( Ok(sandbox) => sandbox, Err(response) => return response, }; - let handle = match sandbox.handle() { - Ok(handle) => handle, - Err(err) => { - return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); - } - }; // Providers with a leased SSH gateway honor the requested lifetime; // providers with a fixed local command return it as is. - let result = match handle.ssh() { - Some(ssh) => ssh + let result = match (sandbox.ssh(), sandbox.shell_command()) { + (Some(ssh), _) => ssh .ssh_access(Some(Duration::from_secs_f64(request.ttl_minutes * 60.0))) .await .map(|access| Some(access.command)) - .map_err(|err| fabro_sandbox::Error::context("Failed to create SSH access", err)), - None => sandbox.ssh_access_command().await, + .context("Failed to create SSH access"), + (None, Some(shell)) => shell + .shell_command() + .await + .map(Some) + .context("Failed to build sandbox shell command"), + (None, None) => Ok(None), }; match result { Ok(Some(command)) => { @@ -435,7 +448,7 @@ async fn create_ssh_access( "Sandbox provider does not support access commands.", ) .into_response(), - Err(err) => ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(), + Err(err) => ApiError::new(StatusCode::CONFLICT, format!("{err:#}")).into_response(), } } @@ -473,12 +486,12 @@ async fn build_vnc_preview_response( provider: &SandboxProviderKind, sandbox: &impl VncSandbox, ) -> Result { - let url = sandbox.vnc_viewer_url().await.map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; - let url = vnc_viewer_url(&url).map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; + let url = sandbox + .vnc_viewer_url() + .await + .map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err:#}")).into_response())?; + let url = vnc_viewer_url(&url) + .map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err:#}")).into_response())?; Ok(VncPreviewResponse { expires_in_secs: NonZeroU64::new( u64::try_from(DEFAULT_VNC_TTL_SECS).expect("default VNC TTL should fit in u64"), @@ -494,7 +507,7 @@ async fn build_vnc_preview_response( /// Pins the viewer URL to the noVNC page with autoconnect and scaling. The /// provider already points at the viewer; this makes the query idempotent /// so a URL that already carries the viewer parameters is not duplicated. -fn vnc_viewer_url(signed_url: &str) -> fabro_sandbox::Result { +fn vnc_viewer_url(signed_url: &str) -> anyhow::Result { // Internal URL manipulation, not logging — `DisplaySafeUrl` is for // logging/error boundaries. The signed URL may carry a credential, so // the parse-failure message intentionally omits it. @@ -502,8 +515,7 @@ fn vnc_viewer_url(signed_url: &str) -> fabro_sandbox::Result { clippy::disallowed_types, reason = "internal url manipulation; redaction handled by omitting the URL from error messages" )] - let mut url = url::Url::parse(signed_url) - .map_err(|err| fabro_sandbox::Error::context("Failed to parse signed VNC URL", err))?; + let mut url = url::Url::parse(signed_url).context("Failed to parse signed VNC URL")?; let preserved: Vec<(String, String)> = url .query_pairs() .filter(|(key, _)| key != VNC_VIEWER_AUTOCONNECT.0 && key != VNC_VIEWER_RESIZE.0) @@ -533,23 +545,36 @@ async fn list_sandbox_files( Ok(id) => id, Err(response) => return response, }; - let sandbox = match reconnect_run_sandbox(&state, &id).await { + let (record, sandbox) = match reconnect_run_sandbox(&state, &id).await { Ok(sandbox) => sandbox, Err(response) => return response, }; - match sandbox.list_directory(¶ms.path, params.depth).await { - Ok(entries) => Json(SandboxFileListResponse { - data: entries - .into_iter() - .map(|entry| SandboxFileEntry { - is_dir: entry.kind == FileKind::Directory, - name: entry.path, - size: entry.size.map(u64::cast_signed), - }) - .collect(), - }) - .into_response(), - Err(err) => ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response(), + // To `depth` (the immediate children by default), sorted by path, with + // sizes for files only. + let path = resolve_path(¶ms.path, &record.runtime.working_directory); + match sandbox + .fs() + .list_dir(&path, params.depth.unwrap_or(1)) + .await + { + Ok(mut entries) => { + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Json(SandboxFileListResponse { + data: entries + .into_iter() + .map(|entry| SandboxFileEntry { + is_dir: entry.kind == FileKind::Directory, + size: (entry.kind == FileKind::File) + .then_some(entry.size) + .flatten() + .map(u64::cast_signed), + name: entry.path, + }) + .collect(), + }) + .into_response() + } + Err(err) => ApiError::new(StatusCode::NOT_FOUND, display_for_log(&err)).into_response(), } } @@ -571,12 +596,12 @@ async fn list_sandbox_services( Ok(sandbox) => sandbox, Err(response) => return response, }; - let services = match sandbox.services() { - Ok(services) => services, - Err(err) => { - return ApiError::new(StatusCode::NOT_IMPLEMENTED, err.display_with_causes()) - .into_response(); - } + let Some(services) = sandbox.services() else { + return ApiError::new( + StatusCode::NOT_IMPLEMENTED, + format!("sandbox provider `{provider}` does not support background services"), + ) + .into_response(); }; let ports = match services.listening_ports().await { Ok(ports) => ports, @@ -657,7 +682,7 @@ async fn get_sandbox_file( Ok(id) => id, Err(response) => return response, }; - let sandbox = match reconnect_run_sandbox(&state, &id).await { + let (record, sandbox) = match reconnect_run_sandbox(&state, &id).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -668,11 +693,9 @@ async fn get_sandbox_file( .into_response(); } }; - if let Err(err) = sandbox - .download_file_to_local(¶ms.path, temp.path()) - .await - { - return ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response(); + let path = resolve_path(¶ms.path, &record.runtime.working_directory); + if let Err(err) = sandbox.fs().download(&path, temp.path()).await { + return ApiError::new(StatusCode::NOT_FOUND, display_for_log(&err)).into_response(); } match fs::read(temp.path()).await { Ok(bytes) => octet_stream_response(bytes.into()), @@ -696,7 +719,7 @@ async fn put_sandbox_file( if let Some(response) = reject_if_archived(state.as_ref(), &id).await { return response; } - let sandbox = match reconnect_run_sandbox(&state, &id).await { + let (record, sandbox) = match reconnect_run_sandbox(&state, &id).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -710,22 +733,23 @@ async fn put_sandbox_file( if let Err(err) = fs::write(temp.path(), &body).await { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } - match sandbox - .upload_file_from_local(temp.path(), ¶ms.path) - .await - { + let path = resolve_path(¶ms.path, &record.runtime.working_directory); + match sandbox.fs().upload(temp.path(), &path).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.display_with_causes()) - .into_response(), + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, display_for_log(&err)).into_response() + } } } +/// The run's sandbox record and its handle, attached and running. async fn reconnect_run_sandbox( state: &Arc, run_id: &RunId, -) -> Result { +) -> Result<(RunSandboxInstance, Arc), Response> { let record = load_run_sandbox_instance(state, run_id).await?; - reconnect_run_sandbox_instance(state, run_id, &record).await + let sandbox = reconnect_run_sandbox_instance(state, run_id, &record).await?; + Ok((record, sandbox)) } /// Reconnects a run's sandbox and brings it to running. @@ -733,22 +757,15 @@ async fn reconnect_run_sandbox_instance( state: &Arc, run_id: &RunId, record: &RunSandboxInstance, -) -> Result { +) -> Result, Response> { let access = load_provider_access(state).await?; - let sandbox = reconnect_for_run(record, &access, Some(*run_id), None) + sandbox_access::attach_running_run_sandbox(&access, record, *run_id) .await - .map_err(|err| { - let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); - ApiError::new(StatusCode::CONFLICT, detail).into_response() - })?; - sandbox.activate().await.map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; - Ok(sandbox) + .map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err:#}")).into_response()) } async fn load_provider_access(state: &AppState) -> Result { - state.legacy_provider_access().await.map_err(|err| { + state.provider_access().await.map_err(|err| { tracing::error!(error = ?err, "Loading Daytona API key failed"); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -918,12 +935,10 @@ mod tests { } impl VncSandbox for FakeVncSandbox { - fn vnc_viewer_url( - &self, - ) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result> { + fn vnc_viewer_url(&self) -> futures_util::future::BoxFuture<'_, anyhow::Result> { async move { match self.error { - Some(message) => Err(fabro_sandbox::Error::message(message)), + Some(message) => Err(anyhow::anyhow!("{message}")), None => Ok(self.viewer_url.to_string()), } } diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index f5311f5a7..d38781efa 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -16,7 +16,6 @@ use fabro_api::types::{ use fabro_llm::lithos_catalog::Catalog; use fabro_llm::{FabroClient, ModelSelectionError, selection}; 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; use fabro_types::session_event::{ @@ -50,6 +49,7 @@ use super::super::session_runtime::{InterruptTurnError, SessionTurnLease, StartT use super::super::{AppState, PaginationParams, paginate_items, parse_run_id_path}; use crate::error::ApiError; use crate::principal_middleware::RequiredUser; +use crate::sandbox_access; use crate::worker_token::issue_worker_token; const SESSION_SSE_BUFFER_CAPACITY: usize = 1024; @@ -727,23 +727,14 @@ async fn build_agent( AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created")) })?; let access = state - .legacy_provider_access() + .provider_access() .await .map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?; - let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id), None) + let handle = sandbox_access::attach_running_run_sandbox(&access, sandbox_instance, run_id) .await .map_err(AskFabroBuildError::SandboxUnavailable)?; - sandbox - .activate() - .await - .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?; - 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()) + PebbleSandbox::attach(handle, &sandbox_instance.runtime.working_directory) .await .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?, ); diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index e13a63064..e08827f54 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -23,7 +23,7 @@ workspace = true anyhow.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } fabro-config = { path = "../../foundation/fabro-config" } -fabro-sandbox = { path = "../fabro-sandbox" } +fabro-pebble-sandbox = { path = "../fabro-pebble-sandbox" } sandbox-driver.workspace = true pebble-coding-agent.workspace = true fabro-github = { path = "../fabro-github" } @@ -61,7 +61,7 @@ fabro-store = { path = "../fabro-store", features = ["test-support"] } fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] } fabro-github = { path = "../fabro-github", features = ["test-support"] } fabro-workflow = { path = ".", features = ["test-support"] } -fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } +sandbox-driver-host.workspace = true tokio = { workspace = true, features = ["test-util", "macros"] } httpmock = "0.8" fabro-macros = { path = "../../foundation/fabro-macros" } diff --git a/lib/components/fabro-workflow/src/sandbox_git.rs b/lib/components/fabro-workflow/src/sandbox_git.rs index b3aa24ef0..7eec2858f 100644 --- a/lib/components/fabro-workflow/src/sandbox_git.rs +++ b/lib/components/fabro-workflow/src/sandbox_git.rs @@ -5,13 +5,18 @@ //! refuse the file transport and external diff drivers) and returns typed //! results. Fabro decides what to stage, what to say in a checkpoint //! commit, and which ranges the Run Files endpoint reads. +//! +//! Every operation takes the driver handle of the run's sandbox and the +//! directory the run's repository is checked out in, as the run record +//! carries it. use std::collections::{HashMap, HashSet}; use std::time::Duration; -use fabro_sandbox::RunSandbox; +use fabro_pebble_sandbox::display_for_log; use sandbox_driver::{ Git as _, GitChange, GitDiffEntry, GitDiffOptions, GitFacet, GitFailureKind, GitRevisionRange, + Sandbox, }; #[derive(Debug, thiserror::Error)] @@ -19,7 +24,7 @@ use sandbox_driver::{ pub struct GitCommandError { pub message: String, #[source] - pub source: fabro_sandbox::Error, + pub source: sandbox_driver::Error, } /// Rename detection threshold for the diffs the Run Files endpoint and the @@ -120,7 +125,8 @@ pub struct BlobMeta { /// the SHAs, not the paths. The `--numstat` companion classifies text vs /// binary so callers can skip binary contents without ever fetching them. pub async fn list_changed_files_raw( - sandbox: &RunSandbox, + sandbox: &dyn Sandbox, + working_directory: &str, base_sha: &str, to_sha: &str, ) -> std::result::Result, DiffError> { @@ -129,7 +135,7 @@ pub async fn list_changed_files_raw( .find_renames(FIND_RENAMES_PERCENT) .timeout(RUN_FILES_TIMEOUT); let entries = git - .diff_entries(sandbox.working_directory(), &options) + .diff_entries(working_directory, &options) .await .map_err(|error| diff_error(&error))?; entries @@ -139,9 +145,11 @@ pub async fn list_changed_files_raw( .map_err(|message| DiffError::Permanent { message }) } -fn diff_facet(sandbox: &RunSandbox) -> std::result::Result, DiffError> { - sandbox.git().map_err(|error| DiffError::Permanent { - message: fabro_sandbox::display_for_log(&error), +/// The sandbox's git facet; a provider without git cannot serve files, and +/// a retry would not change that. +fn diff_facet(sandbox: &dyn Sandbox) -> std::result::Result, DiffError> { + sandbox.git().ok_or_else(|| DiffError::Permanent { + message: "sandbox provider does not support git".to_string(), }) } @@ -151,7 +159,7 @@ fn diff_facet(sandbox: &RunSandbox) -> std::result::Result, DiffErr /// retry reads the same object; a timeout, a transport failure, or anything /// else is transient and surfaces as a 503 for the client to retry. fn diff_error(error: &sandbox_driver::Error) -> DiffError { - let message = fabro_sandbox::display_for_log(error); + let message = display_for_log(error); match error { sandbox_driver::Error::Io { .. } => DiffError::Permanent { message }, sandbox_driver::Error::Git(failure) => { @@ -290,7 +298,8 @@ pub fn summarize_diff_numstat(numstat: &DiffNumstat) -> DiffSummary { /// The numstat of `base_sha..to_sha`: the set of binary paths and the /// text-file `+/-` totals, from one driver call. pub async fn list_diff_numstat( - sandbox: &RunSandbox, + sandbox: &dyn Sandbox, + working_directory: &str, base_sha: &str, to_sha: &str, ) -> std::result::Result { @@ -299,7 +308,7 @@ pub async fn list_diff_numstat( .find_renames(FIND_RENAMES_PERCENT) .timeout(RUN_FILES_TIMEOUT); let rows = git - .diff_numstat(sandbox.working_directory(), &options) + .diff_numstat(working_directory, &options) .await .map_err(|error| diff_error(&error))?; @@ -323,7 +332,8 @@ pub async fn list_diff_numstat( /// Blob sizes for many SHAs in one driver call, in the order of `shas`. /// A blob git does not have yields `BlobMeta { size: None, .. }`. pub async fn stream_blob_metadata( - sandbox: &RunSandbox, + sandbox: &dyn Sandbox, + working_directory: &str, shas: &[String], ) -> std::result::Result, DiffError> { if shas.is_empty() { @@ -331,7 +341,7 @@ pub async fn stream_blob_metadata( } let git = diff_facet(sandbox)?; let sizes = git - .blob_sizes(sandbox.working_directory(), shas) + .blob_sizes(working_directory, shas) .await .map_err(|error| diff_error(&error))?; Ok(shas @@ -351,7 +361,8 @@ pub async fn stream_blob_metadata( /// as does a blob git does not have or one that is not UTF-8. Callers are /// expected to have pre-filtered binary blobs via [`list_diff_numstat`]. pub async fn stream_blobs( - sandbox: &RunSandbox, + sandbox: &dyn Sandbox, + working_directory: &str, shas: &[String], size_cap_bytes: u64, ) -> std::result::Result>, DiffError> { @@ -360,7 +371,7 @@ pub async fn stream_blobs( } let git = diff_facet(sandbox)?; let blobs = git - .blobs(sandbox.working_directory(), shas, size_cap_bytes) + .blobs(working_directory, shas, size_cap_bytes) .await .map_err(|error| diff_error(&error))?; Ok(blobs @@ -376,8 +387,29 @@ mod tests { reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk." )] + use std::sync::Arc; + + use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec}; + use sandbox_driver_host::HostProvider; + use super::*; + /// The repository at `repo` as a host sandbox, with the provider it + /// lives on and the directory the operations take. + async fn host_sandbox(repo: &std::path::Path) -> (HostProvider, Arc, String) { + let provider = HostProvider::new(); + let sandbox = provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(repo.display().to_string()), + None, + ) + .await + .expect("a host sandbox over the repository"); + let working_directory = sandbox.working_directory().to_string(); + (provider, sandbox, working_directory) + } + // Test helpers for machine-readable diff enumeration. The repo is seeded // with a single commit at `base_sha`, then callers mutate and re-commit // to produce a synthetic `base_sha..HEAD` diff. @@ -433,10 +465,8 @@ mod tests { std::fs::remove_file(repo.join("drop.txt")).unwrap(); let head = git_commit_all(repo, "change"); - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) - .await - .unwrap(); - let entries = list_changed_files_raw(&sandbox, &base, &head) + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; + let entries = list_changed_files_raw(sandbox.as_ref(), &working_directory, &base, &head) .await .unwrap(); @@ -474,10 +504,8 @@ mod tests { std::fs::write(repo.join("new.txt"), &content).unwrap(); let head = git_commit_all(repo, "rename"); - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) - .await - .unwrap(); - let entries = list_changed_files_raw(&sandbox, &base, &head) + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; + let entries = list_changed_files_raw(sandbox.as_ref(), &working_directory, &base, &head) .await .unwrap(); @@ -520,10 +548,10 @@ mod tests { std::fs::write(repo.join("logo.png"), png).unwrap(); let head = git_commit_all(repo, "change"); - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; + let stats = list_diff_numstat(sandbox.as_ref(), &working_directory, &base, &head) .await .unwrap(); - let stats = list_diff_numstat(&sandbox, &base, &head).await.unwrap(); assert!( stats.binary_paths.contains("logo.png"), @@ -566,11 +594,11 @@ mod tests { sha_by_name.insert(path.to_string(), sha.to_string()); } - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; + let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["b.txt"].clone()]; + let metas = stream_blob_metadata(sandbox.as_ref(), &working_directory, &shas) .await .unwrap(); - let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["b.txt"].clone()]; - let metas = stream_blob_metadata(&sandbox, &shas).await.unwrap(); assert_eq!(metas.len(), 2); assert_eq!(metas[0].sha, shas[0]); assert_eq!(metas[0].size, Some(4)); @@ -604,13 +632,13 @@ mod tests { sha_by_name.insert(path.to_string(), sha.to_string()); } - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) - .await - .unwrap(); + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["big.txt"].clone()]; // size_cap = 100 bytes — "hello\n" (6) stays, 200-byte blob truncates. - let contents = stream_blobs(&sandbox, &shas, 100).await.unwrap(); + let contents = stream_blobs(sandbox.as_ref(), &working_directory, &shas, 100) + .await + .unwrap(); assert_eq!(contents.len(), 2); assert_eq!(contents[0].as_deref(), Some("hello\n")); assert!(contents[1].is_none(), "oversize blob should be None"); @@ -624,13 +652,15 @@ mod tests { std::fs::write(repo.join("x"), "x").unwrap(); git_commit_all(repo, "seed"); - let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf()) - .await - .unwrap(); - let err = - list_changed_files_raw(&sandbox, "0000000000000000000000000000000000000000", "HEAD") - .await - .expect_err("expected error for unknown base sha"); + let (_provider, sandbox, working_directory) = host_sandbox(repo).await; + let err = list_changed_files_raw( + sandbox.as_ref(), + &working_directory, + "0000000000000000000000000000000000000000", + "HEAD", + ) + .await + .expect_err("expected error for unknown base sha"); assert!(matches!(err, DiffError::Permanent { .. }), "err: {err:?}"); } }