docs: add mcp and skill session config (#106)

This commit is contained in:
NathanFlurry 2026-02-09 10:13:25 +00:00
parent d236edf35c
commit 4c8d93e077
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
95 changed files with 10014 additions and 1342 deletions

View file

@ -2,6 +2,10 @@
See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed architecture documentation covering the daemon, agent schema pipeline, session management, agent execution patterns, and SDK modes.
## Skill Source Installation
Skills are installed via `skills.sources` in the session create request. The [vercel-labs/skills](https://github.com/vercel-labs/skills) repo (`~/misc/skills`) provides reference for skill installation patterns and source parsing logic. The server handles fetching GitHub repos (via zip download) and git repos (via clone) to `~/.sandbox-agent/skills-cache/`, discovering `SKILL.md` files, and symlinking into agent skill roots.
# Server Testing
## Test placement

View file

@ -743,7 +743,13 @@ fn parse_version_output(output: &std::process::Output) -> Option<String> {
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| line.to_string())
.map(|line| {
// Strip trailing metadata like " (released ...)" from version strings
match line.find(" (") {
Some(pos) => line[..pos].to_string(),
None => line.to_string(),
}
})
}
fn parse_jsonl(text: &str) -> Vec<Value> {

View file

@ -36,6 +36,9 @@ tracing-logfmt.workspace = true
tracing-subscriber.workspace = true
include_dir.workspace = true
base64.workspace = true
toml_edit.workspace = true
tar.workspace = true
zip.workspace = true
tempfile = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command as ProcessCommand, Stdio};
@ -13,12 +14,14 @@ mod build_version {
}
use crate::router::{build_router_with_state, shutdown_servers};
use crate::router::{
AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, MessageRequest,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, McpServerConfig,
MessageRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest, SkillSource,
SkillsConfig,
};
use crate::router::{
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse,
EventsResponse, SessionListResponse,
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
FsActionResponse, FsEntry, FsMoveRequest, FsMoveResponse, FsStat, FsUploadBatchResponse,
FsWriteResponse, SessionListResponse,
};
use crate::server_logs::ServerLogs;
use crate::telemetry;
@ -176,6 +179,10 @@ pub struct DaemonStartArgs {
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
port: u16,
/// If the daemon is already running but outdated, stop and restart it.
#[arg(long, default_value_t = false)]
upgrade: bool,
}
#[derive(Args, Debug)]
@ -202,6 +209,8 @@ pub enum ApiCommand {
Agents(AgentsArgs),
/// Create sessions and interact with session events.
Sessions(SessionsArgs),
/// Manage filesystem entries.
Fs(FsArgs),
}
#[derive(Subcommand, Debug)]
@ -225,6 +234,12 @@ pub struct SessionsArgs {
command: SessionsCommand,
}
#[derive(Args, Debug)]
pub struct FsArgs {
#[command(subcommand)]
command: FsCommand,
}
#[derive(Subcommand, Debug)]
pub enum AgentsCommand {
/// List all agents and install status.
@ -272,6 +287,27 @@ pub enum SessionsCommand {
ReplyPermission(PermissionReplyArgs),
}
#[derive(Subcommand, Debug)]
pub enum FsCommand {
/// List directory entries.
Entries(FsEntriesArgs),
/// Read a file.
Read(FsReadArgs),
/// Write a file.
Write(FsWriteArgs),
/// Delete a file or directory.
Delete(FsDeleteArgs),
/// Create a directory.
Mkdir(FsMkdirArgs),
/// Move a file or directory.
Move(FsMoveArgs),
/// Stat a file or directory.
Stat(FsStatArgs),
/// Upload a tar archive and extract it.
#[command(name = "upload-batch")]
UploadBatch(FsUploadBatchArgs),
}
#[derive(Args, Debug, Clone)]
pub struct ClientArgs {
#[arg(long, short = 'e')]
@ -323,6 +359,10 @@ pub struct CreateSessionArgs {
variant: Option<String>,
#[arg(long, short = 'A')]
agent_version: Option<String>,
#[arg(long)]
mcp_config: Option<PathBuf>,
#[arg(long)]
skill: Vec<PathBuf>,
#[command(flatten)]
client: ClientArgs,
}
@ -406,6 +446,91 @@ pub struct PermissionReplyArgs {
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsEntriesArgs {
#[arg(long)]
path: Option<String>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsReadArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsWriteArgs {
path: String,
#[arg(long)]
content: Option<String>,
#[arg(long = "from-file")]
from_file: Option<PathBuf>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsDeleteArgs {
path: String,
#[arg(long)]
recursive: bool,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsMkdirArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsMoveArgs {
from: String,
to: String,
#[arg(long)]
overwrite: bool,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsStatArgs {
path: String,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct FsUploadBatchArgs {
#[arg(long = "tar")]
tar_path: PathBuf,
#[arg(long)]
path: Option<String>,
#[arg(long)]
session_id: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct CredentialsExtractArgs {
#[arg(long, short = 'a', value_enum)]
@ -433,6 +558,8 @@ pub struct CredentialsExtractEnvArgs {
#[derive(Debug, Error)]
pub enum CliError {
#[error("missing --token or --no-token for server mode")]
MissingToken,
#[error("invalid cors origin: {0}")]
InvalidCorsOrigin(String),
#[error("invalid cors method: {0}")]
@ -590,6 +717,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> {
match command {
ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli),
ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli),
ApiCommand::Fs(subcommand) => run_fs(&subcommand.command, cli),
}
}
@ -672,6 +800,9 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> {
let token = cli.token.as_deref();
match command {
DaemonCommand::Start(args) if args.upgrade => {
crate::daemon::ensure_running(cli, &args.host, args.port, token)
}
DaemonCommand::Start(args) => crate::daemon::start(cli, &args.host, args.port, token),
DaemonCommand::Stop(args) => crate::daemon::stop(&args.host, args.port),
DaemonCommand::Status(args) => {
@ -722,6 +853,33 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
}
SessionsCommand::Create(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let mcp = if let Some(path) = &args.mcp_config {
let text = std::fs::read_to_string(path)?;
let parsed =
serde_json::from_str::<std::collections::BTreeMap<String, McpServerConfig>>(
&text,
)?;
Some(parsed)
} else {
None
};
let skills = if args.skill.is_empty() {
None
} else {
Some(SkillsConfig {
sources: args
.skill
.iter()
.map(|path| SkillSource {
source_type: "local".to_string(),
source: path.to_string_lossy().to_string(),
skills: None,
git_ref: None,
subpath: None,
})
.collect(),
})
};
let body = CreateSessionRequest {
agent: args.agent.clone(),
agent_mode: args.agent_mode.clone(),
@ -731,6 +889,8 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
agent_version: args.agent_version.clone(),
directory: None,
title: None,
mcp,
skills,
};
let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
let response = ctx.post(&path, &body)?;
@ -740,6 +900,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
let ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest {
message: args.message.clone(),
attachments: Vec::new(),
};
let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id);
let response = ctx.post(&path, &body)?;
@ -749,6 +910,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
let ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest {
message: args.message.clone(),
attachments: Vec::new(),
};
let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id);
let response = ctx.post_with_query(
@ -845,6 +1007,129 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
}
}
fn run_fs(command: &FsCommand, cli: &CliConfig) -> Result<(), CliError> {
match command {
FsCommand::Entries(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/entries"),
&[
("path", args.path.clone()),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<Vec<FsEntry>>(response)
}
FsCommand::Read(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/file"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_binary_response(response)
}
FsCommand::Write(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = match (&args.content, &args.from_file) {
(Some(_), Some(_)) => {
return Err(CliError::Server(
"use --content or --from-file, not both".to_string(),
))
}
(None, None) => {
return Err(CliError::Server(
"write requires --content or --from-file".to_string(),
))
}
(Some(content), None) => content.clone().into_bytes(),
(None, Some(path)) => std::fs::read(path)?,
};
let response = ctx.put_raw_with_query(
&format!("{API_PREFIX}/fs/file"),
body,
"application/octet-stream",
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsWriteResponse>(response)
}
FsCommand::Delete(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.delete_with_query(
&format!("{API_PREFIX}/fs/entry"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
(
"recursive",
if args.recursive {
Some("true".to_string())
} else {
None
},
),
],
)?;
print_json_response::<FsActionResponse>(response)
}
FsCommand::Mkdir(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.post_empty_with_query(
&format!("{API_PREFIX}/fs/mkdir"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsActionResponse>(response)
}
FsCommand::Move(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = FsMoveRequest {
from: args.from.clone(),
to: args.to.clone(),
overwrite: if args.overwrite { Some(true) } else { None },
};
let response = ctx.post_with_query(
&format!("{API_PREFIX}/fs/move"),
&body,
&[("session_id", args.session_id.clone())],
)?;
print_json_response::<FsMoveResponse>(response)
}
FsCommand::Stat(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.get_with_query(
&format!("{API_PREFIX}/fs/stat"),
&[
("path", Some(args.path.clone())),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsStat>(response)
}
FsCommand::UploadBatch(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let file = File::open(&args.tar_path)?;
let response = ctx.post_raw_with_query(
&format!("{API_PREFIX}/fs/upload-batch"),
file,
"application/x-tar",
&[
("path", args.path.clone()),
("session_id", args.session_id.clone()),
],
)?;
print_json_response::<FsUploadBatchResponse>(response)
}
}
}
fn create_opencode_session(
base_url: &str,
token: Option<&str>,
@ -1275,9 +1560,75 @@ impl ClientContext {
Ok(request.send()?)
}
fn put_raw_with_query<B: Into<reqwest::blocking::Body>>(
&self,
path: &str,
body: B,
content_type: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self
.request(Method::PUT, path)
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::ACCEPT, "application/json");
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.body(body).send()?)
}
fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::POST, path).send()?)
}
fn post_empty_with_query(
&self,
path: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::POST, path);
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.send()?)
}
fn delete_with_query(
&self,
path: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::DELETE, path);
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.send()?)
}
fn post_raw_with_query<B: Into<reqwest::blocking::Body>>(
&self,
path: &str,
body: B,
content_type: &str,
query: &[(&str, Option<String>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self
.request(Method::POST, path)
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::ACCEPT, "application/json");
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.body(body).send()?)
}
}
fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
@ -1310,6 +1661,25 @@ fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliE
Ok(())
}
fn print_binary_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
let bytes = response.bytes()?;
if !status.is_success() {
if let Ok(text) = std::str::from_utf8(&bytes) {
print_error_body(text)?;
} else {
write_stderr_line("Request failed with non-text response body")?;
}
return Err(CliError::HttpStatus(status));
}
let mut out = std::io::stdout();
out.write_all(&bytes)?;
out.flush()?;
Ok(())
}
fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
if status.is_success() {

View file

@ -1,5 +1,7 @@
use sandbox_agent::cli::run_sandbox_agent;
fn main() {
if let Err(err) = sandbox_agent::cli::run_sandbox_agent() {
if let Err(err) = run_sandbox_agent() {
tracing::error!(error = %err, "sandbox-agent failed");
std::process::exit(1);
}

View file

@ -524,6 +524,8 @@ async fn ensure_backing_session(
agent_version: None,
directory,
title,
mcp: None,
skills: None,
};
let manager = state.inner.session_manager();
match manager
@ -4264,7 +4266,7 @@ async fn oc_session_message_create(
if let Err(err) = state
.inner
.session_manager()
.send_message(session_id.clone(), prompt_text)
.send_message(session_id.clone(), prompt_text, Vec::new())
.await
{
let mut should_emit_idle = false;

File diff suppressed because it is too large Load diff

View file

@ -186,3 +186,130 @@ async fn agent_endpoints_snapshots() {
});
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_sources() {
let app = TestApp::new();
// Create a temp skill directory with SKILL.md
let skill_dir = tempfile::tempdir().expect("create skill dir");
let skill_path = skill_dir.path().join("my-test-skill");
std::fs::create_dir_all(&skill_path).expect("create skill subdir");
std::fs::write(skill_path.join("SKILL.md"), "# Test Skill\nA test skill.").expect("write SKILL.md");
// Create session with local skill source
let (status, payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-test-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy()
}
]
}
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session with skills: {payload}");
assert!(
payload.get("healthy").and_then(Value::as_bool).unwrap_or(false),
"session should be healthy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_sources_filter() {
let app = TestApp::new();
// Create a temp directory with two skills
let skill_dir = tempfile::tempdir().expect("create skill dir");
let wanted = skill_dir.path().join("wanted-skill");
let unwanted = skill_dir.path().join("unwanted-skill");
std::fs::create_dir_all(&wanted).expect("create wanted dir");
std::fs::create_dir_all(&unwanted).expect("create unwanted dir");
std::fs::write(wanted.join("SKILL.md"), "# Wanted").expect("write wanted SKILL.md");
std::fs::write(unwanted.join("SKILL.md"), "# Unwanted").expect("write unwanted SKILL.md");
// Create session with filter
let (status, payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-filter-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy(),
"skills": ["wanted-skill"]
}
]
}
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session with skill filter: {payload}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_invalid_skill_source() {
let app = TestApp::new();
// Use a non-existent path
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-invalid-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": "/nonexistent/path/to/skills"
}
]
}
})),
)
.await;
// Should fail with a 4xx or 5xx error
assert_ne!(status, StatusCode::OK, "session with invalid skill source should fail");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_session_with_skill_filter_no_match() {
let app = TestApp::new();
let skill_dir = tempfile::tempdir().expect("create skill dir");
let skill_path = skill_dir.path().join("alpha");
std::fs::create_dir_all(&skill_path).expect("create alpha dir");
std::fs::write(skill_path.join("SKILL.md"), "# Alpha").expect("write SKILL.md");
// Filter for a skill that doesn't exist
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/sessions/skill-nomatch-session",
Some(json!({
"agent": "mock",
"skills": {
"sources": [
{
"type": "local",
"source": skill_dir.path().to_string_lossy(),
"skills": ["nonexistent"]
}
]
}
})),
)
.await;
assert_ne!(status, StatusCode::OK, "session with no matching skills should fail");
}

View file

@ -0,0 +1,267 @@
// Filesystem HTTP endpoints.
include!("../common/http.rs");
use std::fs as stdfs;
use tar::{Builder, Header};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_read_write_move_delete() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let dir_path = temp.path();
let file_path = dir_path.join("hello.txt");
let file_path_str = file_path.to_string_lossy().to_string();
let request = Request::builder()
.method(Method::PUT)
.uri(format!("/v1/fs/file?path={file_path_str}"))
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(Body::from("hello"))
.expect("write request");
let (status, _headers, _payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "write file");
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/fs/file?path={file_path_str}"))
.body(Body::empty())
.expect("read request");
let (status, headers, bytes) = send_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "read file");
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/octet-stream")
);
assert_eq!(bytes.as_ref(), b"hello");
let entries_path = dir_path.to_string_lossy().to_string();
let (status, entries) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/entries?path={entries_path}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "list entries");
let entry_list = entries.as_array().cloned().unwrap_or_default();
let entry_names: Vec<String> = entry_list
.iter()
.filter_map(|entry| entry.get("name").and_then(|value| value.as_str()))
.map(|value| value.to_string())
.collect();
assert!(entry_names.contains(&"hello.txt".to_string()));
let new_path = dir_path.join("moved.txt");
let new_path_str = new_path.to_string_lossy().to_string();
let (status, _payload) = send_json(
&app.app,
Method::POST,
"/v1/fs/move",
Some(json!({
"from": file_path_str,
"to": new_path_str,
"overwrite": true
})),
)
.await;
assert_eq!(status, StatusCode::OK, "move file");
assert!(new_path.exists(), "moved file exists");
let (status, _payload) = send_json(
&app.app,
Method::DELETE,
&format!("/v1/fs/entry?path={}", new_path.to_string_lossy()),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "delete file");
assert!(!new_path.exists(), "file deleted");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_upload_batch_tar() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir");
let mut builder = Builder::new(Vec::new());
let mut tar_header = Header::new_gnu();
let contents = b"hello";
tar_header.set_size(contents.len() as u64);
tar_header.set_cksum();
builder
.append_data(&mut tar_header, "a.txt", &contents[..])
.expect("append tar entry");
let mut tar_header = Header::new_gnu();
let contents = b"world";
tar_header.set_size(contents.len() as u64);
tar_header.set_cksum();
builder
.append_data(&mut tar_header, "nested/b.txt", &contents[..])
.expect("append tar entry");
let tar_bytes = builder.into_inner().expect("tar bytes");
let request = Request::builder()
.method(Method::POST)
.uri(format!(
"/v1/fs/upload-batch?path={}",
dest_dir.path().to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/x-tar")
.body(Body::from(tar_bytes))
.expect("tar request");
let (status, _headers, payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "upload batch");
assert!(payload
.get("paths")
.and_then(|value| value.as_array())
.map(|value| !value.is_empty())
.unwrap_or(false));
assert!(payload.get("truncated").and_then(|value| value.as_bool()) == Some(false));
let a_path = dest_dir.path().join("a.txt");
let b_path = dest_dir.path().join("nested").join("b.txt");
assert!(a_path.exists(), "a.txt extracted");
assert!(b_path.exists(), "b.txt extracted");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_relative_paths_use_session_dir() {
let app = TestApp::new();
let session_id = "fs-session";
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({ "agent": "mock" })),
)
.await;
assert_eq!(status, StatusCode::OK, "create session");
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let relative_dir = temp
.path()
.strip_prefix(&cwd)
.expect("strip prefix")
.to_path_buf();
let relative_path = relative_dir.join("session.txt");
let request = Request::builder()
.method(Method::PUT)
.uri(format!(
"/v1/fs/file?session_id={session_id}&path={}",
relative_path.to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(Body::from("session"))
.expect("write request");
let (status, _headers, _payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "write relative file");
let absolute_path = cwd.join(relative_path);
let content = stdfs::read_to_string(&absolute_path).expect("read file");
assert_eq!(content, "session");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_upload_batch_truncates_paths() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir");
let mut builder = Builder::new(Vec::new());
for index in 0..1030 {
let mut tar_header = Header::new_gnu();
tar_header.set_size(0);
tar_header.set_cksum();
let name = format!("file_{index}.txt");
builder
.append_data(&mut tar_header, name, &[][..])
.expect("append tar entry");
}
let tar_bytes = builder.into_inner().expect("tar bytes");
let request = Request::builder()
.method(Method::POST)
.uri(format!(
"/v1/fs/upload-batch?path={}",
dest_dir.path().to_string_lossy()
))
.header(header::CONTENT_TYPE, "application/x-tar")
.body(Body::from(tar_bytes))
.expect("tar request");
let (status, _headers, payload) = send_json_request(&app.app, request).await;
assert_eq!(status, StatusCode::OK, "upload batch");
let paths = payload
.get("paths")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
assert_eq!(paths.len(), 1024);
assert_eq!(payload.get("truncated").and_then(|value| value.as_bool()), Some(true));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_mkdir_stat_and_delete_directory() {
let app = TestApp::new();
let cwd = std::env::current_dir().expect("cwd");
let temp = tempfile::tempdir_in(&cwd).expect("tempdir");
let dir_path = temp.path().join("nested");
let dir_path_str = dir_path.to_string_lossy().to_string();
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/fs/mkdir?path={dir_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "mkdir");
assert!(dir_path.exists(), "directory created");
let (status, stat) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/stat?path={dir_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "stat directory");
assert_eq!(stat["entryType"], "directory");
let file_path = dir_path.join("note.txt");
stdfs::write(&file_path, "content").expect("write file");
let file_path_str = file_path.to_string_lossy().to_string();
let (status, stat) = send_json(
&app.app,
Method::GET,
&format!("/v1/fs/stat?path={file_path_str}"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "stat file");
assert_eq!(stat["entryType"], "file");
let status = send_status(
&app.app,
Method::DELETE,
&format!("/v1/fs/entry?path={dir_path_str}&recursive=true"),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "delete directory");
assert!(!dir_path.exists(), "directory deleted");
}

View file

@ -0,0 +1,6 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 145
expression: snapshot_status(status)
---
status: 204

View file

@ -0,0 +1,5 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: snapshot_status(status)
---
status: 204

View file

@ -0,0 +1,13 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
defaultModel: amp-default
hasDefault: true
hasVariants: false
ids:
- amp-default
modelCount: 1
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: "<redacted>"
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 185
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: false
nonEmpty: true

View file

@ -0,0 +1,8 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: "normalize_agent_models(&models, config.agent)"
---
defaultInList: true
hasDefault: true
hasVariants: "<redacted>"
nonEmpty: true

View file

@ -0,0 +1,9 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 162
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build

View file

@ -0,0 +1,12 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
assertion_line: 162
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build
- description: true
id: plan
name: Plan

View file

@ -0,0 +1,14 @@
---
source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs
expression: normalize_agent_modes(&modes)
---
modes:
- description: true
id: build
name: Build
- description: true
id: custom
name: Custom
- description: true
id: plan
name: Plan

View file

@ -1,2 +1,4 @@
#[path = "http/agent_endpoints.rs"]
mod agent_endpoints;
#[path = "http/fs_endpoints.rs"]
mod fs_endpoints;

View file

@ -1,77 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs
assertion_line: 15
expression: value
---
first:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta
second:
- seq: 1
type: turn.started
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 2
type: item.completed

View file

@ -1,65 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta

View file

@ -1,49 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/questions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 7
type: item.completed

View file

@ -1,79 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
assertion_line: 12
expression: value
---
session_a:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
session_b:
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta

View file

@ -0,0 +1,6 @@
---
source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs
expression: value
---
hasExpectedFields: true
sessionCount: 1

View file

@ -1,41 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/../common/http.rs
assertion_line: 1001
expression: normalized
---
- metadata: true
seq: 1
session: started
type: session.started
- seq: 2
type: turn.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 3
type: item.started
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta