mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 04:02:25 +00:00
feat: add filesystem service and opencode file endpoints
This commit is contained in:
parent
7378abee46
commit
688f8f45ad
11 changed files with 662 additions and 15 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/.turbo
|
||||||
|
|
@ -69,6 +69,8 @@ url = "2.5"
|
||||||
regress = "0.10"
|
regress = "0.10"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
globset = "0.4"
|
||||||
|
mime_guess = "2.0"
|
||||||
|
|
||||||
# Code generation (build deps)
|
# Code generation (build deps)
|
||||||
typify = "0.4"
|
typify = "0.4"
|
||||||
|
|
|
||||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/dist
|
||||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/node_modules
|
||||||
|
|
@ -36,6 +36,8 @@ tracing-logfmt.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
include_dir.workspace = true
|
include_dir.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
globset.workspace = true
|
||||||
|
mime_guess.workspace = true
|
||||||
tempfile = { workspace = true, optional = true }
|
tempfile = { workspace = true, optional = true }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
|
|
||||||
448
server/packages/sandbox-agent/src/filesystem.rs
Normal file
448
server/packages/sandbox-agent/src/filesystem.rs
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
||||||
|
use mime_guess::MimeGuess;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use sandbox_agent_error::SandboxError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct FileReadRange {
|
||||||
|
pub start: Option<u64>,
|
||||||
|
pub end: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct FileReadOptions {
|
||||||
|
pub path: String,
|
||||||
|
pub range: Option<FileReadRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct FileListOptions {
|
||||||
|
pub path: String,
|
||||||
|
pub glob: Option<String>,
|
||||||
|
pub depth: Option<usize>,
|
||||||
|
pub include_hidden: bool,
|
||||||
|
pub directories_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WorkspaceFileNode {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub absolute: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub entry_type: String,
|
||||||
|
pub ignored: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WorkspaceFileContent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub content_type: String,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub encoding: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WorkspaceVcsStatus {
|
||||||
|
pub status: String,
|
||||||
|
pub added: i64,
|
||||||
|
pub removed: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct WorkspaceFileStatus {
|
||||||
|
pub path: String,
|
||||||
|
pub exists: bool,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub entry_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub modified: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub vcs: Option<WorkspaceVcsStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct WorkspaceFilesystemService;
|
||||||
|
|
||||||
|
impl WorkspaceFilesystemService {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scoped(
|
||||||
|
&self,
|
||||||
|
root: impl Into<PathBuf>,
|
||||||
|
) -> Result<WorkspaceFilesystem, SandboxError> {
|
||||||
|
WorkspaceFilesystem::new(root.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct WorkspaceFilesystem {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceFilesystem {
|
||||||
|
fn new(root: PathBuf) -> Result<Self, SandboxError> {
|
||||||
|
let root = fs::canonicalize(&root).unwrap_or(root);
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "workspace root does not exist".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "workspace root is not a directory".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn list(
|
||||||
|
&self,
|
||||||
|
options: FileListOptions,
|
||||||
|
) -> Result<Vec<WorkspaceFileNode>, SandboxError> {
|
||||||
|
let path = options.path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path is required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let directory = self.resolve_path(path, false)?;
|
||||||
|
let metadata = fs::metadata(&directory).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to access directory: {err}"),
|
||||||
|
})?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path is not a directory".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let matcher = build_glob_matcher(options.glob.as_deref())?;
|
||||||
|
let max_depth = options.depth.unwrap_or(1);
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
queue.push_back((directory, 0usize));
|
||||||
|
|
||||||
|
while let Some((current_dir, depth)) = queue.pop_front() {
|
||||||
|
if depth >= max_depth {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let read_dir =
|
||||||
|
fs::read_dir(¤t_dir).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to read directory: {err}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for entry in read_dir {
|
||||||
|
let entry = entry.map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to read directory entry: {err}"),
|
||||||
|
})?;
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy().to_string();
|
||||||
|
if !options.include_hidden && name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to read file type: {err}"),
|
||||||
|
})?;
|
||||||
|
let entry_path = entry.path();
|
||||||
|
|
||||||
|
if file_type.is_dir() && !options.include_hidden && is_hidden_dir(&entry_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_path = path_relative_to_root(&self.root, &entry_path)?;
|
||||||
|
if let Some(matcher) = matcher.as_ref() {
|
||||||
|
if !matcher.is_match(relative_path.as_str()) {
|
||||||
|
if file_type.is_dir() {
|
||||||
|
if depth + 1 < max_depth {
|
||||||
|
queue.push_back((entry_path.clone(), depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.directories_only && !file_type.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_type = if file_type.is_dir() {
|
||||||
|
"directory"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(WorkspaceFileNode {
|
||||||
|
name,
|
||||||
|
path: relative_path,
|
||||||
|
absolute: entry_path.to_string_lossy().to_string(),
|
||||||
|
entry_type: entry_type.to_string(),
|
||||||
|
ignored: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if file_type.is_dir() && depth + 1 < max_depth {
|
||||||
|
queue.push_back((entry_path, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read(
|
||||||
|
&self,
|
||||||
|
options: FileReadOptions,
|
||||||
|
) -> Result<WorkspaceFileContent, SandboxError> {
|
||||||
|
let path = options.path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path is required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let file_path = self.resolve_path(path, false)?;
|
||||||
|
let metadata = fs::metadata(&file_path).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to access file: {err}"),
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path is not a file".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut bytes = fs::read(&file_path).map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("failed to read file: {err}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(range) = options.range {
|
||||||
|
bytes = apply_byte_range(bytes, range)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime = MimeGuess::from_path(&file_path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.essence_str()
|
||||||
|
.to_string();
|
||||||
|
if let Ok(text) = String::from_utf8(bytes.clone()) {
|
||||||
|
return Ok(WorkspaceFileContent {
|
||||||
|
content_type: "text".to_string(),
|
||||||
|
content: text,
|
||||||
|
encoding: None,
|
||||||
|
mime_type: Some(mime),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WorkspaceFileContent {
|
||||||
|
content_type: "binary".to_string(),
|
||||||
|
content: STANDARD.encode(bytes),
|
||||||
|
encoding: Some("base64".to_string()),
|
||||||
|
mime_type: Some(mime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status(&self) -> Result<Vec<WorkspaceFileStatus>, SandboxError> {
|
||||||
|
if !self.root.join(".git").exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("status")
|
||||||
|
.arg("--porcelain=v1")
|
||||||
|
.arg("-z")
|
||||||
|
.current_dir(&self.root)
|
||||||
|
.output()
|
||||||
|
.map_err(|err| SandboxError::StreamError {
|
||||||
|
message: format!("failed to run git status: {err}"),
|
||||||
|
})?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(SandboxError::StreamError {
|
||||||
|
message: format!("git status failed: {}", output.status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for record in stdout.split('\0').filter(|line| !line.is_empty()) {
|
||||||
|
let (status_code, path) = parse_git_porcelain_entry(record);
|
||||||
|
let Some(path) = path else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let status = map_git_status(status_code);
|
||||||
|
let absolute = self.root.join(&path);
|
||||||
|
let (exists, entry_type, size, modified) = file_metadata(&absolute);
|
||||||
|
entries.push(WorkspaceFileStatus {
|
||||||
|
path,
|
||||||
|
exists,
|
||||||
|
entry_type,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
vcs: Some(WorkspaceVcsStatus {
|
||||||
|
status,
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(&self, input: &str, allow_missing: bool) -> Result<PathBuf, SandboxError> {
|
||||||
|
let input_path = PathBuf::from(input);
|
||||||
|
if input_path
|
||||||
|
.components()
|
||||||
|
.any(|component| matches!(component, Component::ParentDir))
|
||||||
|
{
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path traversal is not allowed".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let joined = if input_path.is_absolute() {
|
||||||
|
input_path
|
||||||
|
} else {
|
||||||
|
self.root.join(input_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = if allow_missing {
|
||||||
|
normalize_path(&joined)
|
||||||
|
} else {
|
||||||
|
fs::canonicalize(&joined).unwrap_or(joined.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !normalized.starts_with(&self.root) {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "path is outside the workspace".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_glob_matcher(glob: Option<&str>) -> Result<Option<GlobSet>, SandboxError> {
|
||||||
|
let Some(pattern) = glob else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut builder = GlobSetBuilder::new();
|
||||||
|
let glob = GlobBuilder::new(pattern)
|
||||||
|
.literal_separator(true)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("invalid glob pattern: {err}"),
|
||||||
|
})?;
|
||||||
|
builder.add(glob);
|
||||||
|
let set = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|err| SandboxError::InvalidRequest {
|
||||||
|
message: format!("invalid glob matcher: {err}"),
|
||||||
|
})?;
|
||||||
|
Ok(Some(set))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_byte_range(bytes: Vec<u8>, range: FileReadRange) -> Result<Vec<u8>, SandboxError> {
|
||||||
|
let len = bytes.len() as u64;
|
||||||
|
let start = range.start.unwrap_or(0);
|
||||||
|
let end = range.end.unwrap_or(len);
|
||||||
|
if start > end || end > len {
|
||||||
|
return Err(SandboxError::InvalidRequest {
|
||||||
|
message: "invalid byte range".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bytes[start as usize..end as usize].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &Path) -> PathBuf {
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||||
|
Component::RootDir => normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
Component::Normal(value) => normalized.push(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_relative_to_root(root: &Path, path: &Path) -> Result<String, SandboxError> {
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|_| SandboxError::InvalidRequest {
|
||||||
|
message: "path is outside the workspace".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(relative.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hidden_dir(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_metadata(path: &Path) -> (bool, String, Option<u64>, Option<i64>) {
|
||||||
|
let Ok(metadata) = fs::metadata(path) else {
|
||||||
|
return (false, "file".to_string(), None, None);
|
||||||
|
};
|
||||||
|
let entry_type = if metadata.is_dir() {
|
||||||
|
"directory"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
};
|
||||||
|
let modified = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis() as i64);
|
||||||
|
(true, entry_type.to_string(), Some(metadata.len()), modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_git_porcelain_entry(entry: &str) -> (&str, Option<String>) {
|
||||||
|
if entry.len() < 3 {
|
||||||
|
return ("", None);
|
||||||
|
}
|
||||||
|
let status = &entry[0..2];
|
||||||
|
let path = entry[3..].trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return (status, None);
|
||||||
|
}
|
||||||
|
if let Some((_, new_path)) = path.split_once(" -> ") {
|
||||||
|
return (status, Some(new_path.to_string()));
|
||||||
|
}
|
||||||
|
(status, Some(path.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_git_status(status: &str) -> String {
|
||||||
|
if status.contains('D') {
|
||||||
|
return "deleted".to_string();
|
||||||
|
}
|
||||||
|
if status.contains('A') || status.contains('?') {
|
||||||
|
return "added".to_string();
|
||||||
|
}
|
||||||
|
"modified".to_string()
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
mod agent_server_logs;
|
mod agent_server_logs;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
|
pub(crate) mod filesystem;
|
||||||
pub mod opencode_compat;
|
pub mod opencode_compat;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod server_logs;
|
pub mod server_logs;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use std::str::FromStr;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::sse::{Event, KeepAlive};
|
use axum::response::sse::{Event, KeepAlive};
|
||||||
use axum::response::{IntoResponse, Sse};
|
use axum::response::{IntoResponse, Response, Sse};
|
||||||
use axum::routing::{get, patch, post, put};
|
use axum::routing::{get, patch, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
|
|
@ -23,6 +23,7 @@ use tokio::sync::{broadcast, Mutex};
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||||
|
|
||||||
|
use crate::filesystem::{FileListOptions, FileReadOptions, FileReadRange, WorkspaceFileStatus};
|
||||||
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
||||||
use sandbox_agent_error::SandboxError;
|
use sandbox_agent_error::SandboxError;
|
||||||
use sandbox_agent_agent_management::agents::AgentId;
|
use sandbox_agent_agent_management::agents::AgentId;
|
||||||
|
|
@ -491,10 +492,22 @@ struct FindSymbolsQuery {
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
struct FileListQuery {
|
||||||
|
directory: Option<String>,
|
||||||
|
path: Option<String>,
|
||||||
|
glob: Option<String>,
|
||||||
|
depth: Option<usize>,
|
||||||
|
hidden: Option<bool>,
|
||||||
|
directories: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
struct FileContentQuery {
|
struct FileContentQuery {
|
||||||
directory: Option<String>,
|
directory: Option<String>,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
|
start: Option<u64>,
|
||||||
|
end: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|
@ -769,6 +782,37 @@ fn sandbox_error_response(err: SandboxError) -> (StatusCode, Json<Value>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn filesystem_error_response(err: SandboxError) -> Response {
|
||||||
|
match err {
|
||||||
|
SandboxError::InvalidRequest { message } => bad_request(&message).into_response(),
|
||||||
|
SandboxError::StreamError { message } => internal_error(&message).into_response(),
|
||||||
|
other => internal_error(&other.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenCodeFileStatusEntry {
|
||||||
|
path: String,
|
||||||
|
added: i64,
|
||||||
|
removed: i64,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opencode_status_entries(entries: Vec<WorkspaceFileStatus>) -> Vec<OpenCodeFileStatusEntry> {
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let vcs = entry.vcs?;
|
||||||
|
Some(OpenCodeFileStatusEntry {
|
||||||
|
path: entry.path,
|
||||||
|
added: vcs.added,
|
||||||
|
removed: vcs.removed,
|
||||||
|
status: vcs.status,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_permission_reply_value(value: Option<&str>) -> Result<PermissionReply, String> {
|
fn parse_permission_reply_value(value: Option<&str>) -> Result<PermissionReply, String> {
|
||||||
let value = value.unwrap_or("once").to_ascii_lowercase();
|
let value = value.unwrap_or("once").to_ascii_lowercase();
|
||||||
match value.as_str() {
|
match value.as_str() {
|
||||||
|
|
@ -3754,8 +3798,35 @@ async fn oc_pty_connect(Path(_pty_id): Path<String>) -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_file_list() -> impl IntoResponse {
|
async fn oc_file_list(
|
||||||
(StatusCode::OK, Json(json!([])))
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<FileListQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(path) = query.path else {
|
||||||
|
return bad_request("path is required").into_response();
|
||||||
|
};
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let filesystem = match state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.workspace_filesystem()
|
||||||
|
.scoped(directory)
|
||||||
|
{
|
||||||
|
Ok(filesystem) => filesystem,
|
||||||
|
Err(err) => return filesystem_error_response(err),
|
||||||
|
};
|
||||||
|
let options = FileListOptions {
|
||||||
|
path,
|
||||||
|
glob: query.glob,
|
||||||
|
depth: query.depth,
|
||||||
|
include_hidden: query.hidden.unwrap_or(false),
|
||||||
|
directories_only: query.directories.unwrap_or(false),
|
||||||
|
};
|
||||||
|
match filesystem.list(options) {
|
||||||
|
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
|
||||||
|
Err(err) => filesystem_error_response(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -3764,18 +3835,37 @@ async fn oc_file_list() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResponse {
|
async fn oc_file_content(
|
||||||
if query.path.is_none() {
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<FileContentQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(path) = query.path else {
|
||||||
return bad_request("path is required").into_response();
|
return bad_request("path is required").into_response();
|
||||||
|
};
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let filesystem = match state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.workspace_filesystem()
|
||||||
|
.scoped(directory)
|
||||||
|
{
|
||||||
|
Ok(filesystem) => filesystem,
|
||||||
|
Err(err) => return filesystem_error_response(err),
|
||||||
|
};
|
||||||
|
let range = if query.start.is_some() || query.end.is_some() {
|
||||||
|
Some(FileReadRange {
|
||||||
|
start: query.start,
|
||||||
|
end: query.end,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let options = FileReadOptions { path, range };
|
||||||
|
match filesystem.read(options) {
|
||||||
|
Ok(content) => (StatusCode::OK, Json(content)).into_response(),
|
||||||
|
Err(err) => filesystem_error_response(err),
|
||||||
}
|
}
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(json!({
|
|
||||||
"type": "text",
|
|
||||||
"content": "",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -3784,8 +3874,28 @@ async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResp
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_file_status() -> impl IntoResponse {
|
async fn oc_file_status(
|
||||||
(StatusCode::OK, Json(json!([]))).into_response()
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let filesystem = match state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.workspace_filesystem()
|
||||||
|
.scoped(directory)
|
||||||
|
{
|
||||||
|
Ok(filesystem) => filesystem,
|
||||||
|
Err(err) => return filesystem_error_response(err),
|
||||||
|
};
|
||||||
|
match filesystem.status() {
|
||||||
|
Ok(entries) => {
|
||||||
|
let files = opencode_status_entries(entries);
|
||||||
|
(StatusCode::OK, Json(json!(files))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => filesystem_error_response(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ use tracing::Span;
|
||||||
use utoipa::{Modify, OpenApi, ToSchema};
|
use utoipa::{Modify, OpenApi, ToSchema};
|
||||||
|
|
||||||
use crate::agent_server_logs::AgentServerLogs;
|
use crate::agent_server_logs::AgentServerLogs;
|
||||||
|
use crate::filesystem::WorkspaceFilesystemService;
|
||||||
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
|
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use sandbox_agent_agent_management::agents::{
|
use sandbox_agent_agent_management::agents::{
|
||||||
|
|
@ -818,6 +819,7 @@ pub(crate) struct SessionManager {
|
||||||
sessions: Mutex<Vec<SessionState>>,
|
sessions: Mutex<Vec<SessionState>>,
|
||||||
server_manager: Arc<AgentServerManager>,
|
server_manager: Arc<AgentServerManager>,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
|
filesystem: WorkspaceFilesystemService,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
||||||
|
|
@ -1538,6 +1540,7 @@ impl SessionManager {
|
||||||
sessions: Mutex::new(Vec::new()),
|
sessions: Mutex::new(Vec::new()),
|
||||||
server_manager,
|
server_manager,
|
||||||
http_client: Client::new(),
|
http_client: Client::new(),
|
||||||
|
filesystem: WorkspaceFilesystemService::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1562,6 +1565,10 @@ impl SessionManager {
|
||||||
logs.read_stderr()
|
logs.read_stderr()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn workspace_filesystem(&self) -> &WorkspaceFilesystemService {
|
||||||
|
&self.filesystem
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_session(
|
pub(crate) async fn create_session(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterEach, beforeEach } from "vitest";
|
||||||
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||||
|
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
describe("OpenCode-compatible filesystem API", () => {
|
||||||
|
let handle: SandboxAgentHandle;
|
||||||
|
let client: OpencodeClient;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await buildSandboxAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "opencode-fs-"));
|
||||||
|
await writeFile(join(tempDir, "hello.txt"), "hello world\n");
|
||||||
|
await mkdir(join(tempDir, "nested"), { recursive: true });
|
||||||
|
await writeFile(join(tempDir, "nested", "child.txt"), "child content\n");
|
||||||
|
|
||||||
|
handle = await spawnSandboxAgent({
|
||||||
|
opencodeCompat: true,
|
||||||
|
env: {
|
||||||
|
OPENCODE_COMPAT_DIRECTORY: tempDir,
|
||||||
|
OPENCODE_COMPAT_WORKTREE: tempDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client = createOpencodeClient({
|
||||||
|
baseUrl: `${handle.baseUrl}/opencode`,
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await handle?.dispose();
|
||||||
|
if (tempDir) {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists files within the workspace", async () => {
|
||||||
|
const response = await client.file.list({
|
||||||
|
query: { path: "." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.data).toBeDefined();
|
||||||
|
expect(Array.isArray(response.data)).toBe(true);
|
||||||
|
const paths = (response.data ?? []).map((entry) => entry.path);
|
||||||
|
expect(paths).toContain("hello.txt");
|
||||||
|
expect(paths).toContain("nested");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads file content", async () => {
|
||||||
|
const response = await client.file.read({
|
||||||
|
query: { path: "hello.txt" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.data).toBeDefined();
|
||||||
|
expect(response.data?.type).toBe("text");
|
||||||
|
expect(response.data?.content).toContain("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects paths outside the workspace", async () => {
|
||||||
|
const response = await client.file.read({
|
||||||
|
query: { path: "../outside.txt" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
1
target
Symbolic link
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue