mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +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"
|
||||
include_dir = "0.7"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
mime_guess = "2.0"
|
||||
|
||||
# Code generation (build deps)
|
||||
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
|
||||
include_dir.workspace = true
|
||||
base64.workspace = true
|
||||
globset.workspace = true
|
||||
mime_guess.workspace = true
|
||||
tempfile = { workspace = true, optional = true }
|
||||
|
||||
[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;
|
||||
pub mod credentials;
|
||||
pub(crate) mod filesystem;
|
||||
pub mod opencode_compat;
|
||||
pub mod router;
|
||||
pub mod server_logs;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::str::FromStr;
|
|||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
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::{Json, Router};
|
||||
use futures::stream;
|
||||
|
|
@ -23,6 +23,7 @@ use tokio::sync::{broadcast, Mutex};
|
|||
use tokio::time::interval;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
|
||||
use crate::filesystem::{FileListOptions, FileReadOptions, FileReadRange, WorkspaceFileStatus};
|
||||
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
|
|
@ -491,10 +492,22 @@ struct FindSymbolsQuery {
|
|||
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)]
|
||||
struct FileContentQuery {
|
||||
directory: Option<String>,
|
||||
path: Option<String>,
|
||||
start: Option<u64>,
|
||||
end: Option<u64>,
|
||||
}
|
||||
|
||||
#[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> {
|
||||
let value = value.unwrap_or("once").to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
|
|
@ -3754,8 +3798,35 @@ async fn oc_pty_connect(Path(_pty_id): Path<String>) -> impl IntoResponse {
|
|||
responses((status = 200)),
|
||||
tag = "opencode"
|
||||
)]
|
||||
async fn oc_file_list() -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(json!([])))
|
||||
async fn oc_file_list(
|
||||
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(
|
||||
|
|
@ -3764,18 +3835,37 @@ async fn oc_file_list() -> impl IntoResponse {
|
|||
responses((status = 200)),
|
||||
tag = "opencode"
|
||||
)]
|
||||
async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResponse {
|
||||
if query.path.is_none() {
|
||||
async fn oc_file_content(
|
||||
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();
|
||||
};
|
||||
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(
|
||||
|
|
@ -3784,8 +3874,28 @@ async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResp
|
|||
responses((status = 200)),
|
||||
tag = "opencode"
|
||||
)]
|
||||
async fn oc_file_status() -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(json!([]))).into_response()
|
||||
async fn oc_file_status(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use tracing::Span;
|
|||
use utoipa::{Modify, OpenApi, ToSchema};
|
||||
|
||||
use crate::agent_server_logs::AgentServerLogs;
|
||||
use crate::filesystem::WorkspaceFilesystemService;
|
||||
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
|
||||
use crate::ui;
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
|
|
@ -818,6 +819,7 @@ pub(crate) struct SessionManager {
|
|||
sessions: Mutex<Vec<SessionState>>,
|
||||
server_manager: Arc<AgentServerManager>,
|
||||
http_client: Client,
|
||||
filesystem: WorkspaceFilesystemService,
|
||||
}
|
||||
|
||||
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
||||
|
|
@ -1538,6 +1540,7 @@ impl SessionManager {
|
|||
sessions: Mutex::new(Vec::new()),
|
||||
server_manager,
|
||||
http_client: Client::new(),
|
||||
filesystem: WorkspaceFilesystemService::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1562,6 +1565,10 @@ impl SessionManager {
|
|||
logs.read_stderr()
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_filesystem(&self) -> &WorkspaceFilesystemService {
|
||||
&self.filesystem
|
||||
}
|
||||
|
||||
pub(crate) async fn create_session(
|
||||
self: &Arc<Self>,
|
||||
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