mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 22:01:43 +00:00
feat: download batch
This commit is contained in:
parent
3545139cd3
commit
e1a09564e4
14 changed files with 702 additions and 91 deletions
|
|
@ -26,7 +26,7 @@ use schemars::JsonSchema;
|
|||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tar::Archive;
|
||||
use tar::{Archive, Builder};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::Span;
|
||||
use utoipa::{Modify, OpenApi, ToSchema};
|
||||
|
|
@ -166,6 +166,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
.route("/fs/move", post(post_v1_fs_move))
|
||||
.route("/fs/stat", get(get_v1_fs_stat))
|
||||
.route("/fs/upload-batch", post(post_v1_fs_upload_batch))
|
||||
.route("/fs/download-batch", get(get_v1_fs_download_batch))
|
||||
.route(
|
||||
"/config/mcp",
|
||||
get(get_v1_config_mcp)
|
||||
|
|
@ -295,6 +296,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
post_v1_fs_move,
|
||||
get_v1_fs_stat,
|
||||
post_v1_fs_upload_batch,
|
||||
get_v1_fs_download_batch,
|
||||
get_v1_config_mcp,
|
||||
put_v1_config_mcp,
|
||||
delete_v1_config_mcp,
|
||||
|
|
@ -321,6 +323,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
FsEntriesQuery,
|
||||
FsDeleteQuery,
|
||||
FsUploadBatchQuery,
|
||||
FsDownloadBatchQuery,
|
||||
FsEntryType,
|
||||
FsEntry,
|
||||
FsStat,
|
||||
|
|
@ -1075,6 +1078,129 @@ async fn post_v1_fs_upload_batch(
|
|||
}))
|
||||
}
|
||||
|
||||
fn tar_add_path(
|
||||
builder: &mut Builder<&mut Vec<u8>>,
|
||||
base: &StdPath,
|
||||
path: &StdPath,
|
||||
) -> Result<(), SandboxError> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|err| map_fs_error(path, err))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: format!(
|
||||
"symlinks are not supported in download-batch: {}",
|
||||
path.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let rel = path
|
||||
.strip_prefix(base)
|
||||
.map_err(|_| SandboxError::InvalidRequest {
|
||||
message: format!("path is not under base: {}", path.display()),
|
||||
})?;
|
||||
let name = StdPath::new(".").join(rel);
|
||||
|
||||
if metadata.is_dir() {
|
||||
builder
|
||||
.append_dir(&name, path)
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
for entry in fs::read_dir(path).map_err(|err| map_fs_error(path, err))? {
|
||||
let entry = entry.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
tar_add_path(builder, base, &entry.path())?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if metadata.is_file() {
|
||||
builder
|
||||
.append_path_with_name(path, &name)
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(SandboxError::InvalidRequest {
|
||||
message: format!("unsupported filesystem entry type: {}", path.display()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Download a tar archive of a file or directory.
|
||||
///
|
||||
/// Returns `application/x-tar` bytes containing the requested path. If the path is a directory,
|
||||
/// the archive contains its contents (similar to `tar -C <dir> .`).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/fs/download-batch",
|
||||
tag = "v1",
|
||||
params(
|
||||
("path" = Option<String>, Query, description = "Source path (file or directory)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "tar archive bytes")
|
||||
)
|
||||
)]
|
||||
async fn get_v1_fs_download_batch(
|
||||
Query(query): Query<FsDownloadBatchQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let raw = query.path.unwrap_or_else(|| ".".to_string());
|
||||
let target = resolve_fs_path(&raw)?;
|
||||
let metadata = fs::symlink_metadata(&target).map_err(|err| map_fs_error(&target, err))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: format!(
|
||||
"symlinks are not supported in download-batch: {}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut out = Vec::<u8>::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut out);
|
||||
if metadata.is_dir() {
|
||||
// Pack directory contents, not an extra top-level folder wrapper.
|
||||
for entry in fs::read_dir(&target).map_err(|err| map_fs_error(&target, err))? {
|
||||
let entry = entry.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
tar_add_path(&mut builder, &target, &entry.path())?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
let name = StdPath::new(".").join(target.file_name().ok_or_else(|| {
|
||||
SandboxError::InvalidRequest {
|
||||
message: format!("invalid file path: {}", target.display()),
|
||||
}
|
||||
})?);
|
||||
builder
|
||||
.append_path_with_name(&target, name)
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
} else {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: format!("unsupported filesystem entry type: {}", target.display()),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
builder.finish().map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "application/x-tar")],
|
||||
Bytes::from(out),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/config/mcp",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,13 @@ pub struct FsUploadBatchQuery {
|
|||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsDownloadBatchQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FsEntryType {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_health_removed_legacy_and_opencode_unmounted() {
|
||||
|
|
@ -134,6 +135,73 @@ async fn v1_filesystem_endpoints_round_trip() {
|
|||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_filesystem_download_batch_returns_tar() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, _) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
"/v1/fs/file?path=docs/a.txt",
|
||||
Some(b"aaa".to_vec()),
|
||||
&[],
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, _, _) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
"/v1/fs/file?path=docs/nested/b.txt",
|
||||
Some(b"bbb".to_vec()),
|
||||
&[],
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, headers, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/fs/download-batch?path=docs",
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
"application/x-tar"
|
||||
);
|
||||
|
||||
let mut archive = tar::Archive::new(Cursor::new(body));
|
||||
let mut paths: Vec<String> = archive
|
||||
.entries()
|
||||
.expect("tar entries")
|
||||
.map(|entry| {
|
||||
entry
|
||||
.expect("tar entry")
|
||||
.path()
|
||||
.expect("tar path")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
let has_a = paths.iter().any(|p| p == "a.txt" || p == "./a.txt");
|
||||
let has_b = paths
|
||||
.iter()
|
||||
.any(|p| p == "nested/b.txt" || p == "./nested/b.txt");
|
||||
assert!(has_a, "expected a.txt in tar, got: {paths:?}");
|
||||
assert!(has_b, "expected nested/b.txt in tar, got: {paths:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn require_preinstall_blocks_missing_agent() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue