mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Serve the sandbox tab, Run Files, and deletion on the driver handle
The sandbox handlers describe, list, download, upload, open a terminal and build SSH and VNC access on the `Arc<dyn Sandbox>` the server attaches to the run's record, with paths resolved against the recorded working directory. Run Files holds the handle beside that directory and runs its git through the driver's git facet and Fabro's exec policy; fabro-workflow's sandbox git takes the same pair, and its `GitCommandError` carries the driver's error. Run deletion deletes by id through the provider scoped to the run's `petri.run` label, so a foreign sandbox is refused and a designated host directory is left in place. Ask Fabro wraps the attached, running handle. The legacy access shim is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0cc645b2d1
commit
23b8a6c449
8 changed files with 337 additions and 271 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<dyn Sandbox>,
|
||||
working_directory: String,
|
||||
}
|
||||
|
||||
impl SandboxCheckout {
|
||||
fn new(handle: Arc<dyn Sandbox>, working_directory: impl Into<String>) -> 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<GitFacet<'_>, 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<Vec<RunCommit>, 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<GitFacet<'_>, 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<AppState>,
|
||||
run_id: &RunId,
|
||||
projection: &fabro_store::RunProjection,
|
||||
) -> std::result::Result<RunSandbox, ApiError> {
|
||||
) -> std::result::Result<SandboxCheckout, ApiError> {
|
||||
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<chrono::DateTime<chrono::Utc>>), 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<chrono::DateTime<chrono::Utc>>), 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<String> {
|
|||
/// 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<HashMap<String, Option<String>>, 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<String> = 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())));
|
||||
|
|
|
|||
|
|
@ -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<fabro_sandbox::ProviderAccess, SecretStoreError> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<String>>;
|
||||
fn vnc_viewer_url(&self) -> BoxFuture<'_, anyhow::Result<String>>;
|
||||
}
|
||||
|
||||
impl VncSandbox for RunSandbox {
|
||||
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>> {
|
||||
impl VncSandbox for Arc<dyn Sandbox> {
|
||||
fn vnc_viewer_url(&self) -> BoxFuture<'_, anyhow::Result<String>> {
|
||||
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::<SandboxDetails>(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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<VncPreviewResponse, Response> {
|
||||
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<String> {
|
||||
fn vnc_viewer_url(signed_url: &str) -> anyhow::Result<String> {
|
||||
// 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<String> {
|
|||
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<AppState>,
|
||||
run_id: &RunId,
|
||||
) -> Result<RunSandbox, Response> {
|
||||
) -> Result<(RunSandboxInstance, Arc<dyn Sandbox>), 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<AppState>,
|
||||
run_id: &RunId,
|
||||
record: &RunSandboxInstance,
|
||||
) -> Result<RunSandbox, Response> {
|
||||
) -> Result<Arc<dyn Sandbox>, 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<ProviderAccess, Response> {
|
||||
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<String>> {
|
||||
fn vnc_viewer_url(&self) -> futures_util::future::BoxFuture<'_, anyhow::Result<String>> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<dyn Environment> = 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)))?,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<Vec<RawDiffEntry>, 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<GitFacet<'_>, 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<GitFacet<'_>, 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<GitFacet<'_>, 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<DiffNumstat, DiffError> {
|
||||
|
|
@ -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<Vec<BlobMeta>, 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<Vec<Option<String>>, 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<dyn Sandbox>, 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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue