use secure_exec_kernel::mount_plugin::{
FileSystemPluginFactory, OpenFileSystemPluginRequest, PluginError,
};
use secure_exec_kernel::mount_table::{MountedFileSystem, MountedVirtualFileSystem};
use secure_exec_kernel::vfs::{
normalize_path, VfsError, VfsResult, VirtualDirEntry, VirtualFileSystem, VirtualStat, S_IFDIR,
S_IFREG,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const DEFAULT_MAX_FULL_READ_BYTES: u64 = 256 * 1024;
const DEFAULT_PROCESS_TIMEOUT_MS: u64 = 10_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SandboxAgentMountConfig {
base_url: String,
token: Option<String>,
headers: Option<BTreeMap<String, String>>,
base_path: Option<String>,
timeout_ms: Option<u64>,
max_full_read_bytes: Option<u64>,
}
#[derive(Debug)]
pub(crate) struct SandboxAgentMountPlugin;
impl<Context> FileSystemPluginFactory<Context> for SandboxAgentMountPlugin {
fn plugin_id(&self) -> &'static str {
"sandbox_agent"
}
fn open(
&self,
request: OpenFileSystemPluginRequest<'_, Context>,
) -> Result<Box<dyn MountedFileSystem>, PluginError> {
let config: SandboxAgentMountConfig = serde_json::from_value(request.config.clone())
.map_err(|error| PluginError::invalid_input(error.to_string()))?;
let filesystem = SandboxAgentFilesystem::from_config(config)?;
Ok(Box::new(MountedVirtualFileSystem::new(filesystem)))
}
}
struct SandboxAgentFilesystem {
client: SandboxAgentFilesystemClient,
base_path: String,
max_full_read_bytes: u64,
process_runtime: Mutex<Option<RemoteProcessRuntime>>,
}
impl SandboxAgentFilesystem {
fn from_config(config: SandboxAgentMountConfig) -> Result<Self, PluginError> {
let base_url = validate_sandbox_agent_base_url(&config.base_url)?;
let timeout_ms = config.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout = Duration::from_millis(timeout_ms);
let base_path = normalize_sandbox_agent_base_path(config.base_path.as_deref());
Ok(Self {
client: SandboxAgentFilesystemClient::new(
base_url,
config.token,
config.headers.unwrap_or_default(),
timeout,
),
base_path,
max_full_read_bytes: config
.max_full_read_bytes
.unwrap_or(DEFAULT_MAX_FULL_READ_BYTES),
process_runtime: Mutex::new(None),
})
}
fn scoped_path(&self, path: &str) -> String {
let normalized = normalize_path(path);
if self.base_path == "/" {
return normalized;
}
let suffix = normalized.trim_start_matches('/');
if self.base_path.starts_with('/') {
return normalize_path(&format!(
"{}/{}",
self.base_path.trim_end_matches('/'),
suffix
));
}
if suffix.is_empty() {
self.base_path.clone()
} else {
format!("{}/{}", self.base_path.trim_end_matches('/'), suffix)
}
}
fn stat_from_remote(stat: &SandboxAgentFsStat) -> VirtualStat {
let modified_ms = now_ms();
let is_directory = stat.entry_type == "directory";
VirtualStat {
mode: if is_directory {
S_IFDIR | 0o755
} else {
S_IFREG | 0o644
},
size: stat.size,
blocks: if stat.size == 0 {
0
} else {
stat.size.div_ceil(512)
},
dev: 1,
rdev: 0,
is_directory,
is_symbolic_link: false,
atime_ms: modified_ms,
atime_nsec: 0,
mtime_ms: modified_ms,
mtime_nsec: 0,
ctime_ms: modified_ms,
ctime_nsec: 0,
birthtime_ms: modified_ms,
ino: 0,
nlink: 1,
uid: 0,
gid: 0,
}
}
fn is_virtual_mount_root(&self, path: &str) -> bool {
self.base_path != "/" && normalize_path(path) == "/"
}
fn virtual_mount_root_stat(&self) -> VirtualStat {
let modified_ms = now_ms();
VirtualStat {
mode: S_IFDIR | 0o755,
size: 0,
blocks: 0,
dev: 1,
rdev: 0,
is_directory: true,
is_symbolic_link: false,
atime_ms: modified_ms,
atime_nsec: 0,
mtime_ms: modified_ms,
mtime_nsec: 0,
ctime_ms: modified_ms,
ctime_nsec: 0,
birthtime_ms: modified_ms,
ino: 0,
nlink: 1,
uid: 0,
gid: 0,
}
}
fn scoped_target(&self, target: &str) -> String {
if target.starts_with('/') {
let scoped = self.scoped_path(target);
if scoped.starts_with('/') {
scoped
} else {
format!("/{scoped}")
}
} else {
target.to_owned()
}
}
fn strip_base_path_prefix<'a>(&self, target: &'a str) -> Option<&'a str> {
if self.base_path == "/" {
return None;
}
let base_path = self.base_path.trim_end_matches('/');
if target == base_path {
Some("")
} else if let Some(stripped) = target
.strip_prefix(base_path)
.filter(|stripped| stripped.starts_with('/'))
{
Some(stripped)
} else if !base_path.starts_with('/') {
let absolute_base_path = format!("/{base_path}");
if target == absolute_base_path {
Some("")
} else {
target
.strip_prefix(&absolute_base_path)
.filter(|stripped| stripped.starts_with('/'))
}
} else {
None
}
}
fn unscoped_target(&self, target: String) -> String {
match self.strip_base_path_prefix(&target) {
Some(stripped) => format!("/{}", stripped.trim_start_matches('/')),
None => target,
}
}
fn process_runtimes(&self) -> Vec<RemoteProcessRuntime> {
let cached = *self
.process_runtime
.lock()
.expect("lock sandbox_agent process runtime cache");
let mut runtimes = Vec::with_capacity(3);
if let Some(runtime) = cached {
runtimes.push(runtime);
}
for runtime in [
RemoteProcessRuntime::Python3,
RemoteProcessRuntime::Python,
RemoteProcessRuntime::Node,
] {
if Some(runtime) != cached {
runtimes.push(runtime);
}
}
runtimes
}
fn remember_process_runtime(&self, runtime: RemoteProcessRuntime) {
*self
.process_runtime
.lock()
.expect("lock sandbox_agent process runtime cache") = Some(runtime);
}
fn run_fs_script(
&self,
op: &'static str,
path: &str,
python_script: &'static str,
node_script: &'static str,
args: &[String],
) -> VfsResult<Option<String>> {
let mut saw_runtime_candidate = false;
for runtime in self.process_runtimes() {
saw_runtime_candidate = true;
match self.run_fs_script_with_runtime(
runtime,
op,
path,
python_script,
node_script,
args,
) {
Ok(result) => {
self.remember_process_runtime(runtime);
return Ok(result);
}
Err(ProcessFallbackError::RuntimeUnavailable) => continue,
Err(ProcessFallbackError::Unsupported(message)) => {
return Err(VfsError::unsupported(format!(
"sandbox_agent {op} '{path}' requires remote process execution but the sandbox-agent server does not support the process API: {message}"
)));
}
Err(ProcessFallbackError::Operation(error)) => return Err(error),
}
}
debug_assert!(saw_runtime_candidate);
Err(VfsError::unsupported(format!(
"sandbox_agent {op} '{path}' requires a remote `python3`, `python`, or `node` runtime via the sandbox-agent process API, but none were available"
)))
}
fn run_fs_script_with_runtime(
&self,
runtime: RemoteProcessRuntime,
op: &'static str,
path: &str,
python_script: &'static str,
node_script: &'static str,
args: &[String],
) -> Result<Option<String>, ProcessFallbackError> {
let request = runtime.process_request(args, python_script, node_script);
let response = self
.client
.run_process(&request)
.map_err(|error| match error {
SandboxAgentClientError::Status {
status: 404 | 405 | 501,
problem,
} => ProcessFallbackError::Unsupported(
problem
.detail
.or(problem.title)
.unwrap_or_else(|| String::from("process API unavailable")),
),
other => {
ProcessFallbackError::Operation(sandbox_client_error_to_vfs(op, path, other))
}
})?;
if response.timed_out {
return Err(ProcessFallbackError::Operation(VfsError::io(format!(
"{op} '{path}': remote process helper timed out after {} ms",
DEFAULT_PROCESS_TIMEOUT_MS
))));
}
if response.exit_code.unwrap_or_default() == 0 {
if response.stdout.is_empty() {
return Ok(None);
}
return parse_process_json_output(&response.stdout, op, path)
.map(Some)
.map_err(ProcessFallbackError::Operation);
}
if runtime.command_missing(&response) {
return Err(ProcessFallbackError::RuntimeUnavailable);
}
Err(ProcessFallbackError::Operation(process_response_to_vfs(
op, path, response,
)))
}
}
impl VirtualFileSystem for SandboxAgentFilesystem {
fn read_file(&mut self, path: &str) -> VfsResult<Vec<u8>> {
let remote_path = self.scoped_path(path);
self.client
.read_fs_file(&remote_path)
.map_err(|error| sandbox_client_error_to_vfs("open", path, error))
}
fn read_dir(&mut self, path: &str) -> VfsResult<Vec<String>> {
let remote_path = self.scoped_path(path);
let mut entries = self
.client
.list_fs_entries(&remote_path)
.or_else(|error| {
if self.is_virtual_mount_root(path) && is_missing_path_error(&error) {
Ok(Vec::new())
} else {
Err(sandbox_client_error_to_vfs("readdir", path, error))
}
})?
.into_iter()
.map(|entry| entry.name)
.filter(|name| name != "." && name != "..")
.collect::<Vec<_>>();
entries.sort();
Ok(entries)
}
fn read_dir_with_types(&mut self, path: &str) -> VfsResult<Vec<VirtualDirEntry>> {
let remote_path = self.scoped_path(path);
let mut entries = self
.client
.list_fs_entries(&remote_path)
.or_else(|error| {
if self.is_virtual_mount_root(path) && is_missing_path_error(&error) {
Ok(Vec::new())
} else {
Err(sandbox_client_error_to_vfs("readdir", path, error))
}
})?
.into_iter()
.filter(|entry| entry.name != "." && entry.name != "..")
.map(|entry| VirtualDirEntry {
name: entry.name,
is_directory: entry.entry_type == "directory",
is_symbolic_link: false,
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| left.name.cmp(&right.name));
Ok(entries)
}
fn write_file(&mut self, path: &str, content: impl Into<Vec<u8>>) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
self.client
.write_fs_file(&remote_path, &content.into())
.map_err(|error| sandbox_client_error_to_vfs("write", path, error))
}
fn create_dir(&mut self, path: &str) -> VfsResult<()> {
self.mkdir(path, false)
}
fn mkdir(&mut self, path: &str, recursive: bool) -> VfsResult<()> {
if !recursive {
let parent_path = dirname(path);
if parent_path != "/" {
let parent_remote = self.scoped_path(&parent_path);
let parent = self
.client
.stat_fs(&parent_remote)
.map_err(|error| sandbox_client_error_to_vfs("mkdir", &parent_path, error))?;
if parent.entry_type != "directory" {
return Err(VfsError::new(
"ENOTDIR",
format!("not a directory, mkdir '{parent_path}'"),
));
}
}
}
let remote_path = self.scoped_path(path);
self.client
.mkdir_fs(&remote_path)
.map_err(|error| sandbox_client_error_to_vfs("mkdir", path, error))
}
fn exists(&self, path: &str) -> bool {
let remote_path = self.scoped_path(path);
match self.client.stat_fs(&remote_path) {
Ok(_) => true,
Err(error) => self.is_virtual_mount_root(path) && is_missing_path_error(&error),
}
}
fn stat(&mut self, path: &str) -> VfsResult<VirtualStat> {
let remote_path = self.scoped_path(path);
match self.client.stat_fs(&remote_path) {
Ok(stat) => Ok(Self::stat_from_remote(&stat)),
Err(error) if self.is_virtual_mount_root(path) && is_missing_path_error(&error) => {
Ok(self.virtual_mount_root_stat())
}
Err(error) => Err(sandbox_client_error_to_vfs("stat", path, error)),
}
}
fn remove_file(&mut self, path: &str) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
self.client
.delete_fs_entry(&remote_path, false)
.map_err(|error| sandbox_client_error_to_vfs("unlink", path, error))
}
fn remove_dir(&mut self, path: &str) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
let entries = self
.client
.list_fs_entries(&remote_path)
.map_err(|error| sandbox_client_error_to_vfs("rmdir", path, error))?;
let children = entries
.into_iter()
.filter(|entry| entry.name != "." && entry.name != "..")
.count();
if children > 0 {
return Err(VfsError::new(
"ENOTEMPTY",
format!("directory not empty, rmdir '{path}'"),
));
}
self.client
.delete_fs_entry(&remote_path, false)
.map_err(|error| sandbox_client_error_to_vfs("rmdir", path, error))
}
fn rename(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
let old_remote = self.scoped_path(old_path);
let new_remote = self.scoped_path(new_path);
self.client
.move_fs(&old_remote, &new_remote, true)
.map_err(|error| sandbox_client_error_to_vfs("rename", old_path, error))
}
fn realpath(&self, path: &str) -> VfsResult<String> {
let remote_path = self.scoped_path(path);
let resolved = self.run_fs_script(
"realpath",
path,
PYTHON_REALPATH_SCRIPT,
NODE_REALPATH_SCRIPT,
&[remote_path],
)?;
Ok(self.unscoped_target(resolved.unwrap_or_else(|| normalize_path(path))))
}
fn symlink(&mut self, target: &str, link_path: &str) -> VfsResult<()> {
let remote_target = self.scoped_target(target);
let remote_link = self.scoped_path(link_path);
self.run_fs_script(
"symlink",
link_path,
PYTHON_SYMLINK_SCRIPT,
NODE_SYMLINK_SCRIPT,
&[remote_target, remote_link],
)?;
Ok(())
}
fn read_link(&self, path: &str) -> VfsResult<String> {
let remote_path = self.scoped_path(path);
let target = self.run_fs_script(
"readlink",
path,
PYTHON_READLINK_SCRIPT,
NODE_READLINK_SCRIPT,
&[remote_path],
)?;
Ok(match target {
Some(target) if target.starts_with('/') => self.unscoped_target(target),
Some(target) => target,
None => String::new(),
})
}
fn lstat(&self, path: &str) -> VfsResult<VirtualStat> {
let remote_path = self.scoped_path(path);
match self.client.stat_fs(&remote_path) {
Ok(stat) => Ok(Self::stat_from_remote(&stat)),
Err(error) if self.is_virtual_mount_root(path) && is_missing_path_error(&error) => {
Ok(self.virtual_mount_root_stat())
}
Err(error) => Err(sandbox_client_error_to_vfs("lstat", path, error)),
}
}
fn link(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
let old_remote = self.scoped_path(old_path);
let new_remote = self.scoped_path(new_path);
self.run_fs_script(
"link",
new_path,
PYTHON_LINK_SCRIPT,
NODE_LINK_SCRIPT,
&[old_remote, new_remote],
)?;
Ok(())
}
fn chmod(&mut self, path: &str, mode: u32) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
self.run_fs_script(
"chmod",
path,
PYTHON_CHMOD_SCRIPT,
NODE_CHMOD_SCRIPT,
&[remote_path, mode.to_string()],
)?;
Ok(())
}
fn chown(&mut self, path: &str, uid: u32, gid: u32) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
self.run_fs_script(
"chown",
path,
PYTHON_CHOWN_SCRIPT,
NODE_CHOWN_SCRIPT,
&[remote_path, uid.to_string(), gid.to_string()],
)?;
Ok(())
}
fn utimes(&mut self, path: &str, atime_ms: u64, mtime_ms: u64) -> VfsResult<()> {
let remote_path = self.scoped_path(path);
self.run_fs_script(
"utimes",
path,
PYTHON_UTIMES_SCRIPT,
NODE_UTIMES_SCRIPT,
&[remote_path, atime_ms.to_string(), mtime_ms.to_string()],
)?;
Ok(())
}
fn truncate(&mut self, path: &str, length: u64) -> VfsResult<()> {
if length == 0 {
return self.write_file(path, Vec::<u8>::new());
}
let remote_path = self.scoped_path(path);
self.run_fs_script(
"truncate",
path,
PYTHON_TRUNCATE_SCRIPT,
NODE_TRUNCATE_SCRIPT,
&[remote_path, length.to_string()],
)?;
Ok(())
}
fn pread(&mut self, path: &str, offset: u64, length: usize) -> VfsResult<Vec<u8>> {
if length == 0 {
return Ok(Vec::new());
}
let remote_path = self.scoped_path(path);
let stat = self
.client
.stat_fs(&remote_path)
.map_err(|error| sandbox_client_error_to_vfs("open", path, error))?;
if stat.entry_type == "directory" {
return Err(VfsError::new(
"EISDIR",
format!("illegal operation on a directory, open '{path}'"),
));
}
if offset >= stat.size {
return Ok(Vec::new());
}
match self
.client
.read_fs_file_range(&remote_path, offset, length, self.max_full_read_bytes)
.map_err(|error| sandbox_client_error_to_vfs("open", path, error))?
{
SandboxAgentReadResponse::Partial(content) => Ok(content),
SandboxAgentReadResponse::Full(content) => {
tracing::warn!(
path,
downloaded_bytes = content.len(),
max_full_read_bytes = self.max_full_read_bytes,
"sandbox_agent pread fell back to full-file get because remote ignored range"
);
let start = usize::try_from(offset).unwrap_or(usize::MAX);
if start >= content.len() {
return Ok(Vec::new());
}
let end = start.saturating_add(length).min(content.len());
Ok(content[start..end].to_vec())
}
}
}
}
struct SandboxAgentFilesystemClient {
base_url: String,
token: Option<String>,
headers: BTreeMap<String, String>,
agent: ureq::Agent,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RemoteProcessRuntime {
Python3,
Python,
Node,
}
impl RemoteProcessRuntime {
fn command(self) -> &'static str {
match self {
Self::Python3 => "python3",
Self::Python => "python",
Self::Node => "node",
}
}
fn process_request(
self,
args: &[String],
python_script: &'static str,
node_script: &'static str,
) -> SandboxAgentProcessRunRequest {
match self {
Self::Python3 | Self::Python => {
let mut process_args = vec![String::from("-c"), python_script.to_owned()];
process_args.extend(args.iter().cloned());
SandboxAgentProcessRunRequest {
command: self.command().to_owned(),
args: process_args,
cwd: Some(String::from("/")),
env: None,
max_output_bytes: None,
timeout_ms: Some(DEFAULT_PROCESS_TIMEOUT_MS),
}
}
Self::Node => {
let mut process_args = vec![String::from("-e"), node_script.to_owned()];
if !args.is_empty() {
process_args.push(String::from("--"));
process_args.extend(args.iter().cloned());
}
SandboxAgentProcessRunRequest {
command: self.command().to_owned(),
args: process_args,
cwd: Some(String::from("/")),
env: None,
max_output_bytes: None,
timeout_ms: Some(DEFAULT_PROCESS_TIMEOUT_MS),
}
}
}
}
fn command_missing(self, response: &SandboxAgentProcessRunResponse) -> bool {
if serde_json::from_str::<FsScriptJsonError>(response.stderr.trim()).is_ok() {
return false;
}
let stderr = response.stderr.to_ascii_lowercase();
response.exit_code == Some(127)
|| stderr.contains("command not found")
|| stderr.contains("executable file not found")
|| stderr.contains("enoent")
}
}
enum ProcessFallbackError {
RuntimeUnavailable,
Unsupported(String),
Operation(VfsError),
}
impl SandboxAgentFilesystemClient {
fn new(
base_url: String,
token: Option<String>,
headers: BTreeMap<String, String>,
timeout: Duration,
) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout_connect(timeout)
.timeout_read(timeout)
.timeout_write(timeout)
.redirects(0)
.build();
Self {
base_url,
token,
headers,
agent,
}
}
fn list_fs_entries(
&self,
path: &str,
) -> Result<Vec<SandboxAgentFsEntry>, SandboxAgentClientError> {
self.request_json(
"GET",
"/v1/fs/entries",
vec![(String::from("path"), path.to_owned())],
RequestBody::None,
Some("application/json"),
)
}
fn read_fs_file(&self, path: &str) -> Result<Vec<u8>, SandboxAgentClientError> {
self.request_bytes(
"GET",
"/v1/fs/file",
vec![(String::from("path"), path.to_owned())],
Some("application/octet-stream"),
)
}
fn read_fs_file_range(
&self,
path: &str,
offset: u64,
length: usize,
max_full_read_bytes: u64,
) -> Result<SandboxAgentReadResponse, SandboxAgentClientError> {
let range_length = u64::try_from(length).unwrap_or(u64::MAX);
let end = offset.saturating_add(range_length.saturating_sub(1));
let response = self.request_raw_with_headers(
"GET",
"/v1/fs/file",
vec![(String::from("path"), path.to_owned())],
RequestBody::None,
Some("application/octet-stream"),
vec![(String::from("Range"), format!("bytes={offset}-{end}"))],
)?;
let status = response.status();
Ok(match status {
206 => SandboxAgentReadResponse::Partial(response_into_bytes_limited(
response,
u64::try_from(length).unwrap_or(u64::MAX),
)?),
_ => SandboxAgentReadResponse::Full(response_into_bytes_limited(
response,
max_full_read_bytes,
)?),
})
}
fn write_fs_file(&self, path: &str, content: &[u8]) -> Result<(), SandboxAgentClientError> {
self.request_empty(
"PUT",
"/v1/fs/file",
vec![(String::from("path"), path.to_owned())],
RequestBody::Bytes(content.to_vec()),
Some("application/json"),
)
}
fn delete_fs_entry(&self, path: &str, recursive: bool) -> Result<(), SandboxAgentClientError> {
let mut query = vec![(String::from("path"), path.to_owned())];
if recursive {
query.push((String::from("recursive"), String::from("true")));
}
self.request_empty(
"DELETE",
"/v1/fs/entry",
query,
RequestBody::None,
Some("application/json"),
)
}
fn mkdir_fs(&self, path: &str) -> Result<(), SandboxAgentClientError> {
self.request_empty(
"POST",
"/v1/fs/mkdir",
vec![(String::from("path"), path.to_owned())],
RequestBody::None,
Some("application/json"),
)
}
fn move_fs(
&self,
from: &str,
to: &str,
overwrite: bool,
) -> Result<(), SandboxAgentClientError> {
self.request_empty(
"POST",
"/v1/fs/move",
Vec::new(),
RequestBody::Json(serde_json::json!({
"from": from,
"to": to,
"overwrite": overwrite,
})),
Some("application/json"),
)
}
fn stat_fs(&self, path: &str) -> Result<SandboxAgentFsStat, SandboxAgentClientError> {
self.request_json(
"GET",
"/v1/fs/stat",
vec![(String::from("path"), path.to_owned())],
RequestBody::None,
Some("application/json"),
)
}
fn run_process(
&self,
request: &SandboxAgentProcessRunRequest,
) -> Result<SandboxAgentProcessRunResponse, SandboxAgentClientError> {
self.request_json(
"POST",
"/v1/processes/run",
Vec::new(),
RequestBody::Json(
serde_json::to_value(request).expect("serialize process run request"),
),
Some("application/json"),
)
}
fn request_json<T: DeserializeOwned>(
&self,
method: &str,
path: &str,
query: Vec<(String, String)>,
body: RequestBody,
accept: Option<&str>,
) -> Result<T, SandboxAgentClientError> {
let response = self.request_raw(method, path, query, body, accept)?;
response
.into_json::<T>()
.map_err(|error| SandboxAgentClientError::Decode(error.to_string()))
}
fn request_bytes(
&self,
method: &str,
path: &str,
query: Vec<(String, String)>,
accept: Option<&str>,
) -> Result<Vec<u8>, SandboxAgentClientError> {
let response = self.request_raw(method, path, query, RequestBody::None, accept)?;
response_into_bytes(response)
}
fn request_empty(
&self,
method: &str,
path: &str,
query: Vec<(String, String)>,
body: RequestBody,
accept: Option<&str>,
) -> Result<(), SandboxAgentClientError> {
self.request_raw(method, path, query, body, accept)?;
Ok(())
}
fn request_raw(
&self,
method: &str,
path: &str,
query: Vec<(String, String)>,
body: RequestBody,
accept: Option<&str>,
) -> Result<ureq::Response, SandboxAgentClientError> {
self.request_raw_with_headers(method, path, query, body, accept, Vec::new())
}
fn request_raw_with_headers(
&self,
method: &str,
path: &str,
query: Vec<(String, String)>,
body: RequestBody,
accept: Option<&str>,
request_headers: Vec<(String, String)>,
) -> Result<ureq::Response, SandboxAgentClientError> {
let mut request = self
.agent
.request(method, &format!("{}{}", self.base_url, path));
if let Some(token) = &self.token {
request = request.set("Authorization", &format!("Bearer {token}"));
}
for (name, value) in &self.headers {
request = request.set(name, value);
}
for (name, value) in request_headers {
request = request.set(&name, &value);
}
if let Some(accept) = accept {
request = request.set("Accept", accept);
}
for (name, value) in query {
request = request.query(&name, &value);
}
let response = match body {
RequestBody::None => request.call(),
RequestBody::Json(value) => request.send_json(value),
RequestBody::Bytes(content) => request
.set("Content-Type", "application/octet-stream")
.send_bytes(&content),
};
match response {
Ok(response) if response.status() >= 300 => Err(SandboxAgentClientError::Status {
status: response.status(),
problem: read_problem_details(response),
}),
Ok(response) => Ok(response),
Err(ureq::Error::Status(status, response)) => Err(SandboxAgentClientError::Status {
status,
problem: read_problem_details(response),
}),
Err(ureq::Error::Transport(error)) => {
Err(SandboxAgentClientError::Transport(error.to_string()))
}
}
}
}
enum SandboxAgentReadResponse {
Partial(Vec<u8>),
Full(Vec<u8>),
}
enum RequestBody {
None,
Json(serde_json::Value),
Bytes(Vec<u8>),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SandboxAgentFsEntry {
name: String,
#[serde(rename = "path")]
_path: String,
entry_type: String,
#[serde(rename = "size")]
_size: u64,
#[serde(rename = "modified")]
_modified: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SandboxAgentFsStat {
#[serde(rename = "path")]
_path: String,
entry_type: String,
size: u64,
#[serde(rename = "modified")]
_modified: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SandboxAgentProcessRunRequest {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SandboxAgentProcessRunResponse {
#[serde(rename = "durationMs")]
_duration_ms: u64,
exit_code: Option<i32>,
stderr: String,
#[serde(rename = "stderrTruncated")]
_stderr_truncated: bool,
stdout: String,
#[serde(rename = "stdoutTruncated")]
_stdout_truncated: bool,
timed_out: bool,
}
#[derive(Debug, Deserialize)]
struct FsScriptJsonOutput {
result: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FsScriptJsonError {
errno: Option<i32>,
message: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct SandboxAgentProblemDetails {
title: Option<String>,
detail: Option<String>,
status: Option<u16>,
}
#[derive(Debug)]
enum SandboxAgentClientError {
Status {
status: u16,
problem: SandboxAgentProblemDetails,
},
Transport(String),
Decode(String),
}
fn read_problem_details(response: ureq::Response) -> SandboxAgentProblemDetails {
match response.into_string() {
Ok(body) if !body.is_empty() => {
serde_json::from_str(&body).unwrap_or_else(|_| SandboxAgentProblemDetails {
detail: Some(body),
..SandboxAgentProblemDetails::default()
})
}
_ => SandboxAgentProblemDetails::default(),
}
}
fn response_into_bytes(response: ureq::Response) -> Result<Vec<u8>, SandboxAgentClientError> {
let mut reader = response.into_reader();
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|error| SandboxAgentClientError::Decode(error.to_string()))?;
Ok(bytes)
}
fn response_into_bytes_limited(
response: ureq::Response,
max_bytes: u64,
) -> Result<Vec<u8>, SandboxAgentClientError> {
if response
.header("Content-Length")
.and_then(|value| value.trim().parse::<u64>().ok())
.is_some_and(|content_length| content_length > max_bytes)
{
return Err(SandboxAgentClientError::Decode(format!(
"sandbox-agent response exceeded {max_bytes} byte limit"
)));
}
let read_limit = max_bytes.saturating_add(1);
let mut reader = response.into_reader().take(read_limit);
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|error| SandboxAgentClientError::Decode(error.to_string()))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(SandboxAgentClientError::Decode(format!(
"sandbox-agent response exceeded {max_bytes} byte limit"
)));
}
Ok(bytes)
}
fn validate_sandbox_agent_base_url(raw: &str) -> Result<String, PluginError> {
validate_sandbox_agent_base_url_with_resolver(raw, resolve_sandbox_agent_base_url_host)
}
fn validate_sandbox_agent_base_url_with_resolver(
raw: &str,
resolve_host: impl FnOnce(&str, u16) -> std::io::Result<Vec<SocketAddr>>,
) -> Result<String, PluginError> {
let normalized = raw.trim().trim_end_matches('/').to_owned();
if normalized.is_empty() {
return Err(PluginError::invalid_input(
"sandbox_agent mount requires a non-empty baseUrl",
));
}
let url = Url::parse(&normalized).map_err(|error| {
PluginError::invalid_input(format!(
"sandbox_agent mount baseUrl is not a valid URL: {error}"
))
})?;
let host = url.host_str().ok_or_else(|| {
PluginError::invalid_input("sandbox_agent mount baseUrl must include a host")
})?;
let host_for_address = host
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or(host);
if url.query().is_some() || url.fragment().is_some() {
return Err(PluginError::invalid_input(
"sandbox_agent mount baseUrl must not include a query string or fragment",
));
}
let scheme = url.scheme();
let port = match scheme {
"http" => url.port().unwrap_or(80),
"https" => url.port().unwrap_or(443),
_ => {
return Err(PluginError::invalid_input(
"sandbox_agent mount baseUrl must use http or https",
));
}
};
if host_for_address.eq_ignore_ascii_case("localhost") {
return Ok(normalized);
}
match host_for_address.parse::<IpAddr>() {
Ok(ip) => {
if ip.is_loopback() {
return Ok(normalized);
}
if is_disallowed_sandbox_agent_base_url_ip(ip) {
return Err(PluginError::invalid_input(format!(
"sandbox_agent mount baseUrl must not target a private or local/non-global IP address ({host})"
)));
}
if scheme != "https" {
return Err(PluginError::invalid_input(
"sandbox_agent mount non-local baseUrl must use https",
));
}
}
Err(_) => {
if scheme != "https" {
return Err(PluginError::invalid_input(
"sandbox_agent mount hostname baseUrl must use https unless it targets localhost",
));
}
let addresses = resolve_host(host_for_address, port).map_err(|error| {
PluginError::invalid_input(format!(
"could not resolve sandbox_agent mount baseUrl host '{host}': {error}"
))
})?;
if addresses.is_empty() {
return Err(PluginError::invalid_input(format!(
"could not resolve sandbox_agent mount baseUrl host '{host}'"
)));
}
for address in addresses {
if is_disallowed_sandbox_agent_base_url_ip(address.ip()) {
return Err(PluginError::invalid_input(format!(
"sandbox_agent mount baseUrl host '{host}' resolved to a private or local/non-global IP address ({})",
address.ip()
)));
}
}
}
}
Ok(normalized)
}
fn resolve_sandbox_agent_base_url_host(host: &str, port: u16) -> std::io::Result<Vec<SocketAddr>> {
(host, port)
.to_socket_addrs()
.map(|addresses| addresses.collect())
}
fn is_disallowed_sandbox_agent_base_url_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
let [first, second, third, fourth] = ip.octets();
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_unspecified()
|| first == 0
|| (first == 100 && (second & 0b1100_0000) == 64)
|| (first == 192
&& second == 0
&& third == 0
&& (fourth <= 8 || fourth == 170 || fourth == 171))
|| (first == 192 && second == 0 && third == 2)
|| (first == 192 && second == 88 && third == 99 && fourth == 2)
|| (first == 198 && (second == 18 || second == 19))
|| (first == 198 && second == 51 && third == 100)
|| (first == 203 && second == 0 && third == 113)
|| first >= 240
|| (first == 255 && second == 255 && third == 255 && fourth == 255)
}
IpAddr::V6(ip) => {
if let Some(mapped) = ip.to_ipv4_mapped() {
return is_disallowed_sandbox_agent_base_url_ip(IpAddr::V4(mapped));
}
let segments = ip.segments();
ip.is_loopback()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.is_multicast()
|| ip.is_unspecified()
|| (segments[0] & 0xffc0) == 0xfec0
|| (segments[0..6] == [0, 0, 0, 0, 0, 0])
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 0x0001)
|| (segments[0] == 0x0100
&& segments[1] == 0
&& segments[2] == 0
&& (segments[3] == 0 || segments[3] == 1))
|| (segments[0] == 0x2001 && segments[1] == 0)
|| (segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0)
|| (segments[0] == 0x2001 && (segments[1] & 0xfff0) == 0x0010)
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|| (segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
|| segments[0] == 0x5f00
|| segments[0] == 0x2002
}
}
}
fn sandbox_client_error_to_vfs(
op: &'static str,
path: &str,
error: SandboxAgentClientError,
) -> VfsError {
match error {
SandboxAgentClientError::Status { status, problem } => {
let status = problem.status.unwrap_or(status);
let detail = problem
.detail
.or(problem.title)
.unwrap_or_else(|| format!("sandbox-agent request failed with status {status}"));
let code = if status == 401 || status == 403 {
"EACCES"
} else if status == 404 || detail.contains("path not found") {
"ENOENT"
} else if detail.contains("path is not a file") {
"EISDIR"
} else if detail.contains("destination already exists") || status == 409 {
"EEXIST"
} else if status == 400 {
"EINVAL"
} else {
"EIO"
};
VfsError::new(code, format!("{op} '{path}': {detail}"))
}
SandboxAgentClientError::Transport(message) | SandboxAgentClientError::Decode(message) => {
VfsError::io(format!("{op} '{path}': {message}"))
}
}
}
fn is_missing_path_error(error: &SandboxAgentClientError) -> bool {
match error {
SandboxAgentClientError::Status { status, problem } => {
let detail = problem
.detail
.as_deref()
.or(problem.title.as_deref())
.unwrap_or_default();
*status == 404 || detail.contains("path not found")
}
SandboxAgentClientError::Transport(_) | SandboxAgentClientError::Decode(_) => false,
}
}
fn parse_process_json_output(stdout: &str, op: &'static str, path: &str) -> VfsResult<String> {
let trimmed = stdout.trim();
let output: FsScriptJsonOutput = serde_json::from_str(trimmed).map_err(|error| {
VfsError::io(format!(
"{op} '{path}': failed to decode process helper output: {error}"
))
})?;
Ok(output.result.unwrap_or_default())
}
fn process_response_to_vfs(
op: &'static str,
path: &str,
response: SandboxAgentProcessRunResponse,
) -> VfsError {
let trimmed_stderr = response.stderr.trim();
if let Ok(error) = serde_json::from_str::<FsScriptJsonError>(trimmed_stderr) {
let message = error
.message
.unwrap_or_else(|| String::from("remote filesystem helper failed"));
if let Some(errno) = error.errno {
return VfsError::new(
errno_to_vfs_code(errno),
format!("{op} '{path}': {message}"),
);
}
return VfsError::io(format!("{op} '{path}': {message}"));
}
let detail = if trimmed_stderr.is_empty() {
format!(
"remote process exited with code {}",
response
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| String::from("unknown"))
)
} else {
trimmed_stderr.to_owned()
};
VfsError::io(format!("{op} '{path}': {detail}"))
}
fn errno_to_vfs_code(errno: i32) -> &'static str {
match errno {
nix::libc::EACCES => "EACCES",
nix::libc::EEXIST => "EEXIST",
nix::libc::EINVAL => "EINVAL",
nix::libc::EISDIR => "EISDIR",
nix::libc::ELOOP => "ELOOP",
nix::libc::ENOENT => "ENOENT",
nix::libc::ENOSYS => "ENOSYS",
nix::libc::ENOTDIR => "ENOTDIR",
nix::libc::ENOTEMPTY => "ENOTEMPTY",
nix::libc::EPERM => "EPERM",
nix::libc::EXDEV => "EXDEV",
_ => "EIO",
}
}
fn dirname(path: &str) -> String {
let normalized = normalize_path(path);
match normalized.rsplit_once('/') {
Some((head, _)) if !head.is_empty() => head.to_owned(),
_ => String::from("/"),
}
}
fn normalize_sandbox_agent_base_path(raw: Option<&str>) -> String {
match raw {
None | Some("") | Some("/") => String::from("/"),
Some(path) if path.starts_with('/') => normalize_path(path),
Some(path) => {
let normalized = normalize_path(&format!("/{path}"));
let relative = normalized.trim_start_matches('/');
if relative.is_empty() {
String::from("/")
} else {
relative.to_owned()
}
}
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
const PYTHON_REALPATH_SCRIPT: &str = r#"import json, os, sys
path = sys.argv[1]
try:
resolved = os.path.realpath(path)
os.stat(resolved)
print(json.dumps({"result": resolved}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_REALPATH_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
const resolved = await fs.realpath(process.argv[1]);
console.log(JSON.stringify({ result: resolved }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_SYMLINK_SCRIPT: &str = r#"import json, os, sys
target, link_path = sys.argv[1], sys.argv[2]
try:
os.symlink(target, link_path)
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_SYMLINK_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.symlink(process.argv[1], process.argv[2]);
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_READLINK_SCRIPT: &str = r#"import json, os, sys
path = sys.argv[1]
try:
print(json.dumps({"result": os.readlink(path)}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_READLINK_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
const target = await fs.readlink(process.argv[1]);
console.log(JSON.stringify({ result: target }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_LINK_SCRIPT: &str = r#"import json, os, sys
source, destination = sys.argv[1], sys.argv[2]
try:
os.link(source, destination)
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_LINK_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.link(process.argv[1], process.argv[2]);
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_CHMOD_SCRIPT: &str = r#"import json, os, sys
path, mode = sys.argv[1], int(sys.argv[2])
try:
os.chmod(path, mode)
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_CHMOD_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.chmod(process.argv[1], Number(process.argv[2]));
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_CHOWN_SCRIPT: &str = r#"import json, os, sys
path, uid, gid = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
try:
os.chown(path, uid, gid)
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_CHOWN_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.chown(process.argv[1], Number(process.argv[2]), Number(process.argv[3]));
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_UTIMES_SCRIPT: &str = r#"import json, os, sys
path, atime_ms, mtime_ms = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
try:
os.utime(path, ns=(atime_ms * 1_000_000, mtime_ms * 1_000_000))
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_UTIMES_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.utimes(process.argv[1], Number(process.argv[2]) / 1000, Number(process.argv[3]) / 1000);
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
const PYTHON_TRUNCATE_SCRIPT: &str = r#"import json, os, sys
path, length = sys.argv[1], int(sys.argv[2])
try:
os.truncate(path, length)
print(json.dumps({"result": None}))
except Exception as exc:
payload = {"message": str(exc)}
if isinstance(exc, OSError):
payload["errno"] = exc.errno
print(json.dumps(payload), file=sys.stderr)
sys.exit(1)
"#;
const NODE_TRUNCATE_SCRIPT: &str = r#"const fs = require("node:fs/promises");
(async () => {
try {
await fs.truncate(process.argv[1], Number(process.argv[2]));
console.log(JSON.stringify({ result: null }));
} catch (error) {
console.error(JSON.stringify({ errno: typeof error?.errno === "number" ? Math.abs(error.errno) : undefined, message: error?.message ?? String(error) }));
process.exit(1);
}
})();"#;
#[cfg(test)]
pub(crate) mod test_support {
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub(crate) struct LoggedRequest {
pub method: String,
pub path: String,
pub query: BTreeMap<String, String>,
pub headers: BTreeMap<String, String>,
pub response_status: u16,
pub response_body_bytes: usize,
}
pub(crate) struct MockSandboxAgentServer {
base_url: String,
root: PathBuf,
shutdown: Arc<AtomicBool>,
requests: Arc<Mutex<Vec<LoggedRequest>>>,
handle: Option<JoinHandle<()>>,
}
impl MockSandboxAgentServer {
pub(crate) fn start(prefix: &str, token: Option<&str>) -> Self {
Self::start_with_options(prefix, token, true, true)
}
pub(crate) fn start_without_process_api(prefix: &str, token: Option<&str>) -> Self {
Self::start_with_options(prefix, token, false, true)
}
pub(crate) fn start_without_range_support(prefix: &str, token: Option<&str>) -> Self {
Self::start_with_options(prefix, token, true, false)
}
fn start_with_options(
prefix: &str,
token: Option<&str>,
process_api_supported: bool,
range_requests_supported: bool,
) -> Self {
let root = temp_dir(prefix);
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock sandbox-agent");
listener
.set_nonblocking(true)
.expect("configure mock sandbox-agent listener");
let address = listener
.local_addr()
.expect("resolve mock sandbox-agent address");
let shutdown = Arc::new(AtomicBool::new(false));
let requests = Arc::new(Mutex::new(Vec::new()));
let token = token.map(str::to_owned);
let root_for_thread = root.clone();
let shutdown_for_thread = Arc::clone(&shutdown);
let requests_for_thread = Arc::clone(&requests);
let handle = thread::spawn(move || {
while !shutdown_for_thread.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
handle_stream(
stream,
&root_for_thread,
token.as_deref(),
process_api_supported,
range_requests_supported,
&requests_for_thread,
);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
});
Self {
base_url: format!("http://{}", address),
root,
shutdown,
requests,
handle: Some(handle),
}
}
pub(crate) fn base_url(&self) -> &str {
&self.base_url
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn requests(&self) -> Vec<LoggedRequest> {
self.requests
.lock()
.expect("lock mock sandbox-agent request log")
.clone()
}
}
impl Drop for MockSandboxAgentServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
handle.join().expect("join mock sandbox-agent thread");
}
let _ = fs::remove_dir_all(&self.root);
}
}
#[derive(Debug, Deserialize)]
struct MoveRequest {
from: String,
to: String,
overwrite: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProcessRunRequestBody {
command: String,
args: Option<Vec<String>>,
cwd: Option<String>,
env: Option<BTreeMap<String, String>>,
#[serde(rename = "maxOutputBytes")]
_max_output_bytes: Option<u64>,
#[serde(rename = "timeoutMs")]
_timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProcessRunResponseBody {
duration_ms: u64,
exit_code: Option<i32>,
stderr: String,
stderr_truncated: bool,
stdout: String,
stdout_truncated: bool,
timed_out: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FsEntryBody {
name: String,
path: String,
entry_type: &'static str,
size: u64,
modified: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FsStatBody {
path: String,
entry_type: &'static str,
size: u64,
modified: Option<String>,
}
fn handle_stream(
mut stream: TcpStream,
root: &Path,
token: Option<&str>,
process_api_supported: bool,
range_requests_supported: bool,
requests: &Arc<Mutex<Vec<LoggedRequest>>>,
) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set mock sandbox-agent read timeout");
let mut buffer = Vec::new();
let mut header_end = None;
while header_end.is_none() {
let mut chunk = [0; 1024];
match stream.read(&mut chunk) {
Ok(0) => return,
Ok(read) => {
buffer.extend_from_slice(&chunk[..read]);
header_end = find_header_end(&buffer);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(_) => return,
}
}
let header_end = header_end.expect("parse mock sandbox-agent headers");
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.split("\r\n");
let request_line = match lines.next() {
Some(line) if !line.is_empty() => line,
_ => return,
};
let mut request_line_parts = request_line.split_whitespace();
let method = request_line_parts.next().unwrap_or_default().to_owned();
let target = request_line_parts.next().unwrap_or_default().to_owned();
let (path, query) = split_target(&target);
let mut headers = BTreeMap::new();
for line in lines {
if line.is_empty() {
continue;
}
let Some((name, value)) = line.split_once(':') else {
continue;
};
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned());
}
let content_length = headers
.get("content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
while buffer.len() < header_end + 4 + content_length {
let mut chunk = [0; 1024];
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(_) => break,
}
}
let body = &buffer[header_end + 4..header_end + 4 + content_length];
let request_index = {
let mut logged_requests = requests.lock().expect("record mock sandbox-agent request");
logged_requests.push(LoggedRequest {
method: method.clone(),
path: path.clone(),
query: query.clone(),
headers: headers.clone(),
response_status: 0,
response_body_bytes: 0,
});
logged_requests.len() - 1
};
if let Some(expected_token) = token {
let authorization = headers
.get("authorization")
.map(String::as_str)
.unwrap_or_default();
if authorization != format!("Bearer {expected_token}") {
let outcome =
send_problem(&mut stream, 401, "Unauthorized", "authentication required");
update_logged_request(requests, request_index, outcome);
return;
}
}
let outcome = match (method.as_str(), path.as_str()) {
("GET", "/v1/fs/entries") => {
let path = query
.get("path")
.cloned()
.unwrap_or_else(|| String::from("."));
let target = resolve_fs_path(root, &path);
match fs::read_dir(&target) {
Ok(entries) => {
let mut payload = entries
.filter_map(Result::ok)
.map(|entry| {
let metadata = entry.metadata().expect("read mock entry metadata");
FsEntryBody {
name: entry.file_name().to_string_lossy().into_owned(),
path: entry.path().to_string_lossy().into_owned(),
entry_type: if metadata.is_dir() {
"directory"
} else {
"file"
},
size: metadata.len(),
modified: None,
}
})
.collect::<Vec<_>>();
payload.sort_by(|left, right| left.name.cmp(&right.name));
send_json(&mut stream, 200, &payload)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path not found: {}", target.display()),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("GET", "/v1/fs/file") => {
let path = query.get("path").cloned().unwrap_or_default();
let target = resolve_fs_path(root, &path);
if path == "/redirect-to-private" {
return_with_logged_request(
requests,
request_index,
send_redirect(&mut stream, "http://169.254.169.254/latest"),
);
return;
}
match fs::metadata(&target) {
Ok(metadata) if metadata.is_file() => match fs::read(&target) {
Ok(bytes) => {
if path == "/stream-over-limit" && !range_requests_supported {
return_with_logged_request(
requests,
request_index,
send_bytes_without_content_length(
&mut stream,
200,
"application/octet-stream",
&bytes,
),
);
return;
}
if range_requests_supported {
if let Some(range) = headers
.get("range")
.and_then(|value| parse_range_header(value, bytes.len()))
{
let body = &bytes[range.start..=range.end];
return_with_logged_request(
requests,
request_index,
send_bytes_with_headers(
&mut stream,
206,
"application/octet-stream",
body,
&[
("Accept-Ranges", String::from("bytes")),
(
"Content-Range",
format!(
"bytes {}-{}/{}",
range.start,
range.end,
bytes.len()
),
),
],
),
);
return;
}
}
send_bytes(&mut stream, 200, "application/octet-stream", &bytes)
}
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
},
Ok(_) => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path is not a file: {}", target.display()),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path not found: {}", target.display()),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("PUT", "/v1/fs/file") => {
let path = query.get("path").cloned().unwrap_or_default();
let target = resolve_fs_path(root, &path);
if let Some(parent) = target.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(&target, body) {
Ok(()) => send_json(
&mut stream,
200,
&serde_json::json!({
"path": target.to_string_lossy(),
"bytesWritten": body.len(),
}),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("DELETE", "/v1/fs/entry") => {
let path = query.get("path").cloned().unwrap_or_default();
let recursive = query
.get("recursive")
.map(|value| value == "true")
.unwrap_or(false);
let target = resolve_fs_path(root, &path);
match fs::metadata(&target) {
Ok(metadata) if metadata.is_dir() => {
let result = if recursive {
fs::remove_dir_all(&target)
} else {
fs::remove_dir(&target)
};
match result {
Ok(()) => send_json(
&mut stream,
200,
&serde_json::json!({ "path": target.to_string_lossy() }),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
Ok(_) => match fs::remove_file(&target) {
Ok(()) => send_json(
&mut stream,
200,
&serde_json::json!({ "path": target.to_string_lossy() }),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path not found: {}", target.display()),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("POST", "/v1/fs/mkdir") => {
let path = query.get("path").cloned().unwrap_or_default();
let target = resolve_fs_path(root, &path);
match fs::create_dir_all(&target) {
Ok(()) => send_json(
&mut stream,
200,
&serde_json::json!({ "path": target.to_string_lossy() }),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("POST", "/v1/fs/move") => {
let request: MoveRequest =
serde_json::from_slice(body).expect("parse mock move request");
let source = resolve_fs_path(root, &request.from);
let destination = resolve_fs_path(root, &request.to);
if destination.exists() {
if request.overwrite.unwrap_or(false) {
let metadata =
fs::metadata(&destination).expect("inspect mock destination metadata");
let remove_result = if metadata.is_dir() {
fs::remove_dir_all(&destination)
} else {
fs::remove_file(&destination)
};
if let Err(error) = remove_result {
send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
);
return;
}
} else {
send_problem(
&mut stream,
400,
"Bad Request",
&format!("destination already exists: {}", destination.display()),
);
return;
}
}
if let Some(parent) = destination.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::rename(&source, &destination) {
Ok(()) => send_json(
&mut stream,
200,
&serde_json::json!({
"from": source.to_string_lossy(),
"to": destination.to_string_lossy(),
}),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path not found: {}", source.display()),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("GET", "/v1/fs/stat") => {
let path = query.get("path").cloned().unwrap_or_default();
let target = resolve_fs_path(root, &path);
match fs::metadata(&target) {
Ok(metadata) => send_json(
&mut stream,
200,
&FsStatBody {
path: target.to_string_lossy().into_owned(),
entry_type: if metadata.is_dir() {
"directory"
} else {
"file"
},
size: metadata.len(),
modified: None,
},
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_problem(
&mut stream,
400,
"Bad Request",
&format!("path not found: {}", target.display()),
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
("POST", "/v1/processes/run") => {
if !process_api_supported {
let outcome = send_problem(
&mut stream,
501,
"Not Implemented",
"process API unsupported by mock sandbox-agent",
);
update_logged_request(requests, request_index, outcome);
return;
}
let request: ProcessRunRequestBody =
serde_json::from_slice(body).expect("parse mock process run request");
let started = Instant::now();
let mut command = Command::new(&request.command);
command.args(rewrite_process_args(root, request.args.unwrap_or_default()));
if let Some(cwd) = request.cwd {
if cwd.starts_with('/') {
command.current_dir(resolve_fs_path(root, &cwd));
} else {
command.current_dir(cwd);
}
}
if let Some(env) = request.env {
command.envs(env);
}
match command.output() {
Ok(output) => send_json(
&mut stream,
200,
&ProcessRunResponseBody {
duration_ms: started.elapsed().as_millis() as u64,
exit_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
stderr_truncated: false,
stdout: sanitize_process_stdout(
root,
String::from_utf8_lossy(&output.stdout).into_owned(),
),
stdout_truncated: false,
timed_out: false,
},
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send_json(
&mut stream,
200,
&ProcessRunResponseBody {
duration_ms: started.elapsed().as_millis() as u64,
exit_code: Some(127),
stderr: error.to_string(),
stderr_truncated: false,
stdout: String::new(),
stdout_truncated: false,
timed_out: false,
},
),
Err(error) => send_problem(
&mut stream,
500,
"Internal Server Error",
&error.to_string(),
),
}
}
_ => send_problem(&mut stream, 404, "Not Found", "unknown mock route"),
};
update_logged_request(requests, request_index, outcome);
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn split_target(target: &str) -> (String, BTreeMap<String, String>) {
let Some((path, query)) = target.split_once('?') else {
return (target.to_owned(), BTreeMap::new());
};
let query = query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| match pair.split_once('=') {
Some((name, value)) => (percent_decode(name), percent_decode(value)),
None => (percent_decode(pair), String::new()),
})
.collect::<BTreeMap<_, _>>();
(path.to_owned(), query)
}
fn percent_decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut index = 0;
let mut decoded = Vec::with_capacity(bytes.len());
while index < bytes.len() {
match bytes[index] {
b'+' => {
decoded.push(b' ');
index += 1;
}
b'%' if index + 2 < bytes.len() => {
if let Ok(value) = u8::from_str_radix(&raw[index + 1..index + 3], 16) {
decoded.push(value);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8(decoded).expect("decode mock sandbox-agent query")
}
fn resolve_fs_path(root: &Path, path: &str) -> PathBuf {
let normalized = secure_exec_kernel::vfs::normalize_path(path);
root.join(normalized.trim_start_matches('/'))
}
fn rewrite_process_args(root: &Path, args: Vec<String>) -> Vec<String> {
args.into_iter()
.map(|arg| {
if arg.starts_with('/') {
resolve_fs_path(root, &arg).to_string_lossy().into_owned()
} else {
arg
}
})
.collect()
}
fn sanitize_process_stdout(root: &Path, stdout: String) -> String {
let trimmed = stdout.trim();
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return stdout;
};
if let Some(result) = value
.get("result")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
{
let root_string = root.to_string_lossy();
if result == root_string {
value["result"] = serde_json::Value::String(String::from("/"));
} else if let Some(stripped) = result.strip_prefix(root_string.as_ref()) {
value["result"] =
serde_json::Value::String(format!("/{}", stripped.trim_start_matches('/')));
}
}
serde_json::to_string(&value).expect("serialize sanitized process stdout")
}
#[derive(Clone, Copy)]
struct ResponseOutcome {
status: u16,
body_bytes: usize,
}
#[derive(Clone, Copy)]
struct ByteRange {
start: usize,
end: usize,
}
fn parse_range_header(raw: &str, content_len: usize) -> Option<ByteRange> {
let spec = raw.strip_prefix("bytes=")?;
let (start_raw, end_raw) = spec.split_once('-')?;
if start_raw.is_empty() {
return None;
}
let start = start_raw.parse::<usize>().ok()?;
if start >= content_len {
return None;
}
let end = if end_raw.is_empty() {
content_len.saturating_sub(1)
} else {
end_raw
.parse::<usize>()
.ok()?
.min(content_len.saturating_sub(1))
};
if end < start {
return None;
}
Some(ByteRange { start, end })
}
fn update_logged_request(
requests: &Arc<Mutex<Vec<LoggedRequest>>>,
request_index: usize,
outcome: ResponseOutcome,
) {
if let Some(request) = requests
.lock()
.expect("lock mock sandbox-agent request log")
.get_mut(request_index)
{
request.response_status = outcome.status;
request.response_body_bytes = outcome.body_bytes;
}
}
fn return_with_logged_request(
requests: &Arc<Mutex<Vec<LoggedRequest>>>,
request_index: usize,
outcome: ResponseOutcome,
) {
update_logged_request(requests, request_index, outcome);
}
fn send_json(stream: &mut TcpStream, status: u16, value: &impl Serialize) -> ResponseOutcome {
let body = serde_json::to_vec(value).expect("serialize mock sandbox-agent response");
send_bytes(stream, status, "application/json", &body)
}
fn send_problem(
stream: &mut TcpStream,
status: u16,
title: &str,
detail: &str,
) -> ResponseOutcome {
send_json(
stream,
status,
&serde_json::json!({
"type": "about:blank",
"title": title,
"status": status,
"detail": detail,
}),
)
}
fn send_bytes(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
) -> ResponseOutcome {
send_bytes_with_headers(stream, status, content_type, body, &[])
}
fn send_redirect(stream: &mut TcpStream, location: &str) -> ResponseOutcome {
send_bytes_with_headers(
stream,
302,
"text/plain",
b"",
&[("Location", location.to_owned())],
)
}
fn send_bytes_without_content_length(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
) -> ResponseOutcome {
let status_text = status_text(status);
let headers = format!(
"HTTP/1.1 {status} {status_text}\r\nContent-Type: {content_type}\r\nConnection: close\r\n\r\n"
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
ResponseOutcome {
status,
body_bytes: body.len(),
}
}
fn send_bytes_with_headers(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
extra_headers: &[(&str, String)],
) -> ResponseOutcome {
let status_text = status_text(status);
let mut headers = format!(
"HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\nConnection: close\r\n",
body.len()
);
for (name, value) in extra_headers {
headers.push_str(name);
headers.push_str(": ");
headers.push_str(value);
headers.push_str("\r\n");
}
headers.push_str("\r\n");
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
ResponseOutcome {
status,
body_bytes: body.len(),
}
}
fn status_text(status: u16) -> &'static str {
match status {
200 => "OK",
206 => "Partial Content",
302 => "Found",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
501 => "Not Implemented",
_ => "Internal Server Error",
}
}
fn temp_dir(prefix: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic enough for temp paths")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
}