use agent_contracts::backend::{
capability::{
export::ExportFileRequest,
filesystem::{
ReadBytesRequest, TempPathKind, TempPathRequest, WriteBytesOutcome, WriteBytesRequest,
WriteMode,
},
OperationExport, OperationFileSystem,
},
BackendPath, ExportedFileHandle, ExportedFileMeta, ExportedFileReader, OperationError,
PathKind, PathStat, SharedExportedFileHandle,
};
use async_trait::async_trait;
use reqwest::header::{ACCEPT, CONTENT_TYPE};
use reqwest::{Method, StatusCode};
use serde::Deserialize;
use serde_json::json;
use std::io::Cursor;
use std::sync::Arc;
use super::backend::{connect_json, http_error, shell_quote, E2bBackendState};
use super::exec::E2bExec;
pub(crate) struct E2bFileSystem {
state: Arc<E2bBackendState>,
exec: E2bExec,
}
impl E2bFileSystem {
pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
Self {
exec: E2bExec::new(Arc::clone(&state)),
state,
}
}
async fn upload_raw(&self, path: &BackendPath, content: Vec<u8>) -> Result<(), OperationError> {
let request = self
.state
.envd_request(Method::POST, "/files")
.query(&[("path", path.0.as_str())])
.header(ACCEPT, "application/json");
let response = if self.state.envd_file_upload_multipart {
use reqwest::multipart::{Form, Part};
let name = path.0.rsplit('/').next().unwrap_or("file").to_string();
request
.multipart(Form::new().part("file", Part::bytes(content).file_name(name)))
.send()
} else {
request
.header(CONTENT_TYPE, "application/octet-stream")
.body(content)
.send()
}
.await
.map_err(|error| OperationError::Transport {
message: format!("failed to upload e2b file {}: {error}", path.0),
})?;
if response.status().is_success() {
return Ok(());
}
Err(http_error("upload e2b file", response).await)
}
}
struct E2bExportedFileHandle {
state: Arc<E2bBackendState>,
path: BackendPath,
metadata: ExportedFileMeta,
}
#[async_trait]
impl ExportedFileHandle for E2bExportedFileHandle {
fn metadata(&self) -> &ExportedFileMeta {
&self.metadata
}
async fn open_read(&self) -> Result<ExportedFileReader, OperationError> {
let fs = E2bFileSystem::new(Arc::clone(&self.state));
let content = fs
.read_bytes(ReadBytesRequest {
path: self.path.clone(),
})
.await?;
Ok(Box::new(Cursor::new(content)))
}
}
#[async_trait]
impl OperationFileSystem for E2bFileSystem {
async fn stat(&self, path: &BackendPath) -> Result<PathStat, OperationError> {
self.state.ensure_active()?;
let response: Result<StatResponse, OperationError> = connect_json(
&self.state,
"/filesystem.Filesystem/Stat",
json!({ "path": path.0 }),
)
.await;
match response {
Ok(response) => Ok(path_stat_from_entry(response.entry)),
Err(OperationError::NotFound { .. }) => Ok(PathStat {
exists: false,
kind: None,
size_bytes: None,
modified_at: None,
}),
Err(error) => Err(error),
}
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<Vec<u8>, OperationError> {
self.state.ensure_active()?;
let response = self
.state
.envd_request(Method::GET, "/files")
.query(&[("path", request.path.0.as_str())])
.header(ACCEPT, "application/octet-stream")
.send()
.await
.map_err(|error| OperationError::Transport {
message: format!("failed to download e2b file {}: {error}", request.path.0),
})?;
if response.status() == StatusCode::NOT_FOUND {
return Err(OperationError::NotFound {
path: request.path.0,
});
}
if !response.status().is_success() {
return Err(http_error("download e2b file", response).await);
}
response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|error| OperationError::Transport {
message: format!("failed to read e2b file response: {error}"),
})
}
async fn write_bytes(
&self,
request: WriteBytesRequest,
) -> Result<WriteBytesOutcome, OperationError> {
self.state.ensure_active()?;
if matches!(request.mode, WriteMode::Create) {
let stat = self.stat(&request.path).await?;
if stat.exists {
return Err(OperationError::AlreadyExists {
path: request.path.0,
});
}
}
let before = self.stat(&request.path).await?;
match request.mode {
WriteMode::AtomicOverwrite => {
let temp_path = self
.temp_path(TempPathRequest {
kind: TempPathKind::File,
preferred_parent: None,
prefix: Some(".xiaoo-atomic-".to_string()),
suffix: Some(".tmp".to_string()),
})
.await?;
self.upload_raw(&temp_path, request.content).await?;
let destination = shell_quote(request.path.0.as_str());
let script = format!(
"parent=$(dirname {destination}) && mkdir -p \"$parent\" && mv -f {} {destination}",
shell_quote(temp_path.0.as_str()),
);
let output = self.exec.run_shell_script(script.as_str(), None).await?;
if output.exit_code != Some(0) {
return Err(OperationError::ExecutionFailed {
message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
});
}
}
WriteMode::Create | WriteMode::Overwrite => {
self.upload_raw(&request.path, request.content).await?;
}
}
Ok(WriteBytesOutcome {
path: request.path,
created: !before.exists,
})
}
async fn create_dir_all(&self, path: &BackendPath) -> Result<(), OperationError> {
let script = format!("mkdir -p {}", shell_quote(path.0.as_str()));
let output = self.exec.run_shell_script(script.as_str(), None).await?;
if output.exit_code == Some(0) {
return Ok(());
}
Err(OperationError::ExecutionFailed {
message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
})
}
async fn temp_path(&self, request: TempPathRequest) -> Result<BackendPath, OperationError> {
let parent = request
.preferred_parent
.unwrap_or_else(|| self.state.temp_root.clone());
let quoted_parent = shell_quote(parent.0.as_str());
let prefix = shell_quote(request.prefix.as_deref().unwrap_or("tmp-"));
let suffix = shell_quote(request.suffix.as_deref().unwrap_or(""));
let creation = match request.kind {
TempPathKind::File => ": > \"$path\"",
TempPathKind::Directory => "mkdir \"$path\"",
};
let script = format!(
"mkdir -p {quoted_parent}\nprefix={prefix}\nsuffix={suffix}\nwhile true; do\n path=\"{parent}/$prefix$(date +%s%N)-$RANDOM$suffix\"\n if [ ! -e \"$path\" ]; then\n {creation}\n printf '%s' \"$path\"\n exit 0\n fi\ndone",
parent = parent.0,
);
let output = self.exec.run_shell_script(script.as_str(), None).await?;
if output.exit_code != Some(0) {
return Err(OperationError::ExecutionFailed {
message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
});
}
let text = String::from_utf8_lossy(output.stdout.as_slice())
.trim()
.to_string();
Ok(BackendPath(text))
}
}
#[async_trait]
impl OperationExport for E2bFileSystem {
async fn export_file(
&self,
request: ExportFileRequest,
) -> Result<SharedExportedFileHandle, OperationError> {
let stat = self.stat(&request.path).await?;
if !stat.exists {
return Err(OperationError::NotFound {
path: request.path.0.clone(),
});
}
if stat.kind != Some(PathKind::File) {
return Err(OperationError::NotFile {
path: request.path.0.clone(),
});
}
let file_name = request.preferred_name.unwrap_or_else(|| {
request
.path
.0
.rsplit('/')
.next()
.unwrap_or("exported-file")
.to_string()
});
Ok(Arc::new(E2bExportedFileHandle {
state: Arc::clone(&self.state),
path: request.path,
metadata: ExportedFileMeta {
file_name,
size_bytes: stat.size_bytes,
media_type: None,
},
}))
}
}
fn path_stat_from_entry(entry: Option<EntryInfo>) -> PathStat {
let Some(entry) = entry else {
return PathStat {
exists: false,
kind: None,
size_bytes: None,
modified_at: None,
};
};
PathStat {
exists: true,
kind: file_type_to_kind(entry.file_type.as_deref()),
size_bytes: entry.size.and_then(|size| size.as_u64()),
modified_at: None,
}
}
fn file_type_to_kind(value: Option<&str>) -> Option<PathKind> {
match value {
Some("FILE_TYPE_FILE") => Some(PathKind::File),
Some("FILE_TYPE_DIRECTORY") => Some(PathKind::Directory),
Some("FILE_TYPE_SYMLINK") => Some(PathKind::Symlink),
Some("FILE_TYPE_UNSPECIFIED") | None => None,
Some(_) => Some(PathKind::Other),
}
}
#[derive(Debug, Deserialize)]
struct StatResponse {
entry: Option<EntryInfo>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EntryInfo {
#[serde(rename = "type")]
file_type: Option<String>,
size: Option<SizeValue>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SizeValue {
Number(u64),
String(String),
}
impl SizeValue {
fn as_u64(&self) -> Option<u64> {
match self {
Self::Number(value) => Some(*value),
Self::String(value) => value.parse().ok(),
}
}
}