chore: fix bad merge

This commit is contained in:
Nathan Flurry 2026-02-11 07:52:48 -08:00
parent 1dd45908a3
commit 94353f7696
205 changed files with 19244 additions and 14866 deletions

View file

@ -313,7 +313,7 @@ const client = await SandboxAgent.connect({
### Auto-Detection
`SandboxAgent` provides two factory methods:
Sandbox Agent provides two factory methods:
```typescript
// Connect to existing server

View file

@ -0,0 +1,24 @@
[package]
name = "acp-http-adapter"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Minimal ACP HTTP-to-stdio adapter"
[dependencies]
axum.workspace = true
tokio = { workspace = true, features = ["process", "io-util"] }
tokio-stream.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
reqwest.workspace = true
bytes = "1.10"

View file

@ -0,0 +1,47 @@
# acp-http-adapter
Minimal ACP HTTP to stdio proxy.
## Endpoints
- `GET /v1/health`
- `POST /v1/rpc`
- `GET /v1/rpc` (SSE)
- `DELETE /v1/rpc`
## Stdio framing
Uses ACP stdio framing from ACP docs:
- UTF-8 JSON-RPC messages
- one message per line
- newline-delimited (`\n`)
- no embedded newlines in messages
## Run
```bash
cargo run -p acp-http-adapter -- \
--host 127.0.0.1 \
--port 7591 \
--registry-json '{"distribution":{"npx":{"package":"@zed-industries/codex-acp"}}}'
```
`--registry-json` accepts:
- full registry document (`{"agents":[...]}`) with `--registry-agent-id`
- single registry entry (`{"id":"...","distribution":...}`)
- direct distribution object (`{"npx":...}` or `{"binary":...}`)
## Library
```rust
use std::time::Duration;
use acp_http_adapter::{run_server, ServerConfig};
run_server(ServerConfig {
host: "127.0.0.1".to_string(),
port: 7591,
registry_json: r#"{"distribution":{"npx":{"package":"@zed-industries/codex-acp"}}}"#.to_string(),
registry_agent_id: None,
rpc_timeout: Duration::from_secs(120),
}).await?;
```

View file

@ -0,0 +1,132 @@
use std::sync::Arc;
use std::time::Duration;
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::sse::KeepAlive;
use axum::response::{IntoResponse, Response, Sse};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Serialize;
use serde_json::{json, Value};
use crate::process::{AdapterError, AdapterRuntime, PostOutcome};
#[derive(Debug, Serialize)]
struct HealthResponse {
ok: bool,
}
#[derive(Debug, Serialize)]
struct Problem {
r#type: &'static str,
title: &'static str,
status: u16,
detail: String,
}
pub fn build_router(runtime: Arc<AdapterRuntime>) -> Router {
Router::new()
.route("/v1/health", get(get_health))
.route("/v1/rpc", post(post_rpc).get(get_rpc).delete(delete_rpc))
.with_state(runtime)
}
async fn get_health() -> Json<HealthResponse> {
Json(HealthResponse { ok: true })
}
async fn post_rpc(
State(runtime): State<Arc<AdapterRuntime>>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Response {
if !is_json_content_type(&headers) {
return problem(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"unsupported_media_type",
"content-type must be application/json",
);
}
match runtime.post(payload).await {
Ok(PostOutcome::Response(value)) => (StatusCode::OK, Json(value)).into_response(),
Ok(PostOutcome::Accepted) => StatusCode::ACCEPTED.into_response(),
Err(err) => map_error(err),
}
}
async fn get_rpc(
State(runtime): State<Arc<AdapterRuntime>>,
headers: HeaderMap,
) -> impl IntoResponse {
let last_event_id = headers
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok());
let stream = runtime.clone().sse_stream(last_event_id).await;
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
async fn delete_rpc() -> StatusCode {
StatusCode::NO_CONTENT
}
fn is_json_content_type(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.starts_with("application/json"))
.unwrap_or(false)
}
fn map_error(err: AdapterError) -> Response {
match err {
AdapterError::InvalidEnvelope => problem(
StatusCode::BAD_REQUEST,
"invalid_envelope",
"request body must be a JSON-RPC object",
),
AdapterError::Timeout => problem(
StatusCode::GATEWAY_TIMEOUT,
"timeout",
"timed out waiting for agent response",
),
AdapterError::Write(write) => problem(
StatusCode::BAD_GATEWAY,
"write_failed",
&format!("failed writing to agent stdin: {write}"),
),
AdapterError::Serialize(ser) => problem(
StatusCode::BAD_REQUEST,
"serialize_failed",
&format!("failed to serialize JSON payload: {ser}"),
),
AdapterError::Spawn(spawn) => problem(
StatusCode::BAD_GATEWAY,
"spawn_failed",
&format!("failed to start agent process: {spawn}"),
),
AdapterError::MissingStdin | AdapterError::MissingStdout | AdapterError::MissingStderr => {
problem(
StatusCode::BAD_GATEWAY,
"io_setup_failed",
"agent subprocess pipes were not available",
)
}
}
}
fn problem(status: StatusCode, title: &'static str, detail: &str) -> Response {
(
status,
Json(json!(Problem {
r#type: "about:blank",
title,
status: status.as_u16(),
detail: detail.to_string(),
})),
)
.into_response()
}

View file

@ -0,0 +1,50 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use app::build_router;
use process::AdapterRuntime;
use registry::LaunchSpec;
pub mod app;
pub mod process;
pub mod registry;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub registry_json: String,
pub registry_agent_id: Option<String>,
pub rpc_timeout: Duration,
}
pub async fn run_server(
config: ServerConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let launch =
LaunchSpec::from_registry_blob(&config.registry_json, config.registry_agent_id.as_deref())?;
let runtime = Arc::new(AdapterRuntime::start(launch, config.rpc_timeout).await?);
run_server_with_runtime(config.host, config.port, runtime).await
}
pub async fn run_server_with_runtime(
host: String,
port: u16,
runtime: Arc<AdapterRuntime>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let app = build_router(runtime.clone());
let addr: SocketAddr = format!("{host}:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(addr = %addr, "acp-http-adapter listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(runtime))
.await?;
Ok(())
}
async fn shutdown_signal(runtime: Arc<AdapterRuntime>) {
let _ = tokio::signal::ctrl_c().await;
runtime.shutdown().await;
}

View file

@ -0,0 +1,55 @@
use std::time::Duration;
use acp_http_adapter::{run_server, ServerConfig};
use clap::Parser;
#[derive(Debug, Parser)]
#[command(name = "acp-http-adapter")]
#[command(about = "Minimal ACP HTTP->stdio adapter", version)]
struct Cli {
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = 7591)]
port: u16,
#[arg(long)]
registry_json: String,
#[arg(long)]
registry_agent_id: Option<String>,
#[arg(long)]
rpc_timeout_ms: Option<u64>,
}
#[tokio::main]
async fn main() {
if let Err(err) = run().await {
tracing::error!(error = %err, "acp-http-adapter failed");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.compact()
.init();
let cli = Cli::parse();
run_server(ServerConfig {
host: cli.host,
port: cli.port,
registry_json: cli.registry_json,
registry_agent_id: cli.registry_agent_id,
rpc_timeout: cli
.rpc_timeout_ms
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_secs(120)),
})
.await
}

View file

@ -0,0 +1,567 @@
use std::collections::{HashMap, VecDeque};
use std::convert::Infallible;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::response::sse::Event;
use futures::{stream, Stream, StreamExt};
use serde_json::{json, Value};
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, oneshot, Mutex};
use tokio_stream::wrappers::BroadcastStream;
use crate::registry::LaunchSpec;
const RING_BUFFER_SIZE: usize = 1024;
#[derive(Debug, Error)]
pub enum AdapterError {
#[error("failed to spawn subprocess: {0}")]
Spawn(std::io::Error),
#[error("failed to capture subprocess stdin")]
MissingStdin,
#[error("failed to capture subprocess stdout")]
MissingStdout,
#[error("failed to capture subprocess stderr")]
MissingStderr,
#[error("invalid json-rpc envelope")]
InvalidEnvelope,
#[error("failed to serialize json-rpc message: {0}")]
Serialize(serde_json::Error),
#[error("failed to write subprocess stdin: {0}")]
Write(std::io::Error),
#[error("timeout waiting for response")]
Timeout,
}
#[derive(Debug)]
pub enum PostOutcome {
Response(Value),
Accepted,
}
#[derive(Debug, Clone)]
struct StreamMessage {
sequence: u64,
payload: Value,
}
#[derive(Debug)]
pub struct AdapterRuntime {
stdin: Arc<Mutex<ChildStdin>>,
child: Arc<Mutex<Child>>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>,
sender: broadcast::Sender<StreamMessage>,
ring: Arc<Mutex<VecDeque<StreamMessage>>>,
sequence: Arc<AtomicU64>,
request_timeout: Duration,
shutting_down: AtomicBool,
spawned_at: Instant,
first_stdout: Arc<AtomicBool>,
}
impl AdapterRuntime {
pub async fn start(
launch: LaunchSpec,
request_timeout: Duration,
) -> Result<Self, AdapterError> {
let spawn_start = Instant::now();
let mut command = Command::new(&launch.program);
command
.args(&launch.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (key, value) in &launch.env {
command.env(key, value);
}
tracing::info!(
program = ?launch.program,
args = ?launch.args,
"spawning agent process"
);
let mut child = command.spawn().map_err(|err| {
tracing::error!(
program = ?launch.program,
error = %err,
"failed to spawn agent process"
);
AdapterError::Spawn(err)
})?;
let pid = child.id().unwrap_or(0);
let spawn_elapsed = spawn_start.elapsed();
tracing::info!(
pid = pid,
elapsed_ms = spawn_elapsed.as_millis() as u64,
"agent process spawned"
);
let stdin = child.stdin.take().ok_or(AdapterError::MissingStdin)?;
let stdout = child.stdout.take().ok_or(AdapterError::MissingStdout)?;
let stderr = child.stderr.take().ok_or(AdapterError::MissingStderr)?;
let (sender, _rx) = broadcast::channel(512);
let runtime = Self {
stdin: Arc::new(Mutex::new(stdin)),
child: Arc::new(Mutex::new(child)),
pending: Arc::new(Mutex::new(HashMap::new())),
sender,
ring: Arc::new(Mutex::new(VecDeque::with_capacity(RING_BUFFER_SIZE))),
sequence: Arc::new(AtomicU64::new(0)),
request_timeout,
shutting_down: AtomicBool::new(false),
spawned_at: spawn_start,
first_stdout: Arc::new(AtomicBool::new(false)),
};
runtime.spawn_stdout_loop(stdout);
runtime.spawn_stderr_loop(stderr);
runtime.spawn_exit_watcher();
Ok(runtime)
}
pub async fn post(&self, payload: Value) -> Result<PostOutcome, AdapterError> {
if !payload.is_object() {
return Err(AdapterError::InvalidEnvelope);
}
let method: String = payload
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string();
let has_method = payload.get("method").is_some();
let id = payload.get("id");
if has_method && id.is_some() {
let id_value = id.expect("checked");
let key = id_key(id_value);
let (tx, rx) = oneshot::channel();
let pending_count = self.pending.lock().await.len();
tracing::info!(
method = %method,
id = %key,
pending_count = pending_count,
"post: request → agent (awaiting response)"
);
self.pending.lock().await.insert(key.clone(), tx);
let write_start = Instant::now();
if let Err(err) = self.send_to_subprocess(&payload).await {
tracing::error!(
method = %method,
id = %key,
error = %err,
"post: failed to write to agent stdin"
);
self.pending.lock().await.remove(&key);
return Err(err);
}
let write_ms = write_start.elapsed().as_millis() as u64;
tracing::debug!(
method = %method,
id = %key,
write_ms = write_ms,
"post: stdin write complete, waiting for response"
);
let wait_start = Instant::now();
match tokio::time::timeout(self.request_timeout, rx).await {
Ok(Ok(response)) => {
let wait_ms = wait_start.elapsed().as_millis() as u64;
tracing::info!(
method = %method,
id = %key,
response_ms = wait_ms,
total_ms = write_ms + wait_ms,
"post: got response from agent"
);
Ok(PostOutcome::Response(response))
}
Ok(Err(_)) => {
let wait_ms = wait_start.elapsed().as_millis() as u64;
tracing::error!(
method = %method,
id = %key,
wait_ms = wait_ms,
"post: response channel dropped (agent process may have exited)"
);
self.pending.lock().await.remove(&key);
Err(AdapterError::Timeout)
}
Err(_) => {
let pending_keys: Vec<String> =
self.pending.lock().await.keys().cloned().collect();
tracing::error!(
method = %method,
id = %key,
timeout_ms = self.request_timeout.as_millis() as u64,
age_ms = self.spawned_at.elapsed().as_millis() as u64,
pending_keys = ?pending_keys,
first_stdout_seen = self.first_stdout.load(Ordering::Relaxed),
"post: TIMEOUT waiting for agent response"
);
self.pending.lock().await.remove(&key);
Err(AdapterError::Timeout)
}
}
} else {
tracing::debug!(
method = %method,
"post: notification → agent (fire-and-forget)"
);
self.send_to_subprocess(&payload).await?;
Ok(PostOutcome::Accepted)
}
}
async fn subscribe(
&self,
last_event_id: Option<u64>,
) -> (Vec<(u64, Value)>, broadcast::Receiver<StreamMessage>) {
let replay = {
let ring = self.ring.lock().await;
ring.iter()
.filter(|message| {
if let Some(last_event_id) = last_event_id {
message.sequence > last_event_id
} else {
true
}
})
.map(|message| (message.sequence, message.payload.clone()))
.collect::<Vec<_>>()
};
(replay, self.sender.subscribe())
}
pub async fn sse_stream(
self: Arc<Self>,
last_event_id: Option<u64>,
) -> impl Stream<Item = Result<Event, Infallible>> + Send + 'static {
let (replay, rx) = self.subscribe(last_event_id).await;
let replay_stream = stream::iter(replay.into_iter().map(|(sequence, payload)| {
let event = Event::default()
.event("message")
.id(sequence.to_string())
.data(payload.to_string());
Ok(event)
}));
let live_stream = BroadcastStream::new(rx).filter_map(|item| async move {
match item {
Ok(message) => {
let event = Event::default()
.event("message")
.id(message.sequence.to_string())
.data(message.payload.to_string());
Some(Ok(event))
}
Err(_) => None,
}
});
replay_stream.chain(live_stream)
}
/// Stream of raw JSON-RPC `Value` payloads (without SSE framing).
/// Useful for consumers that need to inspect the payload contents
/// rather than forward them as SSE events.
pub async fn value_stream(
self: Arc<Self>,
last_event_id: Option<u64>,
) -> impl Stream<Item = Value> + Send + 'static {
let (replay, rx) = self.subscribe(last_event_id).await;
let replay_stream = stream::iter(replay.into_iter().map(|(_sequence, payload)| payload));
let live_stream = BroadcastStream::new(rx).filter_map(|item| async move {
match item {
Ok(message) => Some(message.payload),
Err(_) => None,
}
});
replay_stream.chain(live_stream)
}
pub async fn shutdown(&self) {
if self.shutting_down.swap(true, Ordering::SeqCst) {
return;
}
tracing::info!(
age_ms = self.spawned_at.elapsed().as_millis() as u64,
"shutting down agent process"
);
self.pending.lock().await.clear();
let mut child = self.child.lock().await;
match child.try_wait() {
Ok(Some(_)) => {}
Ok(None) => {
let _ = child.kill().await;
let _ = child.wait().await;
}
Err(_) => {
let _ = child.kill().await;
}
}
}
fn spawn_stdout_loop(&self, stdout: tokio::process::ChildStdout) {
let pending = self.pending.clone();
let sender = self.sender.clone();
let ring = self.ring.clone();
let sequence = self.sequence.clone();
let spawned_at = self.spawned_at;
let first_stdout = self.first_stdout.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
let mut line_count: u64 = 0;
while let Ok(Some(line)) = lines.next_line().await {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
line_count += 1;
if !first_stdout.swap(true, Ordering::Relaxed) {
tracing::info!(
first_stdout_ms = spawned_at.elapsed().as_millis() as u64,
line_bytes = trimmed.len(),
"agent process: first stdout line received"
);
}
let payload = match serde_json::from_str::<Value>(trimmed) {
Ok(payload) => payload,
Err(err) => {
tracing::warn!(
error = %err,
line_number = line_count,
raw = %if trimmed.len() > 200 {
format!("{}...", &trimmed[..200])
} else {
trimmed.to_string()
},
"agent stdout: invalid JSON"
);
json!({
"jsonrpc": "2.0",
"method": "_adapter/invalid_stdout",
"params": {
"error": err.to_string(),
"raw": trimmed,
}
})
}
};
let is_response = payload.get("id").is_some() && payload.get("method").is_none();
if is_response {
let key = id_key(payload.get("id").expect("checked"));
let has_error = payload.get("error").is_some();
if let Some(tx) = pending.lock().await.remove(&key) {
tracing::debug!(
id = %key,
has_error = has_error,
age_ms = spawned_at.elapsed().as_millis() as u64,
"agent stdout: response matched to pending request"
);
let _ = tx.send(payload.clone());
// Also broadcast the response so SSE/notification subscribers
// see it in order after preceding notifications. This lets the
// SSE translation task detect turn completion after all
// session/update events have been processed.
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
let message = StreamMessage {
sequence: seq,
payload,
};
{
let mut guard = ring.lock().await;
guard.push_back(message.clone());
while guard.len() > RING_BUFFER_SIZE {
guard.pop_front();
}
}
let _ = sender.send(message);
continue;
} else {
tracing::warn!(
id = %key,
has_error = has_error,
"agent stdout: response has no matching pending request (orphan)"
);
}
}
let method = payload
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
tracing::debug!(
method = method,
line_number = line_count,
"agent stdout: notification/event → SSE broadcast"
);
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
let message = StreamMessage {
sequence: seq,
payload,
};
{
let mut guard = ring.lock().await;
guard.push_back(message.clone());
while guard.len() > RING_BUFFER_SIZE {
guard.pop_front();
}
}
let _ = sender.send(message);
}
tracing::info!(
total_lines = line_count,
age_ms = spawned_at.elapsed().as_millis() as u64,
"agent stdout: stream ended"
);
});
}
fn spawn_stderr_loop(&self, stderr: tokio::process::ChildStderr) {
let spawned_at = self.spawned_at;
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
let mut line_count: u64 = 0;
while let Ok(Some(line)) = lines.next_line().await {
line_count += 1;
tracing::info!(
line_number = line_count,
age_ms = spawned_at.elapsed().as_millis() as u64,
"agent stderr: {}",
line
);
}
tracing::debug!(
total_lines = line_count,
age_ms = spawned_at.elapsed().as_millis() as u64,
"agent stderr: stream ended"
);
});
}
fn spawn_exit_watcher(&self) {
let child = self.child.clone();
let sender = self.sender.clone();
let ring = self.ring.clone();
let sequence = self.sequence.clone();
let spawned_at = self.spawned_at;
let pending = self.pending.clone();
tokio::spawn(async move {
let status = {
let mut guard = child.lock().await;
guard.wait().await.ok()
};
let age_ms = spawned_at.elapsed().as_millis() as u64;
let pending_count = pending.lock().await.len();
if let Some(status) = status {
tracing::warn!(
success = status.success(),
code = status.code(),
age_ms = age_ms,
pending_requests = pending_count,
"agent process exited"
);
let payload = json!({
"jsonrpc": "2.0",
"method": "_adapter/agent_exited",
"params": {
"success": status.success(),
"code": status.code(),
}
});
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
let message = StreamMessage {
sequence: seq,
payload,
};
{
let mut guard = ring.lock().await;
guard.push_back(message.clone());
while guard.len() > RING_BUFFER_SIZE {
guard.pop_front();
}
}
let _ = sender.send(message);
} else {
tracing::error!(
age_ms = age_ms,
pending_requests = pending_count,
"agent process: failed to get exit status"
);
}
});
}
async fn send_to_subprocess(&self, payload: &Value) -> Result<(), AdapterError> {
let method = payload
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
let id = payload.get("id").map(|v| v.to_string()).unwrap_or_default();
tracing::debug!(
method = method,
id = %id,
bytes = serde_json::to_vec(payload).map(|b| b.len()).unwrap_or(0),
"stdin: writing message to agent"
);
let mut stdin = self.stdin.lock().await;
let bytes = serde_json::to_vec(payload).map_err(AdapterError::Serialize)?;
stdin.write_all(&bytes).await.map_err(|err| {
tracing::error!(method = method, id = %id, error = %err, "stdin: write_all failed");
AdapterError::Write(err)
})?;
stdin.write_all(b"\n").await.map_err(|err| {
tracing::error!(method = method, id = %id, error = %err, "stdin: newline write failed");
AdapterError::Write(err)
})?;
stdin.flush().await.map_err(|err| {
tracing::error!(method = method, id = %id, error = %err, "stdin: flush failed");
AdapterError::Write(err)
})?;
tracing::debug!(method = method, id = %id, "stdin: write+flush complete");
Ok(())
}
}
fn id_key(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}

View file

@ -0,0 +1,143 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct LaunchSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("invalid registry json: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("unable to resolve registry entry from blob")]
UnsupportedBlob,
#[error("registry blob has agents[] but no --registry-agent-id was provided")]
MissingAgentId,
#[error("agent '{0}' was not found in registry blob")]
AgentNotFound(String),
#[error("registry entry has no supported launch target")]
MissingLaunchTarget,
#[error("platform '{0}' is not present in distribution.binary")]
UnsupportedPlatform(String),
}
impl LaunchSpec {
pub fn from_registry_blob(blob: &str, agent_id: Option<&str>) -> Result<Self, RegistryError> {
let value: Value = serde_json::from_str(blob)?;
Self::from_registry_value(value, agent_id)
}
fn from_registry_value(value: Value, agent_id: Option<&str>) -> Result<Self, RegistryError> {
if value.get("agents").is_some() {
let doc: RegistryDocument = serde_json::from_value(value)?;
let wanted = agent_id.ok_or(RegistryError::MissingAgentId)?;
let agent = doc
.agents
.into_iter()
.find(|a| a.id == wanted)
.ok_or_else(|| RegistryError::AgentNotFound(wanted.to_string()))?;
return Self::from_distribution(agent.distribution);
}
if value.get("distribution").is_some() {
let entry: RegistryAgent = serde_json::from_value(value)?;
return Self::from_distribution(entry.distribution);
}
if value.get("npx").is_some() || value.get("binary").is_some() {
let distribution: RegistryDistribution = serde_json::from_value(value)?;
return Self::from_distribution(distribution);
}
Err(RegistryError::UnsupportedBlob)
}
fn from_distribution(distribution: RegistryDistribution) -> Result<Self, RegistryError> {
if let Some(npx) = distribution.npx {
let mut args = vec!["-y".to_string(), npx.package];
args.extend(npx.args);
return Ok(Self {
program: PathBuf::from("npx"),
args,
env: npx.env,
});
}
if let Some(binary) = distribution.binary {
let platform = platform_key().ok_or(RegistryError::UnsupportedPlatform(format!(
"{}/{}",
std::env::consts::OS,
std::env::consts::ARCH
)))?;
let target = binary
.get(platform)
.ok_or_else(|| RegistryError::UnsupportedPlatform(platform.to_string()))?;
return Ok(Self {
program: PathBuf::from(&target.cmd),
args: target.args.clone(),
env: target.env.clone(),
});
}
Err(RegistryError::MissingLaunchTarget)
}
}
fn platform_key() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some("linux-x86_64"),
("linux", "aarch64") => Some("linux-aarch64"),
("macos", "x86_64") => Some("darwin-x86_64"),
("macos", "aarch64") => Some("darwin-aarch64"),
("windows", "x86_64") => Some("windows-x86_64"),
("windows", "aarch64") => Some("windows-aarch64"),
_ => None,
}
}
#[derive(Debug, Deserialize)]
struct RegistryDocument {
agents: Vec<RegistryAgent>,
}
#[derive(Debug, Deserialize)]
struct RegistryAgent {
#[allow(dead_code)]
id: String,
distribution: RegistryDistribution,
}
#[derive(Debug, Deserialize)]
struct RegistryDistribution {
#[serde(default)]
npx: Option<RegistryNpx>,
#[serde(default)]
binary: Option<HashMap<String, RegistryBinaryTarget>>,
}
#[derive(Debug, Deserialize)]
struct RegistryNpx {
package: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct RegistryBinaryTarget {
#[allow(dead_code)]
archive: Option<String>,
cmd: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}

View file

@ -0,0 +1,338 @@
use std::io;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use futures::StreamExt;
use reqwest::{Client, StatusCode};
use serde_json::{json, Value};
struct AdapterHandle {
child: Child,
base_url: String,
}
impl Drop for AdapterHandle {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[tokio::test]
async fn health_and_request_response_round_trip() {
let adapter = spawn_adapter().expect("spawn adapter");
wait_for_health(&adapter.base_url)
.await
.expect("wait for health");
let client = Client::new();
let health = client
.get(format!("{}/v1/health", adapter.base_url))
.send()
.await
.expect("health request");
assert_eq!(health.status(), StatusCode::OK);
let health_json: Value = health.json().await.expect("health json");
assert_eq!(health_json["ok"], true);
let payload = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "mock/ping",
"params": {
"text": "hello"
}
});
let response = client
.post(format!("{}/v1/rpc", adapter.base_url))
.json(&payload)
.send()
.await
.expect("post rpc");
assert_eq!(response.status(), StatusCode::OK);
let body: Value = response.json().await.expect("response json");
assert_eq!(body["jsonrpc"], "2.0");
assert_eq!(body["id"], 1);
assert_eq!(body["result"]["echoed"]["method"], "mock/ping");
assert_eq!(body["result"]["echoed"]["params"]["text"], "hello");
}
#[tokio::test]
async fn sse_request_and_client_response_flow() {
let adapter = spawn_adapter().expect("spawn adapter");
wait_for_health(&adapter.base_url)
.await
.expect("wait for health");
let client = Client::new();
let sse_response = client
.get(format!("{}/v1/rpc", adapter.base_url))
.header("accept", "text/event-stream")
.send()
.await
.expect("open sse");
assert_eq!(sse_response.status(), StatusCode::OK);
let mut sse = SseReader::new(sse_response);
let request = json!({
"jsonrpc": "2.0",
"id": 42,
"method": "mock/ask_client",
"params": {
"need": "input"
}
});
let initial = client
.post(format!("{}/v1/rpc", adapter.base_url))
.json(&request)
.send()
.await
.expect("post ask_client");
assert_eq!(initial.status(), StatusCode::OK);
let initial_body: Value = initial.json().await.expect("initial body");
assert_eq!(initial_body["id"], 42);
let mut agent_request_id = None;
for _ in 0..10 {
let event = sse
.next_json(Duration::from_secs(3))
.await
.expect("sse event");
if event["method"] == "mock/request" {
agent_request_id = event.get("id").cloned();
break;
}
}
let agent_request_id = agent_request_id.expect("agent request id");
let client_response = json!({
"jsonrpc": "2.0",
"id": agent_request_id,
"result": {
"approved": true
}
});
let response = client
.post(format!("{}/v1/rpc", adapter.base_url))
.json(&client_response)
.send()
.await
.expect("post client response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let mut saw_client_response = false;
for _ in 0..10 {
let event = sse
.next_json(Duration::from_secs(3))
.await
.expect("sse follow-up");
if event["method"] == "mock/client_response" {
assert_eq!(event["params"]["result"]["approved"], true);
saw_client_response = true;
break;
}
}
assert!(
saw_client_response,
"expected mock/client_response over SSE"
);
}
struct SseReader {
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, reqwest::Error>>,
buffer: Vec<u8>,
}
impl SseReader {
fn new(response: reqwest::Response) -> Self {
Self {
stream: response.bytes_stream().boxed(),
buffer: Vec::new(),
}
}
async fn next_json(&mut self, timeout: Duration) -> io::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
if let Some(event) = self.try_parse_event()? {
return Ok(event);
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for sse event",
));
}
let remaining = deadline.saturating_duration_since(Instant::now());
let chunk = tokio::time::timeout(remaining, self.stream.next())
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "timed out reading sse"))?;
match chunk {
Some(Ok(bytes)) => self.buffer.extend_from_slice(&bytes),
Some(Err(err)) => {
return Err(io::Error::other(format!("sse stream error: {err}")));
}
None => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"sse stream ended",
));
}
}
}
}
fn try_parse_event(&mut self) -> io::Result<Option<Value>> {
let split = self
.buffer
.windows(2)
.position(|window| window == b"\n\n")
.or_else(|| {
self.buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
});
let Some(idx) = split else {
return Ok(None);
};
let delimiter_len = if self.buffer.get(idx..idx + 2) == Some(b"\n\n") {
2
} else {
4
};
let block = self.buffer.drain(..idx + delimiter_len).collect::<Vec<_>>();
let text = String::from_utf8_lossy(&block);
let data = text
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() {
return Ok(None);
}
let value: Value = serde_json::from_str(&data).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid sse json payload: {err}"),
)
})?;
Ok(Some(value))
}
}
fn spawn_adapter() -> io::Result<AdapterHandle> {
let port = pick_port()?;
let base_url = format!("http://127.0.0.1:{port}");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir
.ancestors()
.nth(3)
.expect("workspace root")
.to_path_buf();
let mock_agent_js = workspace_root.join("examples/mock-acp-agent/dist/index.js");
let registry_blob = json!({
"id": "mock-acp-agent",
"distribution": {
"binary": {
"linux-x86_64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
},
"linux-aarch64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
},
"darwin-x86_64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
},
"darwin-aarch64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
},
"windows-x86_64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
},
"windows-aarch64": {
"cmd": "node",
"args": [mock_agent_js.to_string_lossy()],
"env": {}
}
}
}
})
.to_string();
let child = Command::new(env!("CARGO_BIN_EXE_acp-http-adapter"))
.arg("--host")
.arg("127.0.0.1")
.arg("--port")
.arg(port.to_string())
.arg("--registry-json")
.arg(registry_blob)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
Ok(AdapterHandle { child, base_url })
}
fn pick_port() -> io::Result<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
async fn wait_for_health(base_url: &str) -> io::Result<()> {
let client = Client::new();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if Instant::now() > deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"adapter did not become healthy",
));
}
if let Ok(response) = client.get(format!("{base_url}/v1/health")).send().await {
if response.status() == StatusCode::OK {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}

View file

@ -0,0 +1,20 @@
[package]
name = "sandbox-agent-opencode-adapter"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description.workspace = true
repository.workspace = true
[dependencies]
axum.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
sandbox-agent-error.workspace = true
sandbox-agent-opencode-server-manager.workspace = true
reqwest.workspace = true
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "migrate"] }

View file

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
last_connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
destroyed_at INTEGER,
session_init_json TEXT
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
connection_id TEXT NOT NULL,
sender TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_session_created
ON events(session_id, created_at, id);
CREATE TABLE IF NOT EXISTS opencode_session_metadata (
session_id TEXT PRIMARY KEY,
metadata_json TEXT NOT NULL
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
[package]
name = "sandbox-agent-opencode-server-manager"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description.workspace = true
repository.workspace = true
[dependencies]
sandbox-agent-agent-management.workspace = true
dirs.workspace = true
reqwest.workspace = true
tokio.workspace = true
tracing.workspace = true

View file

@ -0,0 +1,310 @@
use std::fs::{self, OpenOptions};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use reqwest::Client;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::warn;
const HEALTH_ENDPOINTS: [&str; 4] = ["health", "healthz", "app/agents", "agents"];
const HEALTH_ATTEMPTS: usize = 20;
const HEALTH_DELAY_MS: u64 = 150;
const MONITOR_DELAY_MS: u64 = 500;
#[derive(Debug, Clone)]
pub struct OpenCodeServerManagerConfig {
pub log_dir: PathBuf,
pub auto_restart: bool,
}
impl Default for OpenCodeServerManagerConfig {
fn default() -> Self {
Self {
log_dir: default_log_dir(),
auto_restart: true,
}
}
}
#[derive(Debug, Clone)]
pub struct OpenCodeServerManager {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
agent_manager: Arc<AgentManager>,
http_client: Client,
config: OpenCodeServerManagerConfig,
ensure_lock: Mutex<()>,
state: Mutex<ManagerState>,
}
#[derive(Debug, Default)]
struct ManagerState {
server: Option<RunningServer>,
restart_count: u64,
shutdown_requested: bool,
last_error: Option<String>,
}
#[derive(Debug, Clone)]
struct RunningServer {
base_url: String,
child: Arc<StdMutex<Option<Child>>>,
instance_id: u64,
}
impl OpenCodeServerManager {
pub fn new(agent_manager: Arc<AgentManager>, config: OpenCodeServerManagerConfig) -> Self {
Self {
inner: Arc::new(Inner {
agent_manager,
http_client: Client::new(),
config,
ensure_lock: Mutex::new(()),
state: Mutex::new(ManagerState::default()),
}),
}
}
pub async fn ensure_server(&self) -> Result<String, String> {
let _guard = self.inner.ensure_lock.lock().await;
if let Some(base_url) = self.running_base_url().await {
return Ok(base_url);
}
let (base_url, child) = self.spawn_http_server().await?;
if let Err(err) = self.wait_for_http_server(&base_url).await {
kill_child(&child);
let mut state = self.inner.state.lock().await;
state.last_error = Some(err.clone());
return Err(err);
}
let instance_id = {
let mut state = self.inner.state.lock().await;
state.shutdown_requested = false;
state.restart_count += 1;
let instance_id = state.restart_count;
state.server = Some(RunningServer {
base_url: base_url.clone(),
child: child.clone(),
instance_id,
});
state.last_error = None;
instance_id
};
self.spawn_monitor_task(instance_id, child);
Ok(base_url)
}
pub async fn shutdown(&self) {
let _guard = self.inner.ensure_lock.lock().await;
let child = {
let mut state = self.inner.state.lock().await;
state.shutdown_requested = true;
state.server.take().map(|server| server.child)
};
if let Some(child) = child {
kill_child(&child);
}
}
async fn running_base_url(&self) -> Option<String> {
let running = {
let state = self.inner.state.lock().await;
state.server.clone()
}?;
if child_is_alive(&running.child) {
return Some(running.base_url);
}
let mut state = self.inner.state.lock().await;
if state
.server
.as_ref()
.map(|server| server.instance_id == running.instance_id)
.unwrap_or(false)
{
state.server = None;
}
None
}
async fn wait_for_http_server(&self, base_url: &str) -> Result<(), String> {
for _ in 0..HEALTH_ATTEMPTS {
for endpoint in HEALTH_ENDPOINTS {
let url = format!("{base_url}/{endpoint}");
match self.inner.http_client.get(&url).send().await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(_) | Err(_) => {}
}
}
sleep(Duration::from_millis(HEALTH_DELAY_MS)).await;
}
Err("OpenCode server health check failed".to_string())
}
async fn spawn_http_server(&self) -> Result<(String, Arc<StdMutex<Option<Child>>>), String> {
let agent_manager = self.inner.agent_manager.clone();
let log_dir = self.inner.config.log_dir.clone();
let (base_url, child) = tokio::task::spawn_blocking(move || {
let path = agent_manager
.resolve_binary(AgentId::Opencode)
.map_err(|err| err.to_string())?;
let port = find_available_port()?;
let mut command = Command::new(path);
let stderr = open_opencode_log(&log_dir).unwrap_or_else(|_| Stdio::null());
command
.arg("serve")
.arg("--port")
.arg(port.to_string())
.stdout(Stdio::null())
.stderr(stderr);
let child = command.spawn().map_err(|err| err.to_string())?;
Ok::<(String, Child), String>((format!("http://127.0.0.1:{port}"), child))
})
.await
.map_err(|err| err.to_string())??;
Ok((base_url, Arc::new(StdMutex::new(Some(child)))))
}
fn spawn_monitor_task(&self, instance_id: u64, child: Arc<StdMutex<Option<Child>>>) {
let manager = self.clone();
tokio::spawn(async move {
loop {
let status = {
let mut guard = match child.lock() {
Ok(guard) => guard,
Err(_) => return,
};
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(status) => status,
Err(_) => None,
},
None => return,
}
};
if let Some(status) = status {
manager.handle_process_exit(instance_id, status).await;
return;
}
sleep(Duration::from_millis(MONITOR_DELAY_MS)).await;
}
});
}
async fn handle_process_exit(&self, instance_id: u64, status: ExitStatus) {
let (should_restart, error_message) = {
let mut state = self.inner.state.lock().await;
let Some(server) = state.server.as_ref() else {
return;
};
if server.instance_id != instance_id {
return;
}
let message = format!("OpenCode server exited with status {:?}", status);
let shutdown_requested = state.shutdown_requested;
if !shutdown_requested {
state.last_error = Some(message.clone());
}
state.server = None;
(
!shutdown_requested && self.inner.config.auto_restart,
message,
)
};
if !should_restart {
return;
}
let manager = self.clone();
tokio::spawn(async move {
sleep(Duration::from_millis(MONITOR_DELAY_MS)).await;
if let Err(err) = manager.ensure_server().await {
warn!(
error = ?err,
prior_exit = %error_message,
"failed to restart OpenCode compat sidecar"
);
}
});
}
}
fn default_log_dir() -> PathBuf {
let mut base = dirs::data_local_dir().unwrap_or_else(|| std::env::temp_dir());
base.push("sandbox-agent");
base.push("agent-logs");
base
}
fn open_opencode_log(log_dir: &Path) -> Result<Stdio, String> {
let directory = log_dir.join("opencode");
fs::create_dir_all(&directory).map_err(|err| err.to_string())?;
let path = directory.join("opencode-compat.log");
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|err| err.to_string())?;
Ok(file.into())
}
fn find_available_port() -> Result<u16, String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| err.to_string())?;
let port = listener.local_addr().map_err(|err| err.to_string())?.port();
drop(listener);
Ok(port)
}
fn child_is_alive(child: &Arc<StdMutex<Option<Child>>>) -> bool {
let mut guard = match child.lock() {
Ok(guard) => guard,
Err(_) => return false,
};
let Some(child) = guard.as_mut() else {
return false;
};
match child.try_wait() {
Ok(Some(_)) => {
*guard = None;
false
}
Ok(None) => true,
Err(_) => false,
}
}
fn kill_child(child: &Arc<StdMutex<Option<Child>>>) {
if let Ok(mut guard) = child.lock() {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
}
*guard = None;
}
}

View file

@ -15,6 +15,9 @@ path = "src/main.rs"
sandbox-agent-error.workspace = true
sandbox-agent-agent-management.workspace = true
sandbox-agent-agent-credentials.workspace = true
sandbox-agent-opencode-adapter.workspace = true
sandbox-agent-opencode-server-manager.workspace = true
acp-http-adapter.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -0,0 +1,509 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use acp_http_adapter::process::{AdapterError, AdapterRuntime, PostOutcome};
use acp_http_adapter::registry::LaunchSpec;
use axum::response::sse::Event;
use futures::Stream;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
use sandbox_agent_error::SandboxError;
use sandbox_agent_opencode_adapter::{AcpDispatch, AcpDispatchResult, AcpPayloadStream};
use serde_json::Value;
use tokio::sync::{Mutex, RwLock};
const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 120_000;
#[derive(Debug, Clone)]
pub struct AcpProxyRuntime {
inner: Arc<AcpProxyRuntimeInner>,
}
#[derive(Debug)]
struct AcpProxyRuntimeInner {
agent_manager: Arc<AgentManager>,
require_preinstall: bool,
request_timeout: Duration,
instances: RwLock<HashMap<String, Arc<ProxyInstance>>>,
instance_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
install_locks: Mutex<HashMap<AgentId, Arc<Mutex<()>>>>,
}
#[derive(Debug)]
struct ProxyInstance {
server_id: String,
agent: AgentId,
runtime: Arc<AdapterRuntime>,
created_at_ms: i64,
}
#[derive(Debug)]
pub enum ProxyPostOutcome {
Response(Value),
Accepted,
}
#[derive(Debug, Clone)]
pub struct AcpServerInstanceInfo {
pub server_id: String,
pub agent: AgentId,
pub created_at_ms: i64,
}
pub type PinBoxSseStream =
std::pin::Pin<Box<dyn Stream<Item = Result<Event, std::convert::Infallible>> + Send>>;
impl AcpProxyRuntime {
pub fn new(agent_manager: Arc<AgentManager>) -> Self {
let require_preinstall = std::env::var("SANDBOX_AGENT_REQUIRE_PREINSTALL")
.ok()
.is_some_and(|value| {
let trimmed = value.trim();
trimmed == "1"
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
});
let request_timeout = duration_from_env_ms(
"SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS",
Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS),
);
Self {
inner: Arc::new(AcpProxyRuntimeInner {
agent_manager,
require_preinstall,
request_timeout,
instances: RwLock::new(HashMap::new()),
instance_locks: Mutex::new(HashMap::new()),
install_locks: Mutex::new(HashMap::new()),
}),
}
}
pub async fn list_instances(&self) -> Vec<AcpServerInstanceInfo> {
let mut infos = self
.inner
.instances
.read()
.await
.values()
.map(|instance| AcpServerInstanceInfo {
server_id: instance.server_id.clone(),
agent: instance.agent,
created_at_ms: instance.created_at_ms,
})
.collect::<Vec<_>>();
infos.sort_by(|left, right| left.server_id.cmp(&right.server_id));
infos
}
pub async fn post(
&self,
server_id: &str,
bootstrap_agent: Option<AgentId>,
payload: Value,
) -> Result<ProxyPostOutcome, SandboxError> {
let method: String = payload
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string();
let id: String = payload.get("id").map(|v| v.to_string()).unwrap_or_default();
tracing::info!(
server_id = server_id,
method = method,
id = %id,
bootstrap_agent = ?bootstrap_agent,
"acp_proxy: POST received"
);
let start = std::time::Instant::now();
let instance = self
.get_or_create_instance(server_id, bootstrap_agent)
.await?;
let instance_elapsed = start.elapsed();
tracing::debug!(
server_id = server_id,
agent = instance.agent.as_str(),
instance_ms = instance_elapsed.as_millis() as u64,
"acp_proxy: instance resolved"
);
match instance.runtime.post(payload).await {
Ok(PostOutcome::Response(value)) => {
let total_ms = start.elapsed().as_millis() as u64;
tracing::info!(
server_id = server_id,
method = method,
id = %id,
total_ms = total_ms,
"acp_proxy: POST → response"
);
let value = annotate_agent_error(instance.agent, value);
Ok(ProxyPostOutcome::Response(value))
}
Ok(PostOutcome::Accepted) => {
tracing::info!(
server_id = server_id,
method = method,
"acp_proxy: POST → accepted"
);
Ok(ProxyPostOutcome::Accepted)
}
Err(err) => {
let total_ms = start.elapsed().as_millis() as u64;
tracing::error!(
server_id = server_id,
method = method,
id = %id,
total_ms = total_ms,
error = %err,
"acp_proxy: POST → error"
);
Err(map_adapter_error(err))
}
}
}
pub async fn sse(
&self,
server_id: &str,
last_event_id: Option<u64>,
) -> Result<PinBoxSseStream, SandboxError> {
let instance = self.get_instance(server_id).await?;
let stream = instance.runtime.clone().sse_stream(last_event_id).await;
Ok(Box::pin(stream))
}
pub async fn delete(&self, server_id: &str) -> Result<(), SandboxError> {
let removed = self.inner.instances.write().await.remove(server_id);
if let Some(instance) = removed {
instance.runtime.shutdown().await;
}
Ok(())
}
pub async fn shutdown_all(&self) {
let instances = {
let mut guard = self.inner.instances.write().await;
guard
.drain()
.map(|(_, instance)| instance)
.collect::<Vec<_>>()
};
for instance in instances {
instance.runtime.shutdown().await;
}
}
async fn get_instance(&self, server_id: &str) -> Result<Arc<ProxyInstance>, SandboxError> {
self.inner
.instances
.read()
.await
.get(server_id)
.cloned()
.ok_or_else(|| SandboxError::SessionNotFound {
session_id: server_id.to_string(),
})
}
async fn get_or_create_instance(
&self,
server_id: &str,
bootstrap_agent: Option<AgentId>,
) -> Result<Arc<ProxyInstance>, SandboxError> {
if let Some(existing) = self.inner.instances.read().await.get(server_id).cloned() {
if let Some(agent) = bootstrap_agent {
if agent != existing.agent {
return Err(SandboxError::Conflict {
message: format!(
"server '{server_id}' already exists for agent '{}'; requested '{agent}'",
existing.agent.as_str()
),
});
}
}
return Ok(existing);
}
let lock = {
let mut locks = self.inner.instance_locks.lock().await;
locks
.entry(server_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _guard = lock.lock().await;
if let Some(existing) = self.inner.instances.read().await.get(server_id).cloned() {
if let Some(agent) = bootstrap_agent {
if agent != existing.agent {
return Err(SandboxError::Conflict {
message: format!(
"server '{server_id}' already exists for agent '{}'; requested '{agent}'",
existing.agent.as_str()
),
});
}
}
return Ok(existing);
}
let agent = bootstrap_agent.ok_or_else(|| SandboxError::InvalidRequest {
message: format!(
"missing required 'agent' query parameter for first POST to /v1/acp/{server_id}"
),
})?;
let created = self.create_instance(server_id, agent).await?;
self.inner
.instances
.write()
.await
.insert(server_id.to_string(), created.clone());
Ok(created)
}
async fn create_instance(
&self,
server_id: &str,
agent: AgentId,
) -> Result<Arc<ProxyInstance>, SandboxError> {
let start = std::time::Instant::now();
tracing::info!(
server_id = server_id,
agent = agent.as_str(),
"create_instance: starting"
);
self.ensure_installed(agent).await?;
let install_elapsed = start.elapsed();
tracing::info!(
server_id = server_id,
agent = agent.as_str(),
install_ms = install_elapsed.as_millis() as u64,
"create_instance: agent installed/verified"
);
let manager = self.inner.agent_manager.clone();
let launch = tokio::task::spawn_blocking(move || manager.resolve_agent_process(agent))
.await
.map_err(|err| SandboxError::StreamError {
message: format!("failed to resolve ACP agent process launch spec: {err}"),
})?
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
tracing::info!(
server_id = server_id,
agent = agent.as_str(),
program = ?launch.program,
args = ?launch.args,
resolve_ms = start.elapsed().as_millis() as u64,
"create_instance: launch spec resolved, spawning"
);
let runtime = AdapterRuntime::start(
LaunchSpec {
program: launch.program,
args: launch.args,
env: launch.env,
},
self.inner.request_timeout,
)
.await
.map_err(map_adapter_error)?;
let total_ms = start.elapsed().as_millis() as u64;
tracing::info!(
server_id = server_id,
agent = agent.as_str(),
total_ms = total_ms,
"create_instance: ready"
);
Ok(Arc::new(ProxyInstance {
server_id: server_id.to_string(),
agent,
runtime: Arc::new(runtime),
created_at_ms: now_ms(),
}))
}
async fn ensure_installed(&self, agent: AgentId) -> Result<(), SandboxError> {
if self.inner.require_preinstall {
if !self.is_ready(agent).await {
return Err(SandboxError::AgentNotInstalled {
agent: agent.as_str().to_string(),
});
}
return Ok(());
}
if self.is_ready(agent).await {
return Ok(());
}
let lock = {
let mut locks = self.inner.install_locks.lock().await;
locks
.entry(agent)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _guard = lock.lock().await;
if self.is_ready(agent).await {
return Ok(());
}
let manager = self.inner.agent_manager.clone();
tokio::task::spawn_blocking(move || manager.install(agent, InstallOptions::default()))
.await
.map_err(|err| SandboxError::InstallFailed {
agent: agent.as_str().to_string(),
stderr: Some(format!("installer task failed: {err}")),
})?
.map_err(|err| SandboxError::InstallFailed {
agent: agent.as_str().to_string(),
stderr: Some(err.to_string()),
})?;
Ok(())
}
async fn is_ready(&self, agent: AgentId) -> bool {
if agent == AgentId::Mock {
return self.inner.agent_manager.agent_process_path(agent).exists();
}
self.inner.agent_manager.is_installed(agent)
}
}
impl AcpDispatch for AcpProxyRuntime {
fn post(
&self,
server_id: &str,
bootstrap_agent: Option<&str>,
payload: Value,
) -> Pin<Box<dyn Future<Output = Result<AcpDispatchResult, String>> + Send + '_>> {
let server_id = server_id.to_string();
let agent = bootstrap_agent.and_then(AgentId::parse);
Box::pin(async move {
match self.post(&server_id, agent, payload).await {
Ok(ProxyPostOutcome::Response(value)) => Ok(AcpDispatchResult::Response(value)),
Ok(ProxyPostOutcome::Accepted) => Ok(AcpDispatchResult::Accepted),
Err(err) => Err(err.to_string()),
}
})
}
fn notification_stream(
&self,
server_id: &str,
last_event_id: Option<u64>,
) -> Pin<Box<dyn Future<Output = Result<AcpPayloadStream, String>> + Send + '_>> {
let server_id = server_id.to_string();
Box::pin(async move {
let instance = self
.get_instance(&server_id)
.await
.map_err(|e| e.to_string())?;
let stream = instance.runtime.clone().value_stream(last_event_id).await;
Ok(Box::pin(stream) as AcpPayloadStream)
})
}
fn delete(
&self,
server_id: &str,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
let server_id = server_id.to_string();
Box::pin(async move { self.delete(&server_id).await.map_err(|err| err.to_string()) })
}
}
fn map_adapter_error(err: AdapterError) -> SandboxError {
match err {
AdapterError::InvalidEnvelope => SandboxError::InvalidRequest {
message: "request body must be a JSON-RPC object".to_string(),
},
AdapterError::Timeout => SandboxError::Timeout {
message: Some("timed out waiting for agent response".to_string()),
},
AdapterError::Serialize(error) => SandboxError::InvalidRequest {
message: format!("failed to serialize JSON payload: {error}"),
},
AdapterError::Write(error) => SandboxError::StreamError {
message: format!("failed writing to agent stdin: {error}"),
},
AdapterError::Spawn(error) => SandboxError::StreamError {
message: format!("failed to start agent process: {error}"),
},
AdapterError::MissingStdin | AdapterError::MissingStdout | AdapterError::MissingStderr => {
SandboxError::StreamError {
message: "agent subprocess pipes were not available".to_string(),
}
}
}
}
/// Inspect JSON-RPC error responses from agent processes and add helpful hints
/// when we can infer the root cause from a known error pattern.
fn annotate_agent_error(agent: AgentId, mut value: Value) -> Value {
if agent != AgentId::Pi {
return value;
}
let matches = value
.pointer("/error/data/details")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("Cannot call write after a stream was destroyed"));
if matches {
if let Some(data) = value.pointer_mut("/error/data") {
if let Some(obj) = data.as_object_mut() {
obj.insert(
"hint".to_string(),
Value::String(
"The pi CLI exited immediately — this usually means no API key is \
configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, \
or another supported provider key."
.to_string(),
),
);
}
}
}
value
}
fn duration_from_env_ms(key: &str, default: Duration) -> Duration {
match std::env::var(key) {
Ok(raw) => raw
.trim()
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.map(Duration::from_millis)
.unwrap_or(default),
Err(_) => default,
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}

View file

@ -1,330 +0,0 @@
use super::*;
impl SharedAgentBackend {
pub(super) fn new_mock(agent: AgentId) -> Arc<Self> {
Arc::new(Self {
agent,
sender: BackendSender::Mock(new_mock_backend()),
pending_client_responses: Mutex::new(HashMap::new()),
})
}
pub(super) async fn new_process(
agent: AgentId,
launch: AgentProcessLaunchSpec,
runtime: Arc<AcpRuntimeInner>,
) -> Result<Arc<Self>, SandboxError> {
let mut command = Command::new(&launch.program);
command
.args(&launch.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (key, value) in &launch.env {
command.env(key, value);
}
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
message: format!(
"failed to start ACP agent process {}: {err}",
launch.program.display()
),
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| SandboxError::StreamError {
message: "failed to capture ACP agent process stdin".to_string(),
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| SandboxError::StreamError {
message: "failed to capture ACP agent process stdout".to_string(),
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| SandboxError::StreamError {
message: "failed to capture ACP agent process stderr".to_string(),
})?;
let process = ProcessBackend {
stdin: Arc::new(Mutex::new(stdin)),
child: Arc::new(Mutex::new(child)),
stderr_capture: Arc::new(Mutex::new(StderrCapture::default())),
terminate_requested: Arc::new(AtomicBool::new(false)),
};
let backend = Arc::new(Self {
agent,
sender: BackendSender::Process(process.clone()),
pending_client_responses: Mutex::new(HashMap::new()),
});
backend.start_process_pumps(runtime, stdout, stderr, process);
Ok(backend)
}
pub(super) async fn is_alive(&self) -> bool {
match &self.sender {
BackendSender::Mock(_) => true,
BackendSender::Process(process) => process.is_alive().await,
}
}
pub(super) fn start_process_pumps(
self: &Arc<Self>,
runtime: Arc<AcpRuntimeInner>,
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
process: ProcessBackend,
) {
let backend = self.clone();
let runtime_stdout = runtime.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let message = match serde_json::from_str::<Value>(&line) {
Ok(message) => message,
Err(err) => json!({
"jsonrpc": "2.0",
"method": AGENT_UNPARSED_METHOD,
"params": {
"error": err.to_string(),
"raw": line,
},
}),
};
runtime_stdout
.handle_backend_message(backend.agent, message)
.await;
}
});
let backend = self.clone();
let stderr_capture = process.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
stderr_capture.record_stderr_line(line.clone()).await;
tracing::debug!(
agent = %backend.agent,
"ACP agent process stderr: {}",
line
);
}
});
let backend = self.clone();
let runtime_exit = runtime.clone();
tokio::spawn(async move {
loop {
let probe = {
let mut child = process.child.lock().await;
match child.try_wait() {
Ok(Some(status)) => Ok(Some(status)),
Ok(None) => Ok(None),
Err(err) => Err(err.to_string()),
}
};
match probe {
Ok(Some(status)) => {
runtime_exit
.remove_backend_if_same(backend.agent, &backend)
.await;
runtime_exit
.handle_backend_process_exit(
backend.agent,
Some(status),
process.terminated_by(),
process.stderr_output().await,
)
.await;
break;
}
Ok(None) => {
tokio::time::sleep(Duration::from_millis(200)).await;
}
Err(err) => {
runtime_exit
.remove_backend_if_same(backend.agent, &backend)
.await;
runtime_exit
.mark_backend_stopped(
backend.agent,
Some(format!("failed to poll ACP agent process status: {err}")),
)
.await;
break;
}
}
}
});
}
pub(super) async fn send(
&self,
runtime: Arc<AcpRuntimeInner>,
payload: Value,
) -> Result<(), SandboxError> {
match &self.sender {
BackendSender::Process(process) => {
let mut stdin = process.stdin.lock().await;
let encoded =
serde_json::to_vec(&payload).map_err(|err| SandboxError::InvalidRequest {
message: format!("failed to serialize JSON-RPC payload: {err}"),
})?;
if let Err(err) = stdin.write_all(&encoded).await {
let message = format!("failed to write to ACP agent process stdin: {err}");
runtime
.mark_backend_stopped(self.agent, Some(message.clone()))
.await;
return Err(SandboxError::StreamError { message });
}
if let Err(err) = stdin.write_all(b"\n").await {
let message =
format!("failed to write line delimiter to ACP agent process stdin: {err}");
runtime
.mark_backend_stopped(self.agent, Some(message.clone()))
.await;
return Err(SandboxError::StreamError { message });
}
if let Err(err) = stdin.flush().await {
let message = format!("failed to flush ACP agent process stdin: {err}");
runtime
.mark_backend_stopped(self.agent, Some(message.clone()))
.await;
return Err(SandboxError::StreamError { message });
}
Ok(())
}
BackendSender::Mock(mock) => {
let agent = self.agent;
Box::pin(handle_mock_payload(mock, &payload, |message| {
let runtime = runtime.clone();
async move {
runtime.handle_backend_message(agent, message).await;
}
}))
.await
}
}
}
pub(super) async fn shutdown(&self, grace: Duration) {
if let BackendSender::Process(process) = &self.sender {
process.terminate_requested.store(true, Ordering::SeqCst);
tokio::time::sleep(grace).await;
let mut child = process.child.lock().await;
match child.try_wait() {
Ok(Some(_)) => {}
Ok(None) => {
let _ = child.kill().await;
let _ = child.wait().await;
}
Err(_) => {
let _ = child.kill().await;
}
}
}
}
}
impl ProcessBackend {
pub(super) async fn record_stderr_line(&self, line: String) {
self.stderr_capture.lock().await.record(line);
}
pub(super) async fn stderr_output(&self) -> Option<StderrOutput> {
self.stderr_capture.lock().await.snapshot()
}
pub(super) async fn is_alive(&self) -> bool {
let mut child = self.child.lock().await;
matches!(child.try_wait(), Ok(None))
}
pub(super) fn terminated_by(&self) -> TerminatedBy {
if self.terminate_requested.load(Ordering::SeqCst) {
TerminatedBy::Daemon
} else {
TerminatedBy::Agent
}
}
}
impl AcpClient {
pub(super) fn new(id: String, default_agent: AgentId) -> Arc<Self> {
let (sender, _rx) = broadcast::channel(512);
Arc::new(Self {
id,
default_agent,
seq: AtomicU64::new(0),
closed: AtomicBool::new(false),
sse_stream_active: Arc::new(AtomicBool::new(false)),
sender,
ring: Mutex::new(VecDeque::with_capacity(RING_BUFFER_SIZE)),
pending: Mutex::new(HashMap::new()),
})
}
pub(super) async fn push_stream(&self, payload: Value) {
let sequence = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
let message = StreamMessage { sequence, payload };
{
let mut ring = self.ring.lock().await;
ring.push_back(message.clone());
while ring.len() > RING_BUFFER_SIZE {
ring.pop_front();
}
}
let _ = self.sender.send(message);
}
pub(super) async fn subscribe(
&self,
last_event_id: Option<u64>,
) -> (Vec<StreamMessage>, broadcast::Receiver<StreamMessage>) {
let replay = {
let ring = self.ring.lock().await;
ring.iter()
.filter(|message| {
if let Some(last_event_id) = last_event_id {
message.sequence > last_event_id
} else {
true
}
})
.cloned()
.collect::<Vec<_>>()
};
(replay, self.sender.subscribe())
}
pub(super) fn try_claim_sse_stream(&self) -> bool {
self.sse_stream_active
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
pub(super) fn sse_active_flag(&self) -> Arc<AtomicBool> {
self.sse_stream_active.clone()
}
pub(super) async fn close(&self) {
if self.closed.swap(true, Ordering::SeqCst) {
return;
}
self.sse_stream_active.store(false, Ordering::SeqCst);
self.pending.lock().await.clear();
}
}

View file

@ -1,123 +0,0 @@
use super::*;
// Canonical extension namespace used for ACP _meta values.
pub(super) const SANDBOX_META_KEY: &str = "sandboxagent.dev";
// _meta[sandboxagent.dev].extensions key in initialize response.
pub(super) const EXTENSIONS_META_KEY: &str = "extensions";
// _meta[sandboxagent.dev].extensions.sessionDetach => method _sandboxagent/session/detach
pub(super) const EXTENSION_KEY_SESSION_DETACH: &str = "sessionDetach";
// _meta[sandboxagent.dev].extensions.sessionTerminate => method _sandboxagent/session/terminate
pub(super) const EXTENSION_KEY_SESSION_TERMINATE: &str = "sessionTerminate";
// _meta[sandboxagent.dev].extensions.sessionEndedNotification => method _sandboxagent/session/ended
pub(super) const EXTENSION_KEY_SESSION_ENDED_NOTIFICATION: &str = "sessionEndedNotification";
// _meta[sandboxagent.dev].extensions.sessionListModels => method _sandboxagent/session/list_models
pub(super) const EXTENSION_KEY_SESSION_LIST_MODELS: &str = "sessionListModels";
// _meta[sandboxagent.dev].extensions.sessionSetMetadata => method _sandboxagent/session/set_metadata
pub(super) const EXTENSION_KEY_SESSION_SET_METADATA: &str = "sessionSetMetadata";
// _meta[sandboxagent.dev].extensions.sessionAgentMeta => session/new + initialize require _meta[sandboxagent.dev].agent
pub(super) const EXTENSION_KEY_SESSION_AGENT_META: &str = "sessionAgentMeta";
// _meta[sandboxagent.dev].extensions.agentList => method _sandboxagent/agent/list
pub(super) const EXTENSION_KEY_AGENT_LIST: &str = "agentList";
// _meta[sandboxagent.dev].extensions.agentInstall => method _sandboxagent/agent/install
pub(super) const EXTENSION_KEY_AGENT_INSTALL: &str = "agentInstall";
// _meta[sandboxagent.dev].extensions.sessionList => method _sandboxagent/session/list
pub(super) const EXTENSION_KEY_SESSION_LIST: &str = "sessionList";
// _meta[sandboxagent.dev].extensions.sessionGet => method _sandboxagent/session/get
pub(super) const EXTENSION_KEY_SESSION_GET: &str = "sessionGet";
// _meta[sandboxagent.dev].extensions.fsListEntries => method _sandboxagent/fs/list_entries
pub(super) const EXTENSION_KEY_FS_LIST_ENTRIES: &str = "fsListEntries";
// _meta[sandboxagent.dev].extensions.fsReadFile => method _sandboxagent/fs/read_file
pub(super) const EXTENSION_KEY_FS_READ_FILE: &str = "fsReadFile";
// _meta[sandboxagent.dev].extensions.fsWriteFile => method _sandboxagent/fs/write_file
pub(super) const EXTENSION_KEY_FS_WRITE_FILE: &str = "fsWriteFile";
// _meta[sandboxagent.dev].extensions.fsDeleteEntry => method _sandboxagent/fs/delete_entry
pub(super) const EXTENSION_KEY_FS_DELETE_ENTRY: &str = "fsDeleteEntry";
// _meta[sandboxagent.dev].extensions.fsMkdir => method _sandboxagent/fs/mkdir
pub(super) const EXTENSION_KEY_FS_MKDIR: &str = "fsMkdir";
// _meta[sandboxagent.dev].extensions.fsMove => method _sandboxagent/fs/move
pub(super) const EXTENSION_KEY_FS_MOVE: &str = "fsMove";
// _meta[sandboxagent.dev].extensions.fsStat => method _sandboxagent/fs/stat
pub(super) const EXTENSION_KEY_FS_STAT: &str = "fsStat";
// _meta[sandboxagent.dev].extensions.fsUploadBatch => method _sandboxagent/fs/upload_batch
pub(super) const EXTENSION_KEY_FS_UPLOAD_BATCH: &str = "fsUploadBatch";
// _meta[sandboxagent.dev].extensions.methods => list of supported extension methods
pub(super) const EXTENSION_KEY_METHODS: &str = "methods";
pub(super) fn extract_sandbox_session_meta(payload: &Value) -> Option<Map<String, Value>> {
payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("_meta"))
.and_then(Value::as_object)
.and_then(|meta| meta.get(SANDBOX_META_KEY))
.and_then(Value::as_object)
.cloned()
}
pub(super) fn inject_extension_capabilities(payload: &mut Value) {
let Some(result) = payload.get_mut("result").and_then(Value::as_object_mut) else {
return;
};
let Some(agent_capabilities) = result
.get_mut("agentCapabilities")
.and_then(Value::as_object_mut)
else {
return;
};
let meta = agent_capabilities
.entry("_meta".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let Some(meta_object) = meta.as_object_mut() else {
return;
};
let sandbox = meta_object
.entry(SANDBOX_META_KEY.to_string())
.or_insert_with(|| Value::Object(Map::new()));
let Some(sandbox_object) = sandbox.as_object_mut() else {
return;
};
sandbox_object.insert(
EXTENSIONS_META_KEY.to_string(),
json!({
EXTENSION_KEY_SESSION_DETACH: true,
EXTENSION_KEY_SESSION_TERMINATE: true,
EXTENSION_KEY_SESSION_ENDED_NOTIFICATION: true,
EXTENSION_KEY_SESSION_LIST_MODELS: true,
EXTENSION_KEY_SESSION_SET_METADATA: true,
EXTENSION_KEY_SESSION_AGENT_META: true,
EXTENSION_KEY_AGENT_LIST: true,
EXTENSION_KEY_AGENT_INSTALL: true,
EXTENSION_KEY_SESSION_LIST: true,
EXTENSION_KEY_SESSION_GET: true,
EXTENSION_KEY_FS_LIST_ENTRIES: true,
EXTENSION_KEY_FS_READ_FILE: true,
EXTENSION_KEY_FS_WRITE_FILE: true,
EXTENSION_KEY_FS_DELETE_ENTRY: true,
EXTENSION_KEY_FS_MKDIR: true,
EXTENSION_KEY_FS_MOVE: true,
EXTENSION_KEY_FS_STAT: true,
EXTENSION_KEY_FS_UPLOAD_BATCH: true,
EXTENSION_KEY_METHODS: [
SESSION_DETACH_METHOD,
SESSION_TERMINATE_METHOD,
SESSION_ENDED_METHOD,
SESSION_LIST_MODELS_METHOD,
SESSION_SET_METADATA_METHOD,
AGENT_LIST_METHOD,
AGENT_INSTALL_METHOD,
SESSION_LIST_METHOD,
SESSION_GET_METHOD,
FS_LIST_ENTRIES_METHOD,
FS_READ_FILE_METHOD,
FS_WRITE_FILE_METHOD,
FS_DELETE_ENTRY_METHOD,
FS_MKDIR_METHOD,
FS_MOVE_METHOD,
FS_STAT_METHOD,
FS_UPLOAD_BATCH_METHOD,
]
}),
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,573 +0,0 @@
use super::*;
pub(super) fn validate_jsonrpc_envelope(payload: &Value) -> Result<(), SandboxError> {
let object = payload
.as_object()
.ok_or_else(|| SandboxError::InvalidRequest {
message: "JSON-RPC payload must be an object".to_string(),
})?;
let Some(jsonrpc) = object.get("jsonrpc").and_then(Value::as_str) else {
return Err(SandboxError::InvalidRequest {
message: "JSON-RPC payload must include jsonrpc field".to_string(),
});
};
if jsonrpc != "2.0" {
return Err(SandboxError::InvalidRequest {
message: "jsonrpc must be '2.0'".to_string(),
});
}
let has_method = object.get("method").is_some();
let has_id = object.get("id").is_some();
let has_result_or_error = object.get("result").is_some() || object.get("error").is_some();
if !has_method && !has_id {
return Err(SandboxError::InvalidRequest {
message: "JSON-RPC payload must include either method or id".to_string(),
});
}
if has_method && has_result_or_error {
return Err(SandboxError::InvalidRequest {
message: "JSON-RPC request/notification must not include result or error".to_string(),
});
}
Ok(())
}
pub(super) fn required_sandbox_agent_meta(
payload: &Value,
method: &str,
) -> Result<AgentId, SandboxError> {
let Some(agent) = payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("_meta"))
.and_then(Value::as_object)
.and_then(|meta| meta.get(SANDBOX_META_KEY))
.and_then(Value::as_object)
.and_then(|sandbox| sandbox.get("agent"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(SandboxError::InvalidRequest {
message: format!("{method} requires params._meta[\"{SANDBOX_META_KEY}\"].agent"),
});
};
AgentId::parse(agent).ok_or_else(|| SandboxError::UnsupportedAgent {
agent: agent.to_string(),
})
}
pub(super) fn explicit_agent_param(payload: &Value) -> Result<Option<AgentId>, SandboxError> {
let Some(agent_value) = payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("agent"))
else {
return Ok(None);
};
let Some(agent_name) = agent_value.as_str() else {
return Err(SandboxError::InvalidRequest {
message: "params.agent must be a string".to_string(),
});
};
let agent_name = agent_name.trim();
if agent_name.is_empty() {
return Err(SandboxError::InvalidRequest {
message: "params.agent must be non-empty".to_string(),
});
}
AgentId::parse(agent_name)
.map(Some)
.ok_or_else(|| SandboxError::UnsupportedAgent {
agent: agent_name.to_string(),
})
}
pub(super) fn to_sse_event(message: StreamMessage) -> Event {
let data = serde_json::to_string(&message.payload).unwrap_or_else(|_| "{}".to_string());
Event::default()
.event("message")
.id(message.sequence.to_string())
.data(data)
}
pub(super) fn message_id_key(id: &Value) -> String {
serde_json::to_string(id).unwrap_or_else(|_| "null".to_string())
}
pub(super) fn set_payload_id(payload: &mut Value, id: Value) {
if let Some(object) = payload.as_object_mut() {
object.insert("id".to_string(), id);
}
}
pub(super) fn extract_session_id_from_payload(payload: &Value) -> Option<String> {
payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("sessionId"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn extract_session_id_from_response(payload: &Value) -> Option<String> {
payload
.get("result")
.and_then(Value::as_object)
.and_then(|result| result.get("sessionId"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn extract_cwd_from_payload(payload: &Value) -> Option<String> {
payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("cwd"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn extract_model_id_from_payload(payload: &Value) -> Option<String> {
payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("modelId"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn extract_mode_id_from_payload(payload: &Value) -> Option<String> {
payload
.get("params")
.and_then(Value::as_object)
.and_then(|params| params.get("modeId"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn extract_models_from_response(response: &Value) -> Option<AgentModelSnapshot> {
let result = response.get("result")?.as_object()?;
let models_root = result
.get("models")
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(|| result.clone());
let available_models = models_root
.get("availableModels")
.and_then(Value::as_array)?
.iter()
.filter_map(|entry| {
let object = entry.as_object()?;
let model_id = object.get("modelId").and_then(Value::as_str)?.to_string();
let mut variants = object
.get("variants")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
variants.sort();
Some(AgentModelInfo {
model_id,
name: object
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: object
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
default_variant: object
.get("defaultVariant")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
variants,
})
})
.collect::<Vec<_>>();
let current_model_id = models_root
.get("currentModelId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
available_models
.first()
.map(|entry| entry.model_id.to_string())
});
Some(AgentModelSnapshot {
available_models,
current_model_id,
})
}
pub(super) fn extract_modes_from_response(response: &Value) -> Option<AgentModeSnapshot> {
let result = response.get("result")?.as_object()?;
let modes_root = result
.get("modes")
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(|| result.clone());
let available_modes = modes_root
.get("availableModes")
.and_then(Value::as_array)?
.iter()
.filter_map(|entry| {
let object = entry.as_object()?;
let mode_id = object
.get("modeId")
.and_then(Value::as_str)
.or_else(|| object.get("id").and_then(Value::as_str))?
.to_string();
Some(AgentModeInfo {
mode_id,
name: object
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: object
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
})
.collect::<Vec<_>>();
let current_mode_id = modes_root
.get("currentModeId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
available_modes
.first()
.map(|entry| entry.mode_id.to_string())
});
Some(AgentModeSnapshot {
available_modes,
current_mode_id,
})
}
pub(super) fn fallback_models_for_agent(agent: AgentId) -> AgentModelSnapshot {
match agent {
// Copied from pre-ACP v1 fallback behavior in router.rs.
AgentId::Claude => AgentModelSnapshot {
available_models: vec![
AgentModelInfo {
model_id: "default".to_string(),
name: Some("Default (recommended)".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
},
AgentModelInfo {
model_id: "sonnet".to_string(),
name: Some("Sonnet".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
},
AgentModelInfo {
model_id: "opus".to_string(),
name: Some("Opus".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
},
AgentModelInfo {
model_id: "haiku".to_string(),
name: Some("Haiku".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
},
],
current_model_id: Some("default".to_string()),
},
AgentId::Amp => AgentModelSnapshot {
available_models: vec![AgentModelInfo {
model_id: "amp-default".to_string(),
name: Some("Amp Default".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
}],
current_model_id: Some("amp-default".to_string()),
},
AgentId::Mock => AgentModelSnapshot {
available_models: vec![AgentModelInfo {
model_id: "mock".to_string(),
name: Some("Mock".to_string()),
description: None,
default_variant: None,
variants: Vec::new(),
}],
current_model_id: Some("mock".to_string()),
},
AgentId::Codex | AgentId::Opencode => AgentModelSnapshot::default(),
}
}
pub(super) fn to_stream_error(
error: sandbox_agent_agent_management::agents::AgentError,
) -> SandboxError {
SandboxError::StreamError {
message: error.to_string(),
}
}
pub(super) fn duration_from_env_ms(var_name: &str, default: Duration) -> Duration {
std::env::var(var_name)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.map(Duration::from_millis)
.unwrap_or(default)
}
impl SessionEndReason {
fn as_str(self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Error => "error",
Self::Terminated => "terminated",
}
}
}
impl TerminatedBy {
fn as_str(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Daemon => "daemon",
}
}
}
impl StderrCapture {
pub(super) fn record(&mut self, line: String) {
self.total_lines = self.total_lines.saturating_add(1);
if self.full_if_small.len() < STDERR_HEAD_LINES + STDERR_TAIL_LINES {
self.full_if_small.push(line.clone());
}
if self.head.len() < STDERR_HEAD_LINES {
self.head.push(line.clone());
}
self.tail.push_back(line);
while self.tail.len() > STDERR_TAIL_LINES {
self.tail.pop_front();
}
}
pub(super) fn snapshot(&self) -> Option<StderrOutput> {
if self.total_lines == 0 {
return None;
}
let max_untruncated = STDERR_HEAD_LINES + STDERR_TAIL_LINES;
if self.total_lines <= max_untruncated {
let head = if self.full_if_small.is_empty() {
None
} else {
Some(self.full_if_small.join("\n"))
};
return Some(StderrOutput {
head,
tail: None,
truncated: false,
total_lines: Some(self.total_lines),
});
}
Some(StderrOutput {
head: if self.head.is_empty() {
None
} else {
Some(self.head.join("\n"))
},
tail: if self.tail.is_empty() {
None
} else {
Some(self.tail.iter().cloned().collect::<Vec<_>>().join("\n"))
},
truncated: true,
total_lines: Some(self.total_lines),
})
}
}
impl From<MetaSession> for SessionRuntimeInfo {
fn from(value: MetaSession) -> Self {
Self {
session_id: value.session_id,
created_at: value.created_at,
updated_at: value.updated_at_ms,
ended: value.ended,
event_count: value.event_count,
model_hint: value.model_hint,
mode_hint: value.mode_hint,
title: value.title,
cwd: value.cwd,
sandbox_meta: value.sandbox_meta,
agent: value.agent,
ended_data: value.ended_data,
}
}
}
impl From<AgentModelSnapshot> for RuntimeModelSnapshot {
fn from(value: AgentModelSnapshot) -> Self {
Self {
available_models: value
.available_models
.into_iter()
.map(|model| RuntimeModelInfo {
model_id: model.model_id,
name: model.name,
description: model.description,
})
.collect(),
current_model_id: value.current_model_id,
}
}
}
impl From<AgentModeSnapshot> for RuntimeModeSnapshot {
fn from(value: AgentModeSnapshot) -> Self {
Self {
available_modes: value
.available_modes
.into_iter()
.map(|mode| RuntimeModeInfo {
mode_id: mode.mode_id,
name: mode.name,
description: mode.description,
})
.collect(),
current_mode_id: value.current_mode_id,
}
}
}
pub(super) fn ended_data_to_value(data: &SessionEndedData) -> Value {
let mut output = Map::new();
output.insert(
"reason".to_string(),
Value::String(data.reason.as_str().to_string()),
);
output.insert(
"terminated_by".to_string(),
Value::String(data.terminated_by.as_str().to_string()),
);
if let Some(message) = &data.message {
output.insert("message".to_string(), Value::String(message.clone()));
}
if let Some(exit_code) = data.exit_code {
output.insert("exit_code".to_string(), Value::from(exit_code));
}
if let Some(stderr) = &data.stderr {
let mut stderr_value = Map::new();
if let Some(head) = &stderr.head {
stderr_value.insert("head".to_string(), Value::String(head.clone()));
}
if let Some(tail) = &stderr.tail {
stderr_value.insert("tail".to_string(), Value::String(tail.clone()));
}
stderr_value.insert("truncated".to_string(), Value::Bool(stderr.truncated));
if let Some(total_lines) = stderr.total_lines {
stderr_value.insert("total_lines".to_string(), Value::from(total_lines as u64));
}
output.insert("stderr".to_string(), Value::Object(stderr_value));
}
Value::Object(output)
}
pub(super) fn ended_data_from_process_exit(
status: Option<ExitStatus>,
terminated_by: TerminatedBy,
stderr: Option<StderrOutput>,
) -> SessionEndedData {
if terminated_by == TerminatedBy::Daemon {
return SessionEndedData {
reason: SessionEndReason::Terminated,
terminated_by,
message: None,
exit_code: None,
stderr: None,
};
}
if status.as_ref().is_some_and(ExitStatus::success) {
return SessionEndedData {
reason: SessionEndReason::Completed,
terminated_by,
message: None,
exit_code: None,
stderr: None,
};
}
let message = status
.as_ref()
.map(|value| format!("agent exited with status {value}"))
.or_else(|| Some("agent exited".to_string()));
SessionEndedData {
reason: SessionEndReason::Error,
terminated_by,
message,
exit_code: status.and_then(|value| value.code()),
stderr,
}
}
pub(super) fn infer_base_url_from_launch(launch: &AgentProcessLaunchSpec) -> Option<String> {
for (key, value) in &launch.env {
if (key.contains("BASE_URL") || key.ends_with("_URL")) && is_http_url(value) {
return Some(value.clone());
}
}
for arg in &launch.args {
if let Some(value) = arg.strip_prefix("--base-url=") {
if is_http_url(value) {
return Some(value.to_string());
}
}
if let Some(value) = arg.strip_prefix("--base_url=") {
if is_http_url(value) {
return Some(value.to_string());
}
}
if let Some(value) = arg.strip_prefix("--url=") {
if is_http_url(value) {
return Some(value.to_string());
}
}
}
let mut args = launch.args.iter();
while let Some(arg) = args.next() {
if arg == "--base-url" || arg == "--base_url" || arg == "--url" {
if let Some(value) = args.next() {
if is_http_url(value) {
return Some(value.to_string());
}
}
}
}
None
}
pub(super) fn is_http_url(value: &str) -> bool {
value.starts_with("http://") || value.starts_with("https://")
}
pub(super) fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}

View file

@ -1,425 +0,0 @@
use sandbox_agent_error::SandboxError;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::future::Future;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
const MOCK_WORD_STREAM_DELAY_MS: u64 = 30;
#[derive(Debug)]
pub(super) struct MockBackend {
session_counter: Mutex<u64>,
permission_counter: Mutex<u64>,
sessions: Mutex<HashSet<String>>,
ended_sessions: Mutex<HashSet<String>>,
}
pub(super) fn new_mock_backend() -> MockBackend {
MockBackend {
session_counter: Mutex::new(0),
permission_counter: Mutex::new(0),
sessions: Mutex::new(HashSet::new()),
ended_sessions: Mutex::new(HashSet::new()),
}
}
pub(super) async fn handle_mock_payload<F, Fut>(
mock: &MockBackend,
payload: &Value,
mut emit: F,
) -> Result<(), SandboxError>
where
F: FnMut(Value) -> Fut,
Fut: Future<Output = ()>,
{
if let Some(method) = payload.get("method").and_then(Value::as_str) {
let id = payload.get("id").cloned();
let params = payload.get("params").cloned().unwrap_or(Value::Null);
if let Some(id_value) = id {
let response = mock_request(mock, &mut emit, id_value, method, params).await;
emit(response).await;
return Ok(());
}
mock_notification(&mut emit, method, params).await;
return Ok(());
}
Ok(())
}
async fn mock_request<F, Fut>(
mock: &MockBackend,
emit: &mut F,
id: Value,
method: &str,
params: Value,
) -> Value
where
F: FnMut(Value) -> Fut,
Fut: Future<Output = ()>,
{
match method {
"initialize" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": params
.get("protocolVersion")
.cloned()
.unwrap_or(Value::String("1.0".to_string())),
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": {
"image": false,
"audio": false
},
"canSetMode": true,
"canSetModel": true,
"sessionCapabilities": {
"list": {}
}
},
"authMethods": []
}
}),
"session/new" => {
let mut counter = mock.session_counter.lock().await;
*counter += 1;
let session_id = format!("mock-session-{}", *counter);
mock.sessions.lock().await.insert(session_id.clone());
mock.ended_sessions.lock().await.remove(&session_id);
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"sessionId": session_id,
"availableModes": [],
"configOptions": []
}
})
}
"session/prompt" => {
let known_session = {
let sessions = mock.sessions.lock().await;
sessions.iter().next().cloned()
};
let session_id = params
.get("sessionId")
.and_then(Value::as_str)
.map(ToString::to_string)
.or(known_session)
.unwrap_or_else(|| "mock-session-1".to_string());
mock.sessions.lock().await.insert(session_id.clone());
if mock.ended_sessions.lock().await.contains(&session_id) {
return json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32000,
"message": "session already ended"
}
});
}
let prompt_text = extract_prompt_text(&params);
let response_text = prompt_text
.clone()
.map(|text| {
if text.trim().is_empty() {
"OK".to_string()
} else {
format!("mock: {text}")
}
})
.unwrap_or_else(|| "OK".to_string());
let requires_permission = prompt_text
.as_deref()
.map(|text| text.to_ascii_lowercase().contains("permission"))
.unwrap_or(false);
if requires_permission {
let mut permission_counter = mock.permission_counter.lock().await;
*permission_counter += 1;
let permission_id = format!("mock-permission-{}", *permission_counter);
emit(json!({
"jsonrpc": "2.0",
"id": permission_id,
"method": "session/request_permission",
"params": {
"sessionId": session_id,
"options": [
{
"id": "allow_once",
"name": "Allow once"
},
{
"id": "deny",
"name": "Deny"
}
],
"toolCall": {
"toolCallId": "tool-call-1",
"kind": "execute",
"status": "pending",
"rawInput": {
"command": "echo test"
}
}
}
}))
.await;
}
let should_crash = prompt_text
.as_deref()
.map(|text| text.to_ascii_lowercase().contains("crash"))
.unwrap_or(false);
if should_crash {
mock.ended_sessions.lock().await.insert(session_id.clone());
emit(json!({
"jsonrpc": "2.0",
"method": "_sandboxagent/session/ended",
"params": {
"session_id": session_id,
"data": {
"reason": "error",
"terminated_by": "agent",
"message": "mock process crashed",
"exit_code": 1,
"stderr": {
"head": "mock stderr line 1\nmock stderr line 2",
"truncated": false,
"total_lines": 2
}
}
}
}))
.await;
return json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32000,
"message": "mock process crashed"
}
});
}
let word_chunks = split_text_into_word_chunks(&response_text);
for (index, chunk) in word_chunks.iter().enumerate() {
emit(json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": chunk
}
}
}
}))
.await;
if index + 1 < word_chunks.len() {
sleep(Duration::from_millis(MOCK_WORD_STREAM_DELAY_MS)).await;
}
}
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"stopReason": "end_turn"
}
})
}
"session/list" => {
let sessions = mock
.sessions
.lock()
.await
.iter()
.cloned()
.collect::<Vec<_>>();
let sessions = sessions
.into_iter()
.map(|session_id| {
json!({
"sessionId": session_id,
"cwd": "/"
})
})
.collect::<Vec<_>>();
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"sessions": sessions,
"nextCursor": null
}
})
}
"session/fork" | "session/resume" | "session/load" => {
let session_id = params
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or("mock-session-1")
.to_string();
mock.sessions.lock().await.insert(session_id.clone());
mock.ended_sessions.lock().await.remove(&session_id);
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"sessionId": session_id,
"configOptions": [],
"availableModes": []
}
})
}
"session/set_mode" | "session/set_model" | "session/set_config_option" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {}
}),
"authenticate" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {}
}),
"$/cancel_request" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {}
}),
"_sandboxagent/session/terminate" => {
let fallback_session = {
let sessions = mock.sessions.lock().await;
sessions.iter().next().cloned()
};
let session_id = params
.get("sessionId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or(fallback_session)
.unwrap_or_else(|| "mock-session-1".to_string());
let exists = mock.sessions.lock().await.contains(&session_id);
let mut ended_sessions = mock.ended_sessions.lock().await;
let terminated = exists && ended_sessions.insert(session_id.clone());
drop(ended_sessions);
if terminated {
emit(json!({
"jsonrpc": "2.0",
"method": "_sandboxagent/session/ended",
"params": {
"session_id": session_id,
"data": {
"reason": "terminated",
"terminated_by": "daemon"
}
}
}))
.await;
}
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"terminated": terminated,
"alreadyEnded": !terminated,
"reason": "terminated",
"terminatedBy": "daemon"
}
})
}
_ => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"_meta": {
"sandboxagent.dev": {
"mockMethod": method,
"echoParams": params
}
}
}
}),
}
}
async fn mock_notification<F, Fut>(emit: &mut F, method: &str, params: Value)
where
F: FnMut(Value) -> Fut,
Fut: Future<Output = ()>,
{
if method == "session/cancel" {
let session_id = params
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or("mock-session-1");
emit(json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "cancelled"
}
}
}
}))
.await;
}
}
fn split_text_into_word_chunks(text: &str) -> Vec<String> {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return vec![text.to_string()];
}
let last = words.len() - 1;
words
.into_iter()
.enumerate()
.map(|(index, word)| {
if index == last {
word.to_string()
} else {
format!("{word} ")
}
})
.collect()
}
fn extract_prompt_text(params: &Value) -> Option<String> {
let prompt = params.get("prompt")?.as_array()?;
let mut output = String::new();
for block in prompt {
if block.get("type").and_then(Value::as_str) == Some("text") {
if let Some(text) = block.get("text").and_then(Value::as_str) {
if !output.is_empty() {
output.push('\n');
}
output.push_str(text);
}
}
}
if output.is_empty() {
None
} else {
Some(output)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use clap::{Args, Parser, Subcommand};
@ -29,7 +30,7 @@ use thiserror::Error;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
const API_PREFIX: &str = "/v2";
const API_PREFIX: &str = "/v1";
const ACP_EXTENSION_AGENT_LIST_METHOD: &str = "_sandboxagent/agent/list";
const ACP_EXTENSION_AGENT_INSTALL_METHOD: &str = "_sandboxagent/agent/install";
const DEFAULT_HOST: &str = "127.0.0.1";
@ -194,7 +195,7 @@ pub struct DaemonStatusArgs {
#[derive(Subcommand, Debug)]
pub enum ApiCommand {
/// Manage available v2 agents and install status.
/// Manage available v1 agents and install status.
Agents(AgentsArgs),
/// Send and stream raw ACP JSON-RPC envelopes.
Acp(AcpArgs),
@ -231,11 +232,11 @@ pub struct AcpArgs {
#[derive(Subcommand, Debug)]
pub enum AcpCommand {
/// Send one ACP JSON-RPC envelope to /v2/rpc.
/// Send one ACP JSON-RPC envelope to /v1/acp/{server_id}.
Post(AcpPostArgs),
/// Stream ACP JSON-RPC envelopes from /v2/rpc SSE.
/// Stream ACP JSON-RPC envelopes from /v1/acp/{server_id} SSE.
Stream(AcpStreamArgs),
/// Close an ACP client.
/// Close an ACP server stream.
Close(AcpCloseArgs),
}
@ -260,22 +261,22 @@ pub struct ApiInstallAgentArgs {
#[derive(Args, Debug)]
pub struct AcpPostArgs {
#[arg(long = "client-id")]
client_id: Option<String>,
#[arg(long = "server-id")]
server_id: String,
#[arg(long = "agent")]
agent: Option<String>,
#[arg(long)]
json: Option<String>,
#[arg(long = "json-file")]
json_file: Option<PathBuf>,
#[arg(long, default_value_t = false)]
print_client_id: bool,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
pub struct AcpStreamArgs {
#[arg(long = "client-id")]
client_id: String,
#[arg(long = "server-id")]
server_id: String,
#[arg(long = "last-event-id")]
last_event_id: Option<u64>,
#[command(flatten)]
@ -284,8 +285,8 @@ pub struct AcpStreamArgs {
#[derive(Args, Debug)]
pub struct AcpCloseArgs {
#[arg(long = "client-id")]
client_id: String,
#[arg(long = "server-id")]
server_id: String,
#[command(flatten)]
client: ClientArgs,
}
@ -498,6 +499,10 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
}
fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> {
let server_id = unique_cli_server_id("cli-ext");
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
let request_path = build_acp_server_path(&server_id, None)?;
let initialize = json!({
"jsonrpc": "2.0",
"id": "cli-init",
@ -512,9 +517,13 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
}
}
});
let initialize_response =
ctx.post_with_headers(&format!("{API_PREFIX}/rpc"), &initialize, &[])?;
let connection_id = extract_connection_id(initialize_response)?;
let initialize_response = ctx.post(&initialize_path, &initialize)?;
let initialize_status = initialize_response.status();
let initialize_text = initialize_response.text()?;
if !initialize_status.is_success() {
print_error_body(&initialize_text)?;
return Err(CliError::HttpStatus(initialize_status));
}
let request = json!({
"jsonrpc": "2.0",
@ -522,16 +531,9 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
"method": method,
"params": params,
});
let response = ctx.post_with_headers(
&format!("{API_PREFIX}/rpc"),
&request,
&[("x-acp-connection-id", connection_id.as_str())],
);
let response = ctx.post(&request_path, &request);
let _ = ctx.delete_with_headers(
&format!("{API_PREFIX}/rpc"),
&[("x-acp-connection-id", connection_id.as_str())],
);
let _ = ctx.delete(&request_path);
let response = response?;
let status = response.status();
@ -553,57 +555,20 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
Ok(parsed.get("result").cloned().unwrap_or(Value::Null))
}
fn extract_connection_id(response: reqwest::blocking::Response) -> Result<String, CliError> {
let status = response.status();
let headers = response.headers().clone();
let text = response.text()?;
if !status.is_success() {
print_error_body(&text)?;
return Err(CliError::HttpStatus(status));
}
headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string())
.ok_or_else(|| {
CliError::Server("missing x-acp-connection-id in initialize response".to_string())
})
}
fn run_acp(command: &AcpCommand, cli: &CliConfig) -> Result<(), CliError> {
match command {
AcpCommand::Post(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let payload = load_json_payload(args.json.as_deref(), args.json_file.as_deref())?;
let mut headers = Vec::new();
if let Some(client_id) = args.client_id.as_deref() {
headers.push(("x-acp-connection-id", client_id));
}
let response =
ctx.post_with_headers(&format!("{API_PREFIX}/rpc"), &payload, &headers)?;
let client_id = response
.headers()
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
print_json_or_empty(response)?;
if args.print_client_id {
if let Some(client_id) = client_id {
write_stdout_line(&client_id)?;
}
}
Ok(())
let path = build_acp_server_path(&args.server_id, args.agent.as_deref())?;
let response = ctx.post(&path, &payload)?;
print_json_or_empty(response)
}
AcpCommand::Stream(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = build_acp_server_path(&args.server_id, None)?;
let request = ctx
.request(Method::GET, &format!("{API_PREFIX}/rpc"))
.header("x-acp-connection-id", args.client_id.as_str())
.request(Method::GET, &path)
.header("accept", "text/event-stream");
let request = apply_last_event_id_header(request, args.last_event_id);
@ -613,19 +578,58 @@ fn run_acp(command: &AcpCommand, cli: &CliConfig) -> Result<(), CliError> {
}
AcpCommand::Close(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let response = ctx.delete_with_headers(
&format!("{API_PREFIX}/rpc"),
&[("x-acp-connection-id", args.client_id.as_str())],
)?;
let path = build_acp_server_path(&args.server_id, None)?;
let response = ctx.delete(&path)?;
print_empty_response(response)
}
}
}
fn run_opencode(_cli: &CliConfig, _args: &OpencodeArgs) -> Result<(), CliError> {
Err(CliError::Server(
"/opencode is disabled during ACP core bring-up and will return in Phase 7".to_string(),
))
fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
let token = cli.token.as_deref();
crate::daemon::ensure_running(cli, &args.host, args.port, token)?;
let base_url = format!("http://{}:{}", args.host, args.port);
let attach_url = format!("{base_url}/opencode");
let mut attach_command = if let Ok(bin) = std::env::var("GIGACODE_OPENCODE_BIN") {
let mut cmd = ProcessCommand::new(bin);
cmd.arg("attach").arg(&attach_url);
cmd
} else {
let mut cmd = ProcessCommand::new("opencode");
cmd.arg("attach").arg(&attach_url);
cmd
};
let status = match attach_command.status() {
Ok(status) => status,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let mut fallback = ProcessCommand::new("npx");
fallback
.arg("--yes")
.arg("opencode-ai")
.arg("attach")
.arg(&attach_url);
fallback.status().map_err(|fallback_err| {
CliError::Server(format!(
"failed to launch opencode attach. Tried `opencode attach` and `npx --yes opencode-ai attach`. Last error: {fallback_err}"
))
})?
}
Err(err) => {
return Err(CliError::Server(format!(
"failed to launch opencode attach: {err}"
)));
}
};
if !status.success() {
return Err(CliError::Server(format!(
"opencode attach exited with status {status}"
)));
}
Ok(())
}
fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> {
@ -935,6 +939,43 @@ fn apply_last_event_id_header(
}
}
fn unique_cli_server_id(prefix: &str) -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{prefix}-{}-{millis}", std::process::id())
}
fn build_acp_server_path(
server_id: &str,
bootstrap_agent: Option<&str>,
) -> Result<String, CliError> {
let server_id = server_id.trim();
if server_id.is_empty() {
return Err(CliError::Server("server id must not be empty".to_string()));
}
if server_id.contains('/') {
return Err(CliError::Server(
"server id must not contain '/'".to_string(),
));
}
let mut path = format!("{API_PREFIX}/acp/{server_id}");
if let Some(agent) = bootstrap_agent {
let agent = agent.trim();
if agent.is_empty() {
return Err(CliError::Server(
"agent must not be empty when provided".to_string(),
));
}
path.push_str("?agent=");
path.push_str(agent);
}
Ok(path)
}
fn default_server_log_dir() -> PathBuf {
if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") {
return PathBuf::from(dir);
@ -1054,29 +1095,8 @@ impl ClientContext {
Ok(self.request(Method::POST, path).json(body).send()?)
}
fn post_with_headers<T: Serialize>(
&self,
path: &str,
body: &T,
headers: &[(&str, &str)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::POST, path).json(body);
for (name, value) in headers {
request = request.header(*name, *value);
}
Ok(request.send()?)
}
fn delete_with_headers(
&self,
path: &str,
headers: &[(&str, &str)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::DELETE, path);
for (name, value) in headers {
request = request.header(*name, *value);
}
Ok(request.send()?)
fn delete(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::DELETE, path).send()?)
}
}
@ -1179,9 +1199,10 @@ mod tests {
#[test]
fn apply_last_event_id_header_sets_header_when_provided() {
let client = HttpClient::builder().build().expect("build client");
let request = apply_last_event_id_header(client.get("http://localhost/v2/rpc"), Some(42))
.build()
.expect("build request");
let request =
apply_last_event_id_header(client.get("http://localhost/v1/acp/test"), Some(42))
.build()
.expect("build request");
let header = request
.headers()
@ -1193,7 +1214,7 @@ mod tests {
#[test]
fn apply_last_event_id_header_omits_header_when_absent() {
let client = HttpClient::builder().build().expect("build client");
let request = apply_last_event_id_header(client.get("http://localhost/v2/rpc"), None)
let request = apply_last_event_id_header(client.get("http://localhost/v1/acp/test"), None)
.build()
.expect("build request");
assert!(request.headers().get("last-event-id").is_none());

View file

@ -145,7 +145,7 @@ pub fn is_process_running(pid: u32) -> bool {
// ---------------------------------------------------------------------------
pub fn check_health(base_url: &str, token: Option<&str>) -> Result<bool, CliError> {
let url = format!("{base_url}/v2/health");
let url = format!("{base_url}/v1/health");
let started_at = Instant::now();
let client = HttpClient::builder()
.connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT)
@ -205,7 +205,7 @@ pub fn wait_for_health(
}
}
let url = format!("{base_url}/v2/health");
let url = format!("{base_url}/v1/health");
let mut request = client.get(&url);
if let Some(token) = token {
request = request.bearer_auth(token);

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,4 @@
use super::*;
pub(super) async fn v1_removed() -> Response {
let problem = ProblemDetails {
type_: "urn:sandbox-agent:error:v1_removed".to_string(),
title: "v1 API removed".to_string(),
status: 410,
detail: Some("v1 API removed; use /v2".to_string()),
instance: None,
extensions: serde_json::Map::new(),
};
(
StatusCode::GONE,
[(header::CONTENT_TYPE, "application/problem+json")],
Json(problem),
)
.into_response()
}
pub(super) async fn opencode_disabled() -> Response {
let problem = ProblemDetails {
type_: "urn:sandbox-agent:error:opencode_disabled".to_string(),
title: "OpenCode compatibility disabled".to_string(),
status: 503,
detail: Some(
"/opencode is disabled during ACP core bring-up and will return in Phase 7".to_string(),
),
instance: None,
extensions: serde_json::Map::new(),
};
(
StatusCode::SERVICE_UNAVAILABLE,
[(header::CONTENT_TYPE, "application/problem+json")],
Json(problem),
)
.into_response()
}
pub(super) async fn not_found() -> Response {
let problem = ProblemDetails {
@ -79,85 +42,7 @@ pub(super) async fn require_token(
}))
}
pub(super) type PinBoxSseStream =
std::pin::Pin<Box<dyn Stream<Item = Result<axum::response::sse::Event, Infallible>> + Send>>;
pub(super) fn map_runtime_session(session: crate::acp_runtime::SessionRuntimeInfo) -> SessionInfo {
SessionInfo {
session_id: session.session_id,
agent: session.agent.as_str().to_string(),
agent_mode: session
.mode_hint
.clone()
.unwrap_or_else(|| "build".to_string()),
permission_mode: session
.sandbox_meta
.get("permissionMode")
.or_else(|| session.sandbox_meta.get("permission_mode"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "default".to_string()),
model: session.model_hint,
native_session_id: session
.sandbox_meta
.get("nativeSessionId")
.or_else(|| session.sandbox_meta.get("native_session_id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ended: session.ended,
event_count: session.event_count,
created_at: session.created_at,
updated_at: session.updated_at,
directory: Some(session.cwd),
title: session.title,
termination_info: session.ended_data.map(map_termination_info),
}
}
pub(super) fn map_termination_info(ended: crate::acp_runtime::SessionEndedData) -> TerminationInfo {
let reason = match ended.reason {
crate::acp_runtime::SessionEndReason::Completed => "completed",
crate::acp_runtime::SessionEndReason::Error => "error",
crate::acp_runtime::SessionEndReason::Terminated => "terminated",
}
.to_string();
let terminated_by = match ended.terminated_by {
crate::acp_runtime::TerminatedBy::Agent => "agent",
crate::acp_runtime::TerminatedBy::Daemon => "daemon",
}
.to_string();
TerminationInfo {
reason,
terminated_by,
message: ended.message,
exit_code: ended.exit_code,
stderr: ended.stderr.map(|stderr| StderrOutput {
head: stderr.head,
tail: stderr.tail,
truncated: stderr.truncated,
total_lines: stderr.total_lines,
}),
}
}
pub(super) fn map_server_status(
status: &crate::acp_runtime::RuntimeServerStatus,
) -> ServerStatusInfo {
let server_status = if status.running {
ServerStatus::Running
} else if status.last_error.is_some() {
ServerStatus::Error
} else {
ServerStatus::Stopped
};
ServerStatusInfo {
status: server_status,
base_url: status.base_url.clone(),
uptime_ms: status.uptime_ms.map(|value| value.max(0) as u64),
restart_count: status.restart_count,
last_error: status.last_error.clone(),
}
}
pub(super) type PinBoxSseStream = crate::acp_proxy_runtime::PinBoxSseStream;
pub(super) fn credentials_available_for(
agent: AgentId,
@ -168,10 +53,149 @@ pub(super) fn credentials_available_for(
AgentId::Claude | AgentId::Amp => has_anthropic,
AgentId::Codex => has_openai,
AgentId::Opencode => has_anthropic || has_openai,
AgentId::Pi | AgentId::Cursor => true,
AgentId::Mock => true,
}
}
/// Fallback config options for agents whose ACP adapters don't return
/// `configOptions` in `session/new`. Loaded from committed JSON resource files
/// in `scripts/agent-configs/resources/` (generated by `scripts/agent-configs/dump.ts`).
///
/// To refresh: `cd scripts/agent-configs && npx tsx dump.ts`
pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
static CLAUDE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
parse_agent_config(include_str!(
"../../../../../scripts/agent-configs/resources/claude.json"
))
});
static CODEX: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
parse_agent_config(include_str!(
"../../../../../scripts/agent-configs/resources/codex.json"
))
});
static OPENCODE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
parse_agent_config(include_str!(
"../../../../../scripts/agent-configs/resources/opencode.json"
))
});
static CURSOR: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
parse_agent_config(include_str!(
"../../../../../scripts/agent-configs/resources/cursor.json"
))
});
match agent {
AgentId::Claude => CLAUDE.clone(),
AgentId::Codex => CODEX.clone(),
AgentId::Opencode => OPENCODE.clone(),
AgentId::Cursor => CURSOR.clone(),
AgentId::Amp => vec![
json!({
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "amp-default",
"options": [
{ "value": "amp-default", "name": "Amp Default" }
]
}),
json!({
"id": "mode",
"name": "Mode",
"category": "mode",
"type": "select",
"currentValue": "smart",
"options": [
{ "value": "smart", "name": "Smart" },
{ "value": "deep", "name": "Deep" },
{ "value": "free", "name": "Free" },
{ "value": "rush", "name": "Rush" }
]
}),
],
AgentId::Pi => vec![json!({
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "default",
"options": [
{ "value": "default", "name": "Default" }
]
})],
AgentId::Mock => vec![json!({
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "mock",
"options": [
{ "value": "mock", "name": "Mock" }
]
})],
}
}
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
/// ACP `SessionConfigOption` values. The JSON format is:
/// ```json
/// { "defaultModel": "...", "models": [{id, name}], "defaultMode?": "...", "modes?": [{id, name}] }
/// ```
fn parse_agent_config(json_str: &str) -> Vec<Value> {
#[derive(serde::Deserialize)]
struct AgentConfig {
#[serde(rename = "defaultModel")]
default_model: String,
models: Vec<ModelEntry>,
#[serde(rename = "defaultMode")]
default_mode: Option<String>,
modes: Option<Vec<ModeEntry>>,
}
#[derive(serde::Deserialize)]
struct ModelEntry {
id: String,
name: String,
}
#[derive(serde::Deserialize)]
struct ModeEntry {
id: String,
name: String,
}
let config: AgentConfig =
serde_json::from_str(json_str).expect("invalid agent config JSON (compile-time resource)");
let mut options = vec![json!({
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": config.default_model,
"options": config.models.iter().map(|m| json!({
"value": m.id,
"name": m.name,
})).collect::<Vec<_>>(),
})];
if let Some(modes) = config.modes {
options.push(json!({
"id": "mode",
"name": "Mode",
"category": "mode",
"type": "select",
"currentValue": config.default_mode.unwrap_or_else(|| modes[0].id.clone()),
"options": modes.iter().map(|m| json!({
"value": m.id,
"name": m.name,
})).collect::<Vec<_>>(),
}));
}
options
}
pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
match agent {
AgentId::Claude => AgentCapabilities {
@ -212,7 +236,7 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
mcp_tools: true,
streaming_deltas: true,
item_started: true,
shared_process: true,
shared_process: false,
},
AgentId::Opencode => AgentCapabilities {
plan_mode: false,
@ -232,7 +256,7 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
mcp_tools: true,
streaming_deltas: true,
item_started: true,
shared_process: true,
shared_process: false,
},
AgentId::Amp => AgentCapabilities {
plan_mode: false,
@ -254,6 +278,46 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
item_started: false,
shared_process: false,
},
AgentId::Pi => AgentCapabilities {
plan_mode: false,
permissions: false,
questions: false,
tool_calls: true,
tool_results: true,
text_messages: true,
images: true,
file_attachments: false,
session_lifecycle: true,
error_events: true,
reasoning: false,
status: false,
command_execution: false,
file_changes: false,
mcp_tools: false,
streaming_deltas: true,
item_started: true,
shared_process: false,
},
AgentId::Cursor => AgentCapabilities {
plan_mode: true,
permissions: true,
questions: false,
tool_calls: true,
tool_results: true,
text_messages: true,
images: true,
file_attachments: false,
session_lifecycle: true,
error_events: true,
reasoning: false,
status: false,
command_execution: false,
file_changes: false,
mcp_tools: false,
streaming_deltas: true,
item_started: true,
shared_process: false,
},
AgentId::Mock => AgentCapabilities {
plan_mode: true,
permissions: true,
@ -277,124 +341,6 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
}
}
pub(super) fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> {
match agent {
AgentId::Opencode => vec![
AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Default build mode".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Planning mode".to_string(),
},
AgentModeInfo {
id: "custom".to_string(),
name: "Custom".to_string(),
description: "Any user-defined OpenCode agent name".to_string(),
},
],
AgentId::Codex => vec![
AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Default build mode".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Planning mode via prompt prefix".to_string(),
},
],
AgentId::Claude => vec![
AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Default build mode".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Plan mode (prompt-only)".to_string(),
},
],
AgentId::Amp => vec![AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Default build mode".to_string(),
}],
AgentId::Mock => vec![
AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Mock agent for UI testing".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Plan-only mock mode".to_string(),
},
],
}
}
pub(super) fn fallback_models_for_agent(
agent: AgentId,
) -> Option<(Vec<AgentModelInfo>, Option<String>)> {
match agent {
AgentId::Claude => Some((
vec![
AgentModelInfo {
id: "default".to_string(),
name: Some("Default (recommended)".to_string()),
variants: None,
default_variant: None,
},
AgentModelInfo {
id: "sonnet".to_string(),
name: Some("Sonnet".to_string()),
variants: None,
default_variant: None,
},
AgentModelInfo {
id: "opus".to_string(),
name: Some("Opus".to_string()),
variants: None,
default_variant: None,
},
AgentModelInfo {
id: "haiku".to_string(),
name: Some("Haiku".to_string()),
variants: None,
default_variant: None,
},
],
Some("default".to_string()),
)),
AgentId::Amp => Some((
vec![AgentModelInfo {
id: "amp-default".to_string(),
name: Some("Amp Default".to_string()),
variants: None,
default_variant: None,
}],
Some("amp-default".to_string()),
)),
AgentId::Mock => Some((
vec![AgentModelInfo {
id: "mock".to_string(),
name: Some("Mock".to_string()),
variants: None,
default_variant: None,
}],
Some("mock".to_string()),
)),
AgentId::Codex | AgentId::Opencode => None,
}
}
pub(super) fn map_install_result(result: InstallResult) -> AgentInstallResponse {
AgentInstallResponse {
already_installed: result.already_installed,
@ -429,41 +375,21 @@ pub(super) fn map_artifact_kind(kind: InstalledArtifactKind) -> String {
.to_string()
}
pub(super) async fn resolve_fs_path(
state: &Arc<AppState>,
session_id: Option<&str>,
raw_path: &str,
) -> Result<PathBuf, SandboxError> {
pub(super) fn resolve_fs_path(raw_path: &str) -> Result<PathBuf, SandboxError> {
let path = PathBuf::from(raw_path);
if path.is_absolute() {
return Ok(path);
}
let root = resolve_fs_root(state, session_id).await?;
let relative = sanitize_relative_path(&path)?;
Ok(root.join(relative))
}
pub(super) async fn resolve_fs_root(
state: &Arc<AppState>,
session_id: Option<&str>,
) -> Result<PathBuf, SandboxError> {
if let Some(session_id) = session_id {
let session = state
.acp_runtime()
.get_session(session_id)
.await
.ok_or_else(|| SandboxError::SessionNotFound {
session_id: session_id.to_string(),
})?;
return Ok(PathBuf::from(session.cwd));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| SandboxError::InvalidRequest {
message: "home directory unavailable".to_string(),
})?;
Ok(home)
let relative = sanitize_relative_path(&path)?;
Ok(home.join(relative))
}
pub(super) fn sanitize_relative_path(path: &StdPath) -> Result<PathBuf, SandboxError> {
@ -495,14 +421,6 @@ pub(super) fn map_fs_error(path: &StdPath, err: std::io::Error) -> SandboxError
}
}
pub(super) fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) fn content_type_is(headers: &HeaderMap, expected: &str) -> bool {
let Some(value) = headers
.get(header::CONTENT_TYPE)
@ -575,44 +493,10 @@ pub(super) fn parse_last_event_id(headers: &HeaderMap) -> Result<Option<u64>, Sa
}
}
pub(super) fn set_client_id_header(
response: &mut Response,
client_id: &str,
) -> Result<(), ApiError> {
let header_value = HeaderValue::from_str(client_id).map_err(|err| {
ApiError::Sandbox(SandboxError::StreamError {
message: format!("invalid client id header value: {err}"),
})
})?;
response
.headers_mut()
.insert(ACP_CLIENT_HEADER, header_value);
Ok(())
}
pub(super) fn request_principal(state: &AppState, headers: &HeaderMap) -> String {
if state.auth.token.is_some() {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "authenticated".to_string())
} else {
"anonymous".to_string()
}
}
pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails {
let mut problem = error.to_problem_details();
match error {
SandboxError::SessionNotFound { .. } => {
problem.type_ = "urn:sandbox-agent:error:client_not_found".to_string();
problem.title = "ACP client not found".to_string();
problem.detail = Some("unknown ACP client id".to_string());
problem.status = 404;
}
SandboxError::InvalidRequest { .. } => {
problem.status = 400;
}
@ -624,3 +508,112 @@ pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails
problem
}
/// Build the OpenCode-compatible provider payload from installed agent config
/// options. This replaces the hardcoded mock/amp/claude/codex list in the
/// opencode-adapter with real model information derived from
/// `fallback_config_options()`.
pub(super) fn build_provider_payload_for_opencode(_state: &Arc<AppState>) -> Value {
let agents: &[AgentId] = &[
AgentId::Mock,
AgentId::Claude,
AgentId::Codex,
AgentId::Amp,
AgentId::Opencode,
AgentId::Pi,
AgentId::Cursor,
];
let has_anthropic = std::env::var("ANTHROPIC_API_KEY").is_ok();
let has_openai = std::env::var("OPENAI_API_KEY").is_ok();
let mut all_providers = Vec::new();
let mut defaults = serde_json::Map::new();
let mut connected = Vec::new();
for &agent in agents {
let agent_str = agent.as_str();
let options = fallback_config_options(agent);
let model_option = options
.iter()
.find(|opt| opt.get("category").and_then(Value::as_str) == Some("model"));
let Some(model_option) = model_option else {
continue;
};
let current_value = model_option
.get("currentValue")
.and_then(Value::as_str)
.unwrap_or("default");
let option_list = model_option
.get("options")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut models = serde_json::Map::new();
for opt in &option_list {
let id = opt
.get("value")
.and_then(Value::as_str)
.unwrap_or("default");
let name = opt.get("name").and_then(Value::as_str).unwrap_or(id);
models.insert(
id.to_string(),
json!({
"id": id,
"name": name,
"family": capitalize_first(agent_str),
"release_date": "1970-01-01",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"limit": { "context": 200_000, "output": 8_192 },
"options": {},
}),
);
}
defaults.insert(agent_str.to_string(), json!(current_value));
if agent == AgentId::Mock || credentials_available_for(agent, has_anthropic, has_openai) {
connected.push(json!(agent_str));
}
all_providers.push(json!({
"id": agent_str,
"name": agent_display_name(agent),
"env": [],
"models": Value::Object(models),
}));
}
json!({
"all": all_providers,
"default": Value::Object(defaults),
"connected": connected,
})
}
fn agent_display_name(agent: AgentId) -> &'static str {
match agent {
AgentId::Mock => "Mock",
AgentId::Claude => "Claude Code",
AgentId::Codex => "Codex CLI",
AgentId::Amp => "Amp",
AgentId::Opencode => "OpenCode",
AgentId::Pi => "Pi",
AgentId::Cursor => "Cursor Agent",
}
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}

View file

@ -1,43 +1,17 @@
use std::collections::BTreeMap;
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct HealthResponse {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentModeInfo {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelInfo {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variants: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_variant: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelsResponse {
pub models: Vec<AgentModelInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ServerStatus {
Running,
Stopped,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -45,12 +19,7 @@ pub enum ServerStatus {
pub struct ServerStatusInfo {
pub status: ServerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uptime_ms: Option<u64>,
pub restart_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -90,11 +59,9 @@ pub struct AgentInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_status: Option<ServerStatusInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models: Option<Vec<AgentModelInfo>>,
pub config_options: Option<Vec<Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modes: Option<Vec<AgentModeInfo>>,
pub config_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -103,6 +70,14 @@ pub struct AgentListResponse {
pub agents: Vec<AgentInfo>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AgentsQuery {
#[serde(default)]
pub config: Option<bool>,
#[serde(default)]
pub no_cache: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
#[serde(rename_all = "camelCase")]
pub struct AgentInstallRequest {
@ -125,110 +100,10 @@ pub struct AgentInstallResponse {
pub artifacts: Vec<AgentInstallArtifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct StderrOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tail: Option<String>,
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_lines: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TerminationInfo {
pub reason: String,
pub terminated_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr: Option<StderrOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub session_id: String,
pub agent: String,
pub agent_mode: String,
pub permission_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_session_id: Option<String>,
pub ended: bool,
pub event_count: u64,
pub created_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub termination_info: Option<TerminationInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct SessionListResponse {
pub sessions: Vec<SessionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum PermissionReply {
Once,
Always,
Reject,
}
impl std::str::FromStr for PermissionReply {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"once" => Ok(Self::Once),
"always" => Ok(Self::Always),
"reject" => Ok(Self::Reject),
_ => Err(format!("invalid permission reply: {value}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsPathQuery {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -236,23 +111,12 @@ pub struct FsPathQuery {
pub struct FsEntriesQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsSessionQuery {
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsDeleteQuery {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recursive: Option<bool>,
}
@ -262,8 +126,6 @@ pub struct FsDeleteQuery {
pub struct FsUploadBatchQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -330,6 +192,162 @@ pub struct FsUploadBatchResponse {
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AcpPostQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AcpServerInfo {
pub server_id: String,
pub agent: String,
pub created_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AcpServerListResponse {
pub servers: Vec<AcpServerInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct McpConfigQuery {
pub directory: String,
#[serde(rename = "mcpName", alias = "mcp_name")]
pub mcp_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SkillsConfigQuery {
pub directory: String,
#[serde(rename = "skillName", alias = "skill_name")]
pub skill_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SkillsConfig {
pub sources: Vec<SkillSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SkillSource {
#[serde(rename = "type")]
pub source_type: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)]
pub enum McpCommand {
Command(String),
CommandWithArgs(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum McpRemoteTransport {
Http,
Sse,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct McpOAuthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)]
pub enum McpOAuthConfigOrDisabled {
Config(McpOAuthConfig),
Disabled(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum McpServerConfig {
#[serde(rename = "local", alias = "stdio")]
Local {
command: McpCommand,
#[serde(default)]
args: Vec<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "environment"
)]
env: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "timeoutMs",
alias = "timeout"
)]
#[schema(rename = "timeoutMs")]
timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
},
#[serde(rename = "remote", alias = "http")]
Remote {
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
headers: Option<BTreeMap<String, String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "bearerTokenEnvVar",
alias = "bearerTokenEnvVar",
alias = "bearer_token_env_var"
)]
#[schema(rename = "bearerTokenEnvVar")]
bearer_token_env_var: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "envHeaders",
alias = "envHttpHeaders",
alias = "env_http_headers"
)]
#[schema(rename = "envHeaders")]
env_headers: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth: Option<McpOAuthConfigOrDisabled>,
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "timeoutMs",
alias = "timeout"
)]
#[schema(rename = "timeoutMs")]
timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
transport: Option<McpRemoteTransport>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct AcpEnvelope {
pub jsonrpc: String,

View file

@ -5,10 +5,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createServer, type AddressInfo, type Server } from "node:net";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { existsSync, mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -79,7 +80,7 @@ async function waitForHealth(
}
try {
const response = await fetch(`${baseUrl}/v2/health`, {
const response = await fetch(`${baseUrl}/v1/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
@ -140,6 +141,8 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
const timeoutMs = options.timeoutMs ?? 30_000;
const args = ["server", "--host", host, "--port", String(port), "--token", token];
const tempStateDir = mkdtempSync(join(tmpdir(), "sandbox-agent-opencode-"));
const sqlitePath = join(tempStateDir, "opencode-sessions.db");
const compatEnv = {
OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000",
@ -149,6 +152,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
OPENCODE_COMPAT_STATE: "/state/opencode",
OPENCODE_COMPAT_CONFIG: "/config/opencode",
OPENCODE_COMPAT_BRANCH: "main",
OPENCODE_COMPAT_DB_PATH: sqlitePath,
};
const child = spawn(binaryPath, args, {
@ -162,18 +166,21 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
// Collect stderr for debugging
let stderr = "";
const logFile = process.env.SANDBOX_AGENT_TEST_LOG_FILE;
child.stderr?.on("data", (chunk) => {
const text = chunk.toString();
stderr += text;
if (logFile) appendFileSync(logFile, `[stderr] ${text}`);
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
process.stderr.write(text);
}
});
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
child.stdout?.on("data", (chunk) => {
child.stdout?.on("data", (chunk) => {
if (logFile) appendFileSync(logFile, `[stdout] ${chunk.toString()}`);
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
process.stderr.write(chunk.toString());
});
}
}
});
const baseUrl = `http://${host}:${port}`;
@ -189,6 +196,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
const dispose = async () => {
if (child.exitCode !== null) {
rmSync(tempStateDir, { recursive: true, force: true });
return;
}
child.kill("SIGTERM");
@ -196,6 +204,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
if (!exited) {
child.kill("SIGKILL");
}
rmSync(tempStateDir, { recursive: true, force: true });
};
return { baseUrl, token, child, dispose };

View file

@ -44,12 +44,12 @@ describe("OpenCode-compatible Model API", () => {
expect(mockModels["mock"].family).toBe("Mock");
const ampModels = ampProvider?.models ?? {};
expect(ampModels["smart"]).toBeDefined();
expect(ampModels["smart"].id).toBe("smart");
expect(ampModels["smart"].family).toBe("Amp");
expect(ampModels["amp-default"]).toBeDefined();
expect(ampModels["amp-default"].id).toBe("amp-default");
expect(ampModels["amp-default"].family).toBe("Amp");
expect(response.data?.default?.["mock"]).toBe("mock");
expect(response.data?.default?.["amp"]).toBe("smart");
expect(response.data?.default?.["amp"]).toBe("amp-default");
});
it("should keep provider backends visible when discovery is degraded", async () => {

View file

@ -9,7 +9,7 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Permission API", () => {

View file

@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Question API", () => {

View file

@ -0,0 +1,244 @@
/**
* Integration test for real agent prompts via the OpenCode compatibility layer.
*
* Skipped unless TEST_AGENT_MODEL is set. Example:
*
* TEST_AGENT_MODEL=gpt-5.2-codex npx vitest run server/packages/sandbox-agent/tests/opencode-compat/real-agent.test.ts
*
* The env var value is a model ID. Provider is inferred:
* - gpt-* codex
* - claude-* claude
* - amp-* amp
* - foo/bar opencode (slash means opencode provider passthrough)
* - otherwise mock
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import {
spawnSandboxAgent,
buildSandboxAgent,
type SandboxAgentHandle,
} from "./helpers/spawn";
const MODEL = process.env.TEST_AGENT_MODEL;
function inferProvider(model: string): string {
if (model.startsWith("gpt-")) return "codex";
if (model.startsWith("claude-")) return "claude";
if (model.startsWith("amp-")) return "amp";
if (model.includes("/")) return "opencode";
return "mock";
}
describe.skipIf(!MODEL)("Real agent round-trip", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
const modelId = MODEL ?? "mock";
const providerId = process.env.TEST_AGENT_PROVIDER ?? inferProvider(modelId);
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({
opencodeCompat: true,
timeoutMs: 60_000,
});
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
});
afterEach(async () => {
await handle?.dispose();
});
/**
* Helper: wait for the next session.idle on the event stream, collecting text.
* Uses a manual iterator to avoid closing the stream (for-await-of calls
* iterator.return() on early exit, which would close the SSE connection).
*/
function collectUntilIdle(
iter: AsyncIterator<any>,
timeoutMs = 30_000,
): Promise<{ events: any[]; text: string }> {
const events: any[] = [];
let text = "";
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() =>
reject(
new Error(
`Timed out after ${timeoutMs}ms. Events: ${JSON.stringify(events.map((e) => e.type))}`,
),
),
timeoutMs,
);
(async () => {
try {
while (true) {
const { value: event, done } = await iter.next();
if (done) {
clearTimeout(timeout);
reject(new Error("Stream ended before session.idle"));
return;
}
events.push(event);
if (
event.type === "message.part.updated" &&
event.properties?.part?.type === "text"
) {
// Prefer the delta (chunk) if present; otherwise use the full
// accumulated part.text (for non-streaming single-shot events).
text += event.properties.delta ?? event.properties.part.text ?? "";
}
if (event.type === "session.idle") {
clearTimeout(timeout);
resolve({ events, text });
return;
}
if (event.type === "session.error") {
clearTimeout(timeout);
reject(
new Error(
`session.error: ${JSON.stringify(event.properties?.error)}`,
),
);
return;
}
}
} catch (err) {
clearTimeout(timeout);
reject(err);
}
})();
});
}
it(`should get a response from ${MODEL}`, async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
const eventStream = await client.event.subscribe();
const stream = (eventStream as any).stream as AsyncIterable<any>;
const iter = stream[Symbol.asyncIterator]();
// Start collecting BEFORE sending prompt so no events are lost
const turn1Promise = collectUntilIdle(iter);
const prompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: providerId, modelID: modelId },
parts: [{ type: "text", text: "Reply with exactly: hello world" }],
},
});
expect(prompt.error).toBeUndefined();
const turn1 = await turn1Promise;
console.log(`Turn 1 — events: ${turn1.events.length}, text: ${turn1.text.slice(0, 200)}`);
expect(turn1.text.length).toBeGreaterThan(0);
}, 60_000);
it(`should have correct message ordering and info`, async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
const eventStream = await client.event.subscribe();
const stream = (eventStream as any).stream as AsyncIterable<any>;
const iter = stream[Symbol.asyncIterator]();
const turnPromise = collectUntilIdle(iter);
await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: providerId, modelID: modelId },
parts: [{ type: "text", text: "Reply with exactly: test" }],
},
});
const turn = await turnPromise;
// ── Verify SSE event ordering ──
const msgUpdates = turn.events.filter((e: any) => e.type === "message.updated");
console.log("SSE message.updated events in order:");
for (const e of msgUpdates) {
const info = e.properties?.info ?? {};
console.log(` role=${info.role ?? "?"} id=${info.id ?? "?"} parentID=${info.parentID ?? "none"} time=${JSON.stringify(info.time)}`);
}
// user message.updated must come before assistant message.updated in the event stream
const userUpdateIdx = msgUpdates.findIndex((e: any) => e.properties?.info?.role === "user");
const assistantUpdateIdx = msgUpdates.findIndex((e: any) => e.properties?.info?.role === "assistant");
expect(userUpdateIdx).toBeGreaterThanOrEqual(0);
expect(assistantUpdateIdx).toBeGreaterThanOrEqual(0);
expect(userUpdateIdx).toBeLessThan(assistantUpdateIdx);
// ── Verify persisted messages via HTTP ──
const res = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
headers: { Authorization: `Bearer ${handle.token}` },
});
const messages: any[] = await res.json();
expect(messages.length).toBeGreaterThanOrEqual(2);
const user = messages.find((m: any) => m.info?.role === "user");
const assistant = messages.find((m: any) => m.info?.role === "assistant");
expect(user).toBeDefined();
expect(assistant).toBeDefined();
// Assistant must reference user via parentID
expect(assistant.info.parentID).toBe(user.info.id);
expect(assistant.info.role).toBe("assistant");
expect(assistant.info.id).toBeDefined();
expect(assistant.info.id).not.toBe("");
// User must appear before assistant in the persisted array
const userIdx = messages.indexOf(user);
const assistantIdx = messages.indexOf(assistant);
expect(userIdx).toBeLessThan(assistantIdx);
console.log(`Persisted: user=${user.info.id}, assistant=${assistant.info.id}, parentID=${assistant.info.parentID}`);
}, 60_000);
it(`should handle multi-turn conversation with ${MODEL}`, async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
const eventStream = await client.event.subscribe();
const stream = (eventStream as any).stream as AsyncIterable<any>;
const iter = stream[Symbol.asyncIterator]();
// Turn 1 — start collecting BEFORE prompt
const turn1Promise = collectUntilIdle(iter);
const p1 = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: providerId, modelID: modelId },
parts: [{ type: "text", text: "Remember the number 42. Reply with just: ok" }],
},
});
expect(p1.error).toBeUndefined();
const turn1 = await turn1Promise;
console.log(`Turn 1 — events: ${turn1.events.length}, text: ${turn1.text.slice(0, 200)}`);
expect(turn1.text.length).toBeGreaterThan(0);
// Turn 2 — start collecting BEFORE prompt
const turn2Promise = collectUntilIdle(iter);
const p2 = await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: "What number did I ask you to remember? Reply with just the number." }],
},
});
expect(p2.error).toBeUndefined();
const turn2 = await turn2Promise;
console.log(`Turn 2 — events: ${turn2.events.length}, text: ${turn2.text.slice(0, 200)}`);
expect(turn2.text.length).toBeGreaterThan(0);
expect(turn2.text).toContain("42");
}, 90_000);
});

View file

@ -15,6 +15,9 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("OpenCode-compatible Session API", () => {
let handle: SandboxAgentHandle;
@ -276,6 +279,47 @@ describe("OpenCode-compatible Session API", () => {
});
expect(prompt.error).toBeUndefined();
});
it("should reject init model changes after the first prompt", async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
const firstPrompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "first" }],
},
});
expect(firstPrompt.error).toBeUndefined();
const changed = await initSessionViaHttp(sessionId, {
providerID: "codex",
modelID: "gpt-5",
});
expect(changed.response.status).toBe(400);
expect(changed.data?.errors?.[0]?.message).toBe(
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
);
});
it("should map agent-only first prompt selection to provider/model defaults", async () => {
const session = await client.session.create();
const sessionId = session.data?.id!;
expect(sessionId).toBeDefined();
const prompt = await client.session.prompt({
path: { id: sessionId },
body: {
agent: "codex",
parts: [{ type: "text", text: "hello with agent only" }],
} as any,
});
expect(prompt.error).toBeUndefined();
expect(prompt.data?.info?.providerID).toBe("codex");
expect(prompt.data?.info?.modelID).toBe("gpt-5");
});
});
describe("session.get", () => {
@ -333,6 +377,68 @@ describe("OpenCode-compatible Session API", () => {
expect(response.error).toBeDefined();
});
it("should restore persisted sessions after server restart and continue prompting", async () => {
await handle.dispose();
const tempStateDir = mkdtempSync(join(tmpdir(), "sandbox-agent-opencode-restore-"));
const sqlitePath = join(tempStateDir, "opencode-sessions.db");
try {
handle = await spawnSandboxAgent({
opencodeCompat: true,
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
});
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
const created = await client.session.create({ body: { title: "Persisted Session" } });
const sessionId = created.data?.id!;
expect(sessionId).toBeDefined();
const firstPrompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "before restart" }],
},
});
expect(firstPrompt.error).toBeUndefined();
await waitForAssistantMessage(sessionId);
await handle.dispose();
handle = await spawnSandboxAgent({
opencodeCompat: true,
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
});
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
const restored = await client.session.get({ path: { id: sessionId } });
expect(restored.error).toBeUndefined();
expect(restored.data?.id).toBe(sessionId);
expect(restored.data?.title).toBe("Persisted Session");
const secondPrompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "after restart" }],
},
});
expect(secondPrompt.error).toBeUndefined();
const messages = await listMessagesViaHttp(sessionId);
expect(messages.length).toBeGreaterThan(2);
} finally {
rmSync(tempStateDir, { recursive: true, force: true });
}
});
});
describe("session.update", () => {
@ -376,6 +482,38 @@ describe("OpenCode-compatible Session API", () => {
);
}
});
it("should reject prompt model changes after the first prompt", async () => {
const created = await client.session.create({ body: { title: "Model Lock" } });
const sessionId = created.data?.id!;
const firstPrompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "first" }],
},
});
expect(firstPrompt.error).toBeUndefined();
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
method: "POST",
headers: {
Authorization: `Bearer ${handle.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: { providerID: "codex", modelID: "gpt-5" },
parts: [{ type: "text", text: "second" }],
}),
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data?.errors?.[0]?.message).toBe(
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
);
});
});
describe("session.delete", () => {

View file

@ -1,75 +1,31 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_agent_management::agents::AgentManager;
use tower::util::ServiceExt;
use sandbox_agent::opencode_compat::OpenCodeApiDoc;
use serde_json::Value;
use utoipa::OpenApi;
#[tokio::test]
async fn opencode_routes_are_mounted() {
let install_dir = tempfile::tempdir().expect("tempdir");
let manager = AgentManager::new(install_dir.path()).expect("agent manager");
let app = build_router(AppState::new(AuthConfig::disabled(), manager));
fn collect_path_methods(spec: &Value) -> BTreeSet<String> {
let mut methods = BTreeSet::new();
let Some(paths) = spec.get("paths").and_then(|value| value.as_object()) else {
return methods;
};
for (path, item) in paths {
let Some(item) = item.as_object() else {
continue;
};
for method in [
"get", "post", "put", "patch", "delete", "options", "head", "trace",
] {
if item.contains_key(method) {
methods.insert(format!("{} {}", method.to_uppercase(), path));
}
}
}
methods
}
fn official_spec_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../resources/agent-schemas/artifacts/openapi/opencode.json")
}
#[test]
fn opencode_openapi_matches_official_paths() {
let official_path = official_spec_path();
if !official_path.exists() {
eprintln!(
"skipping OpenCode OpenAPI parity check; official spec missing at {:?}",
official_path
);
return;
}
let official_json = fs::read_to_string(&official_path).unwrap_or_else(|err| {
panic!("failed to read official OpenCode spec at {official_path:?}: {err}")
});
let official: Value =
serde_json::from_str(&official_json).expect("official OpenCode spec is not valid JSON");
let ours = OpenCodeApiDoc::openapi();
let ours_value = serde_json::to_value(&ours).expect("failed to serialize OpenCode OpenAPI");
let official_methods = collect_path_methods(&official);
let our_methods = collect_path_methods(&ours_value);
let missing: Vec<_> = official_methods.difference(&our_methods).cloned().collect();
let extra: Vec<_> = our_methods.difference(&official_methods).cloned().collect();
if !missing.is_empty() || !extra.is_empty() {
let mut message = String::new();
if !missing.is_empty() {
message.push_str("Missing endpoints (present in official spec, absent in ours):\n");
for endpoint in &missing {
message.push_str(&format!("- {endpoint}\n"));
}
}
if !extra.is_empty() {
message.push_str("Extra endpoints (present in ours, absent in official spec):\n");
for endpoint in &extra {
message.push_str(&format!("- {endpoint}\n"));
}
}
panic!("{message}");
}
let request = Request::builder()
.method(Method::GET)
.uri("/opencode/session")
.body(Body::empty())
.expect("build request");
let response = app.oneshot(request).await.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
assert!(parsed.is_array());
}

View file

@ -0,0 +1,237 @@
use std::fs;
use std::path::Path;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use futures::StreamExt;
use http_body_util::BodyExt;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_agent_management::agents::AgentManager;
use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
struct TestApp {
app: axum::Router,
_install_dir: TempDir,
}
impl TestApp {
fn with_setup<F>(setup: F) -> Self
where
F: FnOnce(&Path),
{
let install_dir = tempfile::tempdir().expect("create temp install dir");
setup(install_dir.path());
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
let state = AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
fn write_executable(path: &Path, script: &str) {
fs::write(path, script).expect("write executable");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).expect("set mode");
}
}
fn write_stub_native(path: &Path, agent: &str) {
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
write_executable(path, &script);
}
fn write_stub_agent_process(path: &Path, agent: &str) {
let script = format!(
r#"#!/usr/bin/env sh
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
echo "{agent}-agent-process 0.0.1"
exit 0
fi
while IFS= read -r line; do
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
if [ -n "$method" ]; then
printf '{{"jsonrpc":"2.0","method":"server/echo","params":{{"agent":"{agent}","method":"%s"}}}}\n' "$method"
fi
if [ -n "$method" ] && [ -n "$id" ]; then
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"ok":true,"agent":"{agent}","echoedMethod":"%s"}}}}\n' "$id" "$method"
fi
done
"#
);
write_executable(path, &script);
}
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
let native = install_dir.join(agent);
write_stub_native(&native, agent);
let agent_processes = install_dir.join("agent_processes");
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
let launcher = if cfg!(windows) {
agent_processes.join(format!("{agent}-acp.cmd"))
} else {
agent_processes.join(format!("{agent}-acp"))
};
write_stub_agent_process(&launcher, agent);
}
fn setup_stub_agent_process_only(install_dir: &Path, agent: &str) {
let agent_processes = install_dir.join("agent_processes");
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
let launcher = if cfg!(windows) {
agent_processes.join(format!("{agent}-acp.cmd"))
} else {
agent_processes.join(format!("{agent}-acp"))
};
write_stub_agent_process(&launcher, agent);
}
async fn send_request(
app: &axum::Router,
method: Method,
uri: &str,
body: Option<Value>,
) -> (StatusCode, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
let request_body = if let Some(body) = body {
builder = builder.header("content-type", "application/json");
Body::from(body.to_string())
} else {
Body::empty()
};
let request = builder.body(request_body).expect("build request");
let response = app.clone().oneshot(request).await.expect("request handled");
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
(status, bytes.to_vec())
}
fn parse_json(bytes: &[u8]) -> Value {
if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(bytes).expect("valid json")
}
}
#[cfg(unix)]
#[tokio::test]
async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
let native_agents = ["claude", "codex", "opencode"];
let agent_process_only_agents = ["pi", "cursor"];
let agents: Vec<&str> = native_agents
.iter()
.chain(agent_process_only_agents.iter())
.copied()
.collect();
let test_app = TestApp::with_setup(|install_dir| {
for agent in native_agents {
setup_stub_artifacts(install_dir, agent);
}
for agent in agent_process_only_agents {
setup_stub_agent_process_only(install_dir, agent);
}
});
for agent in agents {
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {}
}
});
let (status, init_body) = send_request(
&test_app.app,
Method::POST,
&format!("/v1/acp/{agent}-server?agent={agent}"),
Some(initialize),
)
.await;
assert_eq!(status, StatusCode::OK, "{agent}: initialize status");
let init_json = parse_json(&init_body);
assert_eq!(init_json["jsonrpc"], "2.0", "{agent}: initialize jsonrpc");
assert_eq!(init_json["id"], 1, "{agent}: initialize id");
assert_eq!(
init_json["result"]["agent"], agent,
"{agent}: initialize agent"
);
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp"
}
});
let (status, new_body) = send_request(
&test_app.app,
Method::POST,
&format!("/v1/acp/{agent}-server"),
Some(new_session),
)
.await;
assert_eq!(status, StatusCode::OK, "{agent}: session/new status");
let new_json = parse_json(&new_body);
assert_eq!(new_json["jsonrpc"], "2.0", "{agent}: session/new jsonrpc");
assert_eq!(new_json["id"], 2, "{agent}: session/new id");
assert_eq!(new_json["result"]["echoedMethod"], "session/new");
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/acp/{agent}-server"))
.body(Body::empty())
.expect("build sse request");
let response = test_app
.app
.clone()
.oneshot(request)
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
while let Some(item) = stream.next().await {
let bytes = item.expect("sse chunk");
let text = String::from_utf8_lossy(&bytes).to_string();
if text.contains("server/echo") {
return text;
}
}
panic!("sse ended")
})
.await
.expect("sse timeout");
assert!(
chunk.contains("server/echo"),
"{agent}: missing server/echo"
);
}
}

View file

@ -188,45 +188,35 @@ fn parse_json(bytes: &[u8]) -> Value {
}
}
fn initialize_payload(agent: &str) -> Value {
fn initialize_payload() -> Value {
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": agent,
}
}
"clientCapabilities": {}
}
})
}
async fn create_connection(app: &Router, agent: &str, headers: &[(&str, &str)]) -> String {
let initialize = initialize_payload(agent);
let (status, response_headers, _bytes) =
send_request(app, Method::POST, "/v2/rpc", Some(initialize), headers).await;
async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) {
let initialize = initialize_payload();
let (status, _, _body) = send_request(
app,
Method::POST,
&format!("/v1/acp/{server_id}?agent={agent}"),
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
response_headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string())
.expect("connection id")
}
async fn create_mock_connection(app: &Router, headers: &[(&str, &str)]) -> String {
create_connection(app, "mock", headers).await
}
async fn read_first_sse_data(app: &Router, connection_id: &str) -> String {
async fn read_first_sse_data(app: &Router, server_id: &str) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/v2/rpc")
.header("x-acp-connection-id", connection_id)
.uri(format!("/v1/acp/{server_id}"))
.body(Body::empty())
.expect("build request");
@ -250,13 +240,12 @@ async fn read_first_sse_data(app: &Router, connection_id: &str) -> String {
async fn read_first_sse_data_with_last_id(
app: &Router,
connection_id: &str,
server_id: &str,
last_event_id: u64,
) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/v2/rpc")
.header("x-acp-connection-id", connection_id)
.uri(format!("/v1/acp/{server_id}"))
.header("last-event-id", last_event_id.to_string())
.body(Body::empty())
.expect("build request");
@ -279,38 +268,6 @@ async fn read_first_sse_data_with_last_id(
.expect("timed out reading sse")
}
async fn sse_has_data_with_last_id(
app: &Router,
connection_id: &str,
last_event_id: u64,
timeout: Duration,
) -> bool {
let request = Request::builder()
.method(Method::GET)
.uri("/v2/rpc")
.header("x-acp-connection-id", connection_id)
.header("last-event-id", last_event_id.to_string())
.body(Body::empty())
.expect("build request");
let response = app.clone().oneshot(request).await.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
tokio::time::timeout(timeout, async move {
while let Some(chunk) = stream.next().await {
let bytes = chunk.expect("stream chunk");
let text = String::from_utf8_lossy(&bytes).to_string();
if text.contains("data:") {
return true;
}
}
false
})
.await
.unwrap_or(false)
}
fn parse_sse_data(chunk: &str) -> Value {
let data = chunk
.lines()
@ -328,27 +285,9 @@ fn parse_sse_event_id(chunk: &str) -> u64 {
.expect("sse event id")
}
async fn read_sse_until_contains(
app: &Router,
connection_id: &str,
mut last_event_id: u64,
needle: &str,
max_steps: usize,
) -> Option<String> {
for _ in 0..max_steps {
let chunk = read_first_sse_data_with_last_id(app, connection_id, last_event_id).await;
let event_id = parse_sse_event_id(&chunk);
last_event_id = event_id;
if chunk.contains(needle) {
return Some(chunk);
}
}
None
}
#[path = "v2_api/acp_extensions.rs"]
mod acp_extensions;
#[path = "v2_api/acp_transport.rs"]
#[path = "v1_api/acp_transport.rs"]
mod acp_transport;
#[path = "v2_api/control_plane.rs"]
#[path = "v1_api/config_endpoints.rs"]
mod config_endpoints;
#[path = "v1_api/control_plane.rs"]
mod control_plane;

View file

@ -0,0 +1,307 @@
use super::*;
fn write_stub_native(path: &Path, agent: &str) {
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
write_executable(path, &script);
}
fn write_stub_agent_process(path: &Path, agent: &str) {
let script = format!(
r#"#!/usr/bin/env sh
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
echo "{agent}-agent-process 0.0.1"
exit 0
fi
while IFS= read -r line; do
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
if [ -n "$method" ]; then
printf '{{"jsonrpc":"2.0","method":"server/echo","params":{{"method":"%s"}}}}\n' "$method"
fi
if [ -n "$method" ] && [ -n "$id" ]; then
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"ok":true,"echoedMethod":"%s"}}}}\n' "$id" "$method"
elif [ -z "$method" ] && [ -n "$id" ]; then
printf '{{"jsonrpc":"2.0","method":"server/client_response","params":{{"id":%s}}}}\n' "$id"
fi
done
"#
);
write_executable(path, &script);
}
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
let native = install_dir.join(agent);
write_stub_native(&native, agent);
let agent_processes = install_dir.join("agent_processes");
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
let launcher = if cfg!(windows) {
agent_processes.join(format!("{agent}-acp.cmd"))
} else {
agent_processes.join(format!("{agent}-acp"))
};
write_stub_agent_process(&launcher, agent);
}
#[tokio::test]
async fn acp_bootstrap_requires_agent_query() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-a",
Some(initialize_payload()),
&[],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(parse_json(&body)["status"], 400);
}
#[cfg(unix)]
#[tokio::test]
async fn acp_round_trip_and_replay() {
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
setup_stub_artifacts(install_dir, "codex");
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-replay?agent=codex",
Some(initialize_payload()),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["echoedMethod"], "initialize");
let prompt = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/prompt",
"params": {
"sessionId": "s-1",
"prompt": [{"type": "text", "text": "hello"}]
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-replay",
Some(prompt),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
parse_json(&body)["result"]["echoedMethod"],
"session/prompt"
);
let first_chunk = read_first_sse_data_with_last_id(&test_app.app, "server-replay", 0).await;
let first_event_id = parse_sse_event_id(&first_chunk);
let first_event = parse_sse_data(&first_chunk);
assert_eq!(first_event["method"], "server/echo");
let second_chunk =
read_first_sse_data_with_last_id(&test_app.app, "server-replay", first_event_id).await;
let second_event_id = parse_sse_event_id(&second_chunk);
assert!(second_event_id > first_event_id);
}
#[cfg(unix)]
#[tokio::test]
async fn acp_agent_mismatch_returns_conflict() {
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
setup_stub_artifacts(install_dir, "codex");
setup_stub_artifacts(install_dir, "claude");
});
bootstrap_server(&test_app.app, "server-mismatch", "codex").await;
let request = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-mismatch?agent=claude",
Some(request),
&[],
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(parse_json(&body)["status"], 409);
}
#[tokio::test]
async fn acp_get_unknown_returns_not_found() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) =
send_request(&test_app.app, Method::GET, "/v1/acp/missing", None, &[]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(parse_json(&body)["status"], 404);
}
#[tokio::test]
async fn acp_delete_is_idempotent() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v1/acp/server-delete",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v1/acp/server-delete",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-delete",
Some(request),
&[],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(parse_json(&body)["status"], 400);
}
#[cfg(unix)]
#[tokio::test]
async fn acp_list_servers_returns_active_instances() {
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
setup_stub_artifacts(install_dir, "codex");
});
bootstrap_server(&test_app.app, "server-1", "codex").await;
bootstrap_server(&test_app.app, "server-2", "codex").await;
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v1/acp", None, &[]).await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let servers = parsed["servers"].as_array().expect("servers array");
assert!(servers
.iter()
.any(|server| server["serverId"] == "server-1"));
assert!(servers
.iter()
.any(|server| server["serverId"] == "server-2"));
}
#[cfg(unix)]
#[tokio::test]
async fn sandboxagent_methods_are_not_handled_specially() {
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
setup_stub_artifacts(install_dir, "codex");
});
bootstrap_server(&test_app.app, "server-ext", "codex").await;
let request = json!({
"jsonrpc": "2.0",
"id": 22,
"method": "_sandboxagent/session/list",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-ext",
Some(request),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
parse_json(&body)["result"]["echoedMethod"],
"_sandboxagent/session/list"
);
}
#[tokio::test]
async fn post_requires_json_content_type() {
let test_app = TestApp::new(AuthConfig::disabled());
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0","clientCapabilities":{}}}"#
.to_vec();
let (status, _, body) = send_request_raw(
&test_app.app,
Method::POST,
"/v1/acp/server-content?agent=mock",
Some(payload),
&[],
Some("text/plain"),
)
.await;
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(parse_json(&body)["status"], 415);
}
#[tokio::test]
async fn sse_rejects_non_sse_accept() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/acp/server-a",
None,
&[("accept", "application/json")],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
assert_eq!(parse_json(&body)["status"], 406);
}
#[tokio::test]
async fn invalid_last_event_id_returns_bad_request() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/acp/server-a",
None,
&[("last-event-id", "not-a-number")],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
parse_json(&body)["detail"],
"invalid request: Last-Event-ID must be a positive integer"
);
}

View file

@ -0,0 +1,152 @@
use super::*;
#[tokio::test]
async fn mcp_config_requires_directory_and_name() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, _) =
send_request(&test_app.app, Method::GET, "/v1/config/mcp", None, &[]).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, _, _) = send_request(
&test_app.app,
Method::PUT,
"/v1/config/mcp?directory=/tmp",
Some(json!({"type": "local", "command": "mcp"})),
&[],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn mcp_config_crud_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let project = tempfile::tempdir().expect("tempdir");
let directory = project.path().to_string_lossy().to_string();
let entry = json!({
"type": "local",
"command": "node",
"args": ["server.js"],
"env": {"LOG_LEVEL": "debug"},
"timeoutMs": 2000,
"cwd": "/workspace"
});
let (status, _, _) = send_request(
&test_app.app,
Method::PUT,
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
Some(entry.clone()),
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body), entry);
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(parse_json(&body)["status"], 404);
}
#[tokio::test]
async fn skills_config_requires_directory_and_name() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, _) =
send_request(&test_app.app, Method::GET, "/v1/config/skills", None, &[]).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, _, _) = send_request(
&test_app.app,
Method::PUT,
"/v1/config/skills?directory=/tmp",
Some(json!({"sources": []})),
&[],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn skills_config_crud_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let project = tempfile::tempdir().expect("tempdir");
let directory = project.path().to_string_lossy().to_string();
let entry = json!({
"sources": [
{"type": "github", "source": "rivet-dev/skills", "skills": ["sandbox-agent"], "ref": "main"},
{"type": "local", "source": "/workspace/my-skill"}
]
});
let (status, _, _) = send_request(
&test_app.app,
Method::PUT,
&format!("/v1/config/skills?directory={directory}&skillName=default"),
Some(entry.clone()),
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
&format!("/v1/config/skills?directory={directory}&skillName=default"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body), entry);
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
&format!("/v1/config/skills?directory={directory}&skillName=default"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, _) = send_request(
&test_app.app,
Method::GET,
&format!("/v1/config/skills?directory={directory}&skillName=default"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}

View file

@ -0,0 +1,218 @@
use super::*;
#[tokio::test]
async fn v1_health_removed_legacy_and_opencode_unmounted() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v1/health", None, &[]).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["status"], "ok");
let (status, _, _body) =
send_request(&test_app.app, Method::GET, "/v1/anything", None, &[]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _, _) =
send_request(&test_app.app, Method::GET, "/opencode/session", None, &[]).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn v1_auth_enforced_when_token_configured() {
let test_app = TestApp::new(AuthConfig::with_token("secret-token".to_string()));
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v1/health", None, &[]).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/health",
None,
&[("authorization", "Bearer secret-token")],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["status"], "ok");
}
#[tokio::test]
async fn v1_filesystem_endpoints_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request_raw(
&test_app.app,
Method::PUT,
"/v1/fs/file?path=docs/file.txt",
Some(b"hello".to_vec()),
&[],
Some("application/octet-stream"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["bytesWritten"], 5);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/fs/stat?path=docs/file.txt",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["entryType"], "file");
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v1/fs/entries?path=docs",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let entries = parse_json(&body).as_array().cloned().expect("array");
assert!(entries.iter().any(|entry| entry["name"] == "file.txt"));
let (status, headers, body) = send_request_raw(
&test_app.app,
Method::GET,
"/v1/fs/file?path=docs/file.txt",
None,
&[],
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or(""),
"application/octet-stream"
);
assert_eq!(String::from_utf8_lossy(&body), "hello");
let move_body = json!({
"from": "docs/file.txt",
"to": "docs/renamed.txt",
"overwrite": true
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/fs/move",
Some(move_body),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(parse_json(&body)["to"]
.as_str()
.expect("to path")
.ends_with("docs/renamed.txt"));
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v1/fs/mkdir?path=docs/nested",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v1/fs/entry?path=docs/nested&recursive=true",
None,
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
#[serial]
async fn require_preinstall_blocks_missing_agent() {
let test_app = {
let _preinstall = EnvVarGuard::set("SANDBOX_AGENT_REQUIRE_PREINSTALL", "true");
TestApp::new(AuthConfig::disabled())
};
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-a?agent=codex",
Some(initialize_payload()),
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let parsed = parse_json(&body);
assert_eq!(parsed["status"], 404);
assert_eq!(parsed["title"], "Agent Not Installed");
}
#[tokio::test]
#[serial]
async fn lazy_install_runs_on_first_bootstrap() {
let registry_url = serve_registry_once(json!({
"agents": [
{
"id": "codex-acp",
"version": "1.2.3",
"distribution": {
"npx": {
"package": "@example/codex-acp@1.2.3",
"args": [],
"env": {}
}
}
}
]
}));
let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", &registry_url);
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
fs::create_dir_all(install_path.join("agent_processes"))
.expect("create agent processes dir");
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
write_executable(
&install_path.join("bin").join("npx"),
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
);
});
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![test_app.install_path().join("bin")];
paths.extend(std::env::split_paths(&original_path));
let merged_path = std::env::join_paths(paths).expect("join PATH");
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v1/acp/server-lazy?agent=codex",
Some(json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {}
}
})),
&[],
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert!(test_app
.install_path()
.join("agent_processes/codex-acp")
.exists());
}

View file

@ -1,628 +0,0 @@
use std::fs;
use std::path::Path;
use std::time::Duration;
use axum::body::Body;
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
use axum::Router;
use futures::StreamExt;
use http_body_util::BodyExt;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent_agent_management::agents::AgentManager;
use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
struct TestApp {
app: Router,
_install_dir: TempDir,
}
impl TestApp {
fn with_setup<F>(setup: F) -> Self
where
F: FnOnce(&Path),
{
let install_dir = tempfile::tempdir().expect("create temp install dir");
setup(install_dir.path());
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
let state = AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
async fn send_request(
app: &Router,
method: Method,
uri: &str,
body: Option<Value>,
headers: &[(&str, &str)],
) -> (StatusCode, HeaderMap, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let request_body = if let Some(body) = body {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(body.to_string())
} else {
Body::empty()
};
let request = builder.body(request_body).expect("build request");
let response = app.clone().oneshot(request).await.expect("request handled");
let status = response.status();
let headers = response.headers().clone();
let bytes = response
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
(status, headers, bytes.to_vec())
}
fn parse_json(bytes: &[u8]) -> Value {
if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(bytes).expect("valid json")
}
}
async fn read_first_sse_chunk(app: &Router, connection_id: &str) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/v2/rpc")
.header("x-acp-connection-id", connection_id)
.body(Body::empty())
.expect("build request");
let response = app.clone().oneshot(request).await.expect("sse response");
assert_eq!(response.status(), StatusCode::OK);
let mut stream = response.into_body().into_data_stream();
tokio::time::timeout(Duration::from_secs(5), async move {
while let Some(chunk) = stream.next().await {
let bytes = chunk.expect("stream chunk");
let text = String::from_utf8_lossy(&bytes).to_string();
if text.contains("data:") {
return text;
}
}
panic!("SSE stream ended before data chunk")
})
.await
.expect("timed out reading sse")
}
fn parse_sse_data(chunk: &str) -> Value {
let data = chunk
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.collect::<Vec<_>>()
.join("\n");
serde_json::from_str(&data).expect("valid SSE payload json")
}
#[cfg(unix)]
fn set_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).expect("set executable bit");
}
fn write_stub_native(path: &Path, agent: &str) {
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
fs::write(path, script).expect("write native stub");
#[cfg(unix)]
set_executable(path);
}
fn write_stub_agent_process(path: &Path, agent: &str) {
write_stub_agent_process_with_counter(path, agent, None);
}
fn write_stub_agent_process_with_counter(
path: &Path,
agent: &str,
start_counter_file: Option<&Path>,
) {
let start_counter_block = start_counter_file.map_or_else(String::new, |file| {
let path = file.display();
format!(
r#"
count=0
if [ -f "{path}" ]; then
count=$(cat "{path}")
fi
count=$((count + 1))
printf '%s' "$count" > "{path}"
"#
)
});
let script = format!(
r#"#!/usr/bin/env sh
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
echo "{agent}-agent-process 0.0.1"
exit 0
fi
if [ "${{1:-}}" = "acp" ]; then
shift
fi
{start_counter_block}
SESSION_ID="{agent}-session-1"
while IFS= read -r line; do
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
case "$method" in
initialize)
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"protocolVersion":"1.0","agentCapabilities":{{"canSetMode":true,"canSetModel":true}},"authMethods":[]}}}}\n' "$id"
;;
model/list)
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"data":[{{"model":"{agent}-model","displayName":"{agent} model","isDefault":true}}],"nextCursor":null}}}}\n' "$id"
;;
session/new)
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"sessionId":"%s","availableModes":[],"configOptions":[]}}}}\n' "$id" "$SESSION_ID"
;;
session/prompt)
printf '{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"%s","update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{agent}: stub response"}}}}}}}}\n' "$SESSION_ID"
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"stopReason":"end_turn"}}}}\n' "$id"
;;
session/cancel)
printf '{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"%s","update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{agent}: cancelled"}}}}}}}}\n' "$SESSION_ID"
;;
*)
if [ -n "$id" ]; then
printf '{{"jsonrpc":"2.0","id":%s,"result":{{}}}}\n' "$id"
fi
;;
esac
done
"#
);
fs::write(path, script).expect("write agent process stub");
#[cfg(unix)]
set_executable(path);
}
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
let native = install_dir.join(agent);
write_stub_native(&native, agent);
let agent_processes = install_dir.join("agent_processes");
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
let launcher = if cfg!(windows) {
agent_processes.join(format!("{agent}-acp.cmd"))
} else {
agent_processes.join(format!("{agent}-acp"))
};
write_stub_agent_process(&launcher, agent);
}
#[cfg(unix)]
#[tokio::test]
async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
let agents = ["claude", "codex", "opencode"];
let test_app = TestApp::with_setup(|install_dir| {
for agent in agents {
setup_stub_artifacts(install_dir, agent);
}
});
for agent in agents {
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": agent
}
}
}
});
let (status, init_headers, init_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::OK, "{agent}: initialize status");
let init_json = parse_json(&init_body);
assert_eq!(init_json["jsonrpc"], "2.0", "{agent}: initialize jsonrpc");
assert_eq!(init_json["id"], 1, "{agent}: initialize id");
let connection_id = init_headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id");
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": agent
}
}
}
});
let (status, _, new_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK, "{agent}: session/new status");
let new_json = parse_json(&new_body);
assert_eq!(new_json["jsonrpc"], "2.0", "{agent}: session/new jsonrpc");
assert_eq!(new_json["id"], 2, "{agent}: session/new id");
let session_id = new_json["result"]["sessionId"]
.as_str()
.expect("session id");
let prompt = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [{"type": "text", "text": "ping"}]
}
});
let (status, _, prompt_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK, "{agent}: prompt status");
let prompt_json = parse_json(&prompt_body);
assert_eq!(prompt_json["jsonrpc"], "2.0", "{agent}: prompt jsonrpc");
assert_eq!(prompt_json["id"], 3, "{agent}: prompt id");
assert_eq!(
prompt_json["result"]["stopReason"], "end_turn",
"{agent}: prompt stop reason"
);
let sse_chunk = read_first_sse_chunk(&test_app.app, connection_id).await;
let sse_envelope = parse_sse_data(&sse_chunk);
assert_eq!(sse_envelope["jsonrpc"], "2.0", "{agent}: SSE jsonrpc");
assert_eq!(
sse_envelope["method"], "session/update",
"{agent}: SSE method"
);
assert!(
sse_envelope["params"]["update"]["content"]["text"]
.as_str()
.is_some_and(|text| text.contains(agent)),
"{agent}: SSE content text"
);
let (close_status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(
close_status,
StatusCode::NO_CONTENT,
"{agent}: close status"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn one_agent_process_is_shared_across_connections() {
let test_app = TestApp::with_setup(|install_dir| {
let counter_file = install_dir.join("codex-process-start-count.txt");
setup_stub_artifacts(install_dir, "codex");
let launcher = install_dir.join("agent_processes").join("codex-acp");
write_stub_agent_process_with_counter(&launcher, "codex", Some(&counter_file));
});
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, headers_a, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize.clone()),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let connection_a = headers_a
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id")
.to_string();
let (status, headers_b, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let connection_b = headers_b
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id")
.to_string();
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session.clone()),
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_b)],
)
.await;
assert_eq!(status, StatusCode::OK);
let counter_file = test_app
._install_dir
.path()
.join("codex-process-start-count.txt");
let count = fs::read_to_string(counter_file).expect("read process start count file");
assert_eq!(count.trim(), "1");
}
#[cfg(unix)]
#[tokio::test]
async fn session_list_is_global_across_agents() {
let test_app = TestApp::with_setup(|install_dir| {
setup_stub_artifacts(install_dir, "claude");
setup_stub_artifacts(install_dir, "codex");
});
let initialize = |id: i64, agent: &str| {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": agent
}
}
}
})
};
let (status, claude_headers, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize(1, "claude")),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let claude_conn = claude_headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id")
.to_string();
let (status, codex_headers, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize(2, "codex")),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let codex_conn = codex_headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id")
.to_string();
let new_session = |id: i64, agent: &str| {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": agent
}
}
}
})
};
let (status, _, claude_session_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session(3, "claude")),
&[("x-acp-connection-id", &claude_conn)],
)
.await;
assert_eq!(status, StatusCode::OK);
let claude_session = parse_json(&claude_session_body)["result"]["sessionId"]
.as_str()
.expect("claude session")
.to_string();
let (status, _, codex_session_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session(4, "codex")),
&[("x-acp-connection-id", &codex_conn)],
)
.await;
assert_eq!(status, StatusCode::OK);
let codex_session = parse_json(&codex_session_body)["result"]["sessionId"]
.as_str()
.expect("codex session")
.to_string();
let list_request = json!({
"jsonrpc": "2.0",
"id": 5,
"method": "session/list",
"params": {}
});
let (status, _, list_body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(list_request),
&[("x-acp-connection-id", &claude_conn)],
)
.await;
assert_eq!(status, StatusCode::OK);
let list_json = parse_json(&list_body);
let sessions = list_json["result"]["sessions"]
.as_array()
.expect("sessions");
assert!(sessions
.iter()
.any(|session| session["sessionId"] == claude_session));
assert!(sessions
.iter()
.any(|session| session["sessionId"] == codex_session));
}
#[cfg(unix)]
#[tokio::test]
async fn list_models_extension_uses_non_mock_agent_process() {
let test_app = TestApp::with_setup(|install_dir| {
setup_stub_artifacts(install_dir, "codex");
});
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, headers, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let connection_id = headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id")
.to_string();
let list_models = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "_sandboxagent/session/list_models",
"params": {
"agent": "codex"
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(list_models),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let models = parsed["result"]["availableModels"]
.as_array()
.expect("available models");
assert!(
models.iter().any(|model| model["modelId"] == "codex-model"),
"expected codex model in {models:?}"
);
assert_eq!(parsed["result"]["currentModelId"], "codex-model");
}

View file

@ -1,569 +0,0 @@
use super::*;
#[tokio::test]
async fn initialize_advertises_sandbox_extensions() {
let test_app = TestApp::new(AuthConfig::disabled());
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let extensions =
&parsed["result"]["agentCapabilities"]["_meta"]["sandboxagent.dev"]["extensions"];
assert_eq!(extensions["sessionDetach"], true);
assert_eq!(extensions["sessionListModels"], true);
assert_eq!(extensions["sessionSetMetadata"], true);
assert_eq!(extensions["sessionAgentMeta"], true);
assert_eq!(extensions["agentList"], true);
assert_eq!(extensions["agentInstall"], true);
assert_eq!(extensions["sessionList"], true);
assert_eq!(extensions["sessionGet"], true);
assert_eq!(extensions["fsListEntries"], true);
assert_eq!(extensions["fsReadFile"], true);
assert_eq!(extensions["fsWriteFile"], true);
assert_eq!(extensions["fsDeleteEntry"], true);
assert_eq!(extensions["fsMkdir"], true);
assert_eq!(extensions["fsMove"], true);
assert_eq!(extensions["fsStat"], true);
assert_eq!(extensions["fsUploadBatch"], true);
}
#[tokio::test]
async fn agent_list_extension_returns_agents() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let request = json!({
"jsonrpc": "2.0",
"id": 8,
"method": "_sandboxagent/agent/list",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let agents = parsed["result"]["agents"].as_array().expect("agents array");
assert!(
agents.iter().any(|agent| agent["id"] == "mock"),
"expected mock agent in {agents:?}"
);
}
#[tokio::test]
async fn session_get_extension_returns_session() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 9,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let request = json!({
"jsonrpc": "2.0",
"id": 10,
"method": "_sandboxagent/session/get",
"params": {
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["sessionId"], session_id);
}
#[tokio::test]
async fn session_list_models_extension_returns_mock_catalog() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let request = json!({
"jsonrpc": "2.0",
"id": 33,
"method": "_sandboxagent/session/list_models",
"params": {
"agent": "mock"
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let models = parsed["result"]["availableModels"]
.as_array()
.expect("available models");
assert!(
models.iter().any(|model| model["modelId"] == "mock"),
"expected mock model in {models:?}"
);
assert_eq!(parsed["result"]["currentModelId"], "mock");
}
#[tokio::test]
async fn session_set_metadata_extension_updates_session_list_entry() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock",
"title": "From Meta",
"variant": "high",
"requestedSessionId": "alias-1"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let update = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "_sandboxagent/session/set_metadata",
"params": {
"sessionId": session_id,
"metadata": {
"title": "Updated Title",
"permissionMode": "ask",
"model": "mock"
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(update),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"], json!({}));
let list_request = json!({
"jsonrpc": "2.0",
"id": 4,
"method": "session/list",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(list_request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let sessions = parsed["result"]["sessions"]
.as_array()
.expect("sessions array");
let entry = sessions
.iter()
.find(|session| session["sessionId"] == session_id)
.expect("session entry");
assert_eq!(entry["title"], "Updated Title");
assert_eq!(entry["_meta"]["sandboxagent.dev"]["variant"], "high");
assert_eq!(
entry["_meta"]["sandboxagent.dev"]["requestedSessionId"],
"alias-1"
);
assert_eq!(entry["_meta"]["sandboxagent.dev"]["permissionMode"], "ask");
assert_eq!(entry["_meta"]["sandboxagent.dev"]["model"], "mock");
}
#[tokio::test]
async fn session_list_is_shared_across_clients() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_a = create_mock_connection(&test_app.app, &[]).await;
let connection_b = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::OK);
let created_session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let list_request = json!({
"jsonrpc": "2.0",
"id": 77,
"method": "session/list",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(list_request),
&[("x-acp-connection-id", &connection_b)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let sessions = parsed["result"]["sessions"]
.as_array()
.expect("sessions array");
assert!(
sessions
.iter()
.any(|session| session["sessionId"] == created_session_id),
"expected shared session list to include {created_session_id}, got {sessions:?}"
);
}
#[tokio::test]
async fn session_detach_stops_stream_delivery_for_that_client() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_a = create_mock_connection(&test_app.app, &[]).await;
let connection_b = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let load_session = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "session/load",
"params": {
"sessionId": session_id,
"cwd": "/tmp",
"mcpServers": []
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(load_session),
&[("x-acp-connection-id", &connection_b)],
)
.await;
assert_eq!(status, StatusCode::OK);
let leave = json!({
"jsonrpc": "2.0",
"id": 4,
"method": "_sandboxagent/session/detach",
"params": {
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(leave),
&[("x-acp-connection-id", &connection_b)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"], json!({}));
let prompt = json!({
"jsonrpc": "2.0",
"id": 5,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [{"type": "text", "text": "hello"}]
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::OK);
let got_data = sse_has_data_with_last_id(
&test_app.app,
&connection_b,
10_000,
Duration::from_millis(400),
)
.await;
assert!(
!got_data,
"connection should not receive session updates after leave"
);
}
#[tokio::test]
async fn session_terminate_extension_is_idempotent_and_emits_session_ended() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 210,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let terminate = json!({
"jsonrpc": "2.0",
"id": 211,
"method": "_sandboxagent/session/terminate",
"params": {
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(terminate),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["terminated"], true);
let ended_chunk = read_sse_until_contains(
&test_app.app,
&connection_id,
0,
"_sandboxagent/session/ended",
6,
)
.await
.expect("expected session ended event");
let ended_payload = parse_sse_data(&ended_chunk);
assert_eq!(ended_payload["method"], "_sandboxagent/session/ended");
assert_eq!(ended_payload["params"]["session_id"], session_id);
let terminate_again = json!({
"jsonrpc": "2.0",
"id": 212,
"method": "_sandboxagent/session/terminate",
"params": {
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(terminate_again),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["terminated"], false);
}
#[tokio::test]
async fn delete_acp_detaches_but_does_not_terminate_session() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_a = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 220,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", &connection_a)],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let connection_b = create_mock_connection(&test_app.app, &[]).await;
let load = json!({
"jsonrpc": "2.0",
"id": 221,
"method": "session/load",
"params": {
"sessionId": session_id,
"cwd": "/tmp",
"mcpServers": []
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(load),
&[("x-acp-connection-id", &connection_b)],
)
.await;
assert_eq!(status, StatusCode::OK);
}

View file

@ -1,623 +0,0 @@
use super::*;
#[tokio::test]
async fn acp_mock_prompt_flow_and_replay() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let prompt = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [
{
"type": "text",
"text": "hello"
}
]
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["stopReason"], "end_turn");
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
assert!(sse_chunk.contains("session/update"), "{sse_chunk}");
assert!(sse_chunk.contains("mock:"), "{sse_chunk}");
assert!(!sse_chunk.contains("mock: hello"), "{sse_chunk}");
}
#[tokio::test]
async fn acp_delete_is_idempotent() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let prompt = json!({
"jsonrpc": "2.0",
"id": 99,
"method": "session/prompt",
"params": {
"sessionId": "mock-session-1",
"prompt": [{"type": "text", "text": "ping"}]
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn session_cancel_notification_emits_cancelled_update() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let cancel = json!({
"jsonrpc": "2.0",
"method": "session/cancel",
"params": {
"sessionId": "mock-session-1",
"agent": "mock"
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(cancel),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
assert!(sse_chunk.contains("session/update"), "{sse_chunk}");
assert!(sse_chunk.contains("cancelled"), "{sse_chunk}");
}
#[tokio::test]
async fn hitl_permission_request_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let prompt = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [{"type": "text", "text": "needs permission"}]
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
assert!(
sse_chunk.contains("session/request_permission"),
"{sse_chunk}"
);
let permission_request = parse_sse_data(&sse_chunk);
let permission_response = json!({
"jsonrpc": "2.0",
"id": permission_request["id"].clone(),
"result": {
"outcome": {
"outcome": "cancelled"
}
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(permission_response),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
}
#[tokio::test]
async fn invalid_acp_envelope_returns_bad_request() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) =
send_request(&test_app.app, Method::POST, "/v2/rpc", Some(json!([])), &[]).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let parsed = parse_json(&body);
assert_eq!(parsed["status"], 400);
}
#[tokio::test]
async fn post_requires_json_content_type() {
let test_app = TestApp::new(AuthConfig::disabled());
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0","clientCapabilities":{}}}"#
.to_vec();
let (status, _, body) = send_request_raw(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(payload),
&[],
Some("text/plain"),
)
.await;
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(parse_json(&body)["status"], 415);
}
#[tokio::test]
async fn post_rejects_non_json_accept() {
let test_app = TestApp::new(AuthConfig::disabled());
let payload = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(payload),
&[("accept", "text/event-stream")],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
assert_eq!(parse_json(&body)["status"], 406);
}
#[tokio::test]
async fn post_rejects_removed_x_acp_agent_header() {
let test_app = TestApp::new(AuthConfig::disabled());
let payload = initialize_payload("mock");
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(payload),
&[("x-acp-agent", "mock")],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(parse_json(&body)["status"], 400);
}
#[tokio::test]
async fn session_new_requires_sandbox_meta_agent() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 42,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": []
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(parse_json(&body)["status"], 400);
}
#[tokio::test]
async fn unstable_methods_available_on_mock() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let methods = [
"session/list",
"session/fork",
"session/resume",
"session/set_model",
"$/cancel_request",
];
for (index, method) in methods.iter().enumerate() {
let request = json!({
"jsonrpc": "2.0",
"id": index + 10,
"method": method,
"params": {
"agent": "mock"
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK, "{method}");
assert!(parse_json(&body).get("result").is_some(), "{method}");
}
}
#[tokio::test]
async fn sse_replay_honors_last_event_id() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (_status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
for (id, text) in [(3, "one"), (4, "two")] {
let prompt = json!({
"jsonrpc": "2.0",
"id": id,
"method": "session/prompt",
"params": {
"sessionId": session_id,
"prompt": [{"type": "text", "text": text}]
}
});
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(prompt),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
}
// First stream event should have id > 1 when replaying from Last-Event-ID=1.
let replay_chunk = read_first_sse_data_with_last_id(&test_app.app, &connection_id, 1).await;
assert!(
replay_chunk.contains("id: 2") || replay_chunk.contains("id: 3"),
"{replay_chunk}"
);
}
#[tokio::test]
async fn post_with_unknown_connection_returns_not_found() {
let test_app = TestApp::new(AuthConfig::disabled());
let request = json!({
"jsonrpc": "2.0",
"id": 7,
"method": "session/new",
"params": { "cwd": "/", "mcpServers": [] }
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", "missing-connection")],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let parsed = parse_json(&body);
assert_eq!(parsed["status"], 404);
assert_eq!(parsed["title"], "ACP client not found");
}
#[tokio::test]
async fn sse_requires_connection_id_header() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v2/rpc", None, &[]).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
parse_json(&body)["detail"],
"invalid request: missing x-acp-connection-id header"
);
}
#[tokio::test]
async fn sse_rejects_non_sse_accept() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v2/rpc",
None,
&[
("x-acp-connection-id", &connection_id),
("accept", "application/json"),
],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
assert_eq!(parse_json(&body)["status"], 406);
}
#[tokio::test]
async fn sse_single_active_stream_per_connection() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let first_request = Request::builder()
.method(Method::GET)
.uri("/v2/rpc")
.header("x-acp-connection-id", connection_id.as_str())
.header("accept", "text/event-stream")
.body(Body::empty())
.expect("build first sse request");
let first_response = test_app
.app
.clone()
.oneshot(first_request)
.await
.expect("first sse response");
assert_eq!(first_response.status(), StatusCode::OK);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v2/rpc",
None,
&[
("x-acp-connection-id", connection_id.as_str()),
("accept", "text/event-stream"),
],
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(parse_json(&body)["status"], 409);
drop(first_response);
}
#[tokio::test]
async fn delete_requires_connection_id_header() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(&test_app.app, Method::DELETE, "/v2/rpc", None, &[]).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
parse_json(&body)["detail"],
"invalid request: missing x-acp-connection-id header"
);
}
#[tokio::test]
async fn invalid_last_event_id_returns_bad_request() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v2/rpc",
None,
&[
("x-acp-connection-id", &connection_id),
("last-event-id", "not-a-number"),
],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
parse_json(&body)["detail"],
"invalid request: Last-Event-ID must be a positive integer"
);
}
#[tokio::test]
#[serial]
async fn agent_process_request_timeout_maps_to_gateway_timeout() {
let test_app = {
let _timeout = EnvVarGuard::set("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS", "75");
let _close_grace = EnvVarGuard::set("SANDBOX_AGENT_ACP_CLOSE_GRACE_MS", "10");
TestApp::with_setup(AuthConfig::disabled(), |install_path| {
fs::create_dir_all(install_path.join("agent_processes"))
.expect("create agent processes dir");
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
write_executable(
&install_path.join("agent_processes").join("codex-acp"),
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do sleep 10; done\n",
);
})
};
assert!(test_app.install_path().exists(), "install dir should exist");
let initialize_notification = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, headers, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize_notification),
&[],
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let connection_id = headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id");
let request = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(request),
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
assert_eq!(parse_json(&body)["status"], 504);
let (close_status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(close_status, StatusCode::NO_CONTENT);
}

View file

@ -1,366 +0,0 @@
use super::*;
#[tokio::test]
async fn v2_health_and_v1_removed() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v2/health", None, &[]).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["status"], "ok");
let (status, _, body) =
send_request(&test_app.app, Method::GET, "/v1/anything", None, &[]).await;
assert_eq!(status, StatusCode::GONE);
assert_eq!(parse_json(&body)["detail"], "v1 API removed; use /v2");
let (status, _, body) =
send_request(&test_app.app, Method::GET, "/opencode/session", None, &[]).await;
assert_eq!(status, StatusCode::OK);
assert!(parse_json(&body).is_array());
}
#[tokio::test]
async fn v2_sessions_http_endpoints_removed_and_acp_extensions_work() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 100,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v2/sessions", None, &[]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _, _) = send_request(
&test_app.app,
Method::GET,
&format!("/v2/sessions/{session_id}"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let list_request = json!({
"jsonrpc": "2.0",
"id": 101,
"method": "_sandboxagent/session/list",
"params": {}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(list_request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
let sessions = parsed["result"]["sessions"].as_array().expect("sessions");
assert!(
sessions
.iter()
.any(|entry| entry["sessionId"] == session_id),
"expected listed session in {sessions:?}"
);
let get_request = json!({
"jsonrpc": "2.0",
"id": 102,
"method": "_sandboxagent/session/get",
"params": {
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(get_request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let parsed = parse_json(&body);
assert_eq!(parsed["result"]["sessionId"], session_id);
}
#[tokio::test]
async fn v2_filesystem_endpoints_round_trip() {
let test_app = TestApp::new(AuthConfig::disabled());
let connection_id = create_mock_connection(&test_app.app, &[]).await;
let new_session = json!({
"jsonrpc": "2.0",
"id": 200,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": [],
"_meta": {
"sandboxagent.dev": {
"agent": "mock"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(new_session),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
let session_id = parse_json(&body)["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
let (status, _, _) = send_request(
&test_app.app,
Method::POST,
&format!("/v2/fs/mkdir?path=docs&session_id={session_id}"),
None,
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let mkdir_request = json!({
"jsonrpc": "2.0",
"id": 201,
"method": "_sandboxagent/fs/mkdir",
"params": {
"path": "docs",
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(mkdir_request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(parse_json(&body)["result"]["path"].is_string());
let (status, _, body) = send_request_raw(
&test_app.app,
Method::PUT,
&format!("/v2/fs/file?path=docs/file.txt&session_id={session_id}"),
Some(b"hello".to_vec()),
&[],
Some("application/octet-stream"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["bytesWritten"], 5);
let (status, headers, body) = send_request_raw(
&test_app.app,
Method::GET,
&format!("/v2/fs/file?path=docs/file.txt&session_id={session_id}"),
None,
&[],
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
"application/octet-stream"
);
assert_eq!(String::from_utf8_lossy(&body), "hello");
let stat_request = json!({
"jsonrpc": "2.0",
"id": 202,
"method": "_sandboxagent/fs/stat",
"params": {
"path": "docs/file.txt",
"sessionId": session_id
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(stat_request),
&[("x-acp-connection-id", &connection_id)],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["result"]["entryType"], "file");
}
#[tokio::test]
async fn v2_auth_enforced_when_token_configured() {
let test_app = TestApp::new(AuthConfig::with_token("secret-token".to_string()));
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v2/health", None, &[]).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, body) = send_request(
&test_app.app,
Method::GET,
"/v2/health",
None,
&[("authorization", "Bearer secret-token")],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(parse_json(&body)["status"], "ok");
}
#[tokio::test]
#[serial]
async fn require_preinstall_blocks_missing_agent() {
let test_app = {
let _preinstall = EnvVarGuard::set("SANDBOX_AGENT_REQUIRE_PREINSTALL", "true");
TestApp::new(AuthConfig::disabled())
};
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, _, body) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize),
&[],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let parsed = parse_json(&body);
assert_eq!(parsed["status"], 404);
assert_eq!(parsed["title"], "Agent Not Installed");
}
#[tokio::test]
#[serial]
async fn lazy_install_runs_on_first_initialize() {
let registry_url = serve_registry_once(json!({
"agents": [
{
"id": "codex-acp",
"version": "1.2.3",
"distribution": {
"npx": {
"package": "@example/codex-acp@1.2.3",
"args": [],
"env": {}
}
}
}
]
}));
let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", &registry_url);
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
fs::create_dir_all(install_path.join("agent_processes"))
.expect("create agent processes dir");
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
write_executable(
&install_path.join("bin").join("npx"),
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
);
});
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![test_app.install_path().join("bin")];
paths.extend(std::env::split_paths(&original_path));
let merged_path = std::env::join_paths(paths).expect("join PATH");
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
let initialize_notification = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "1.0",
"clientCapabilities": {},
"_meta": {
"sandboxagent.dev": {
"agent": "codex"
}
}
}
});
let (status, headers, _) = send_request(
&test_app.app,
Method::POST,
"/v2/rpc",
Some(initialize_notification),
&[],
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let launcher_path = test_app
.install_path()
.join("agent_processes")
.join("codex-acp");
assert!(
launcher_path.exists(),
"expected lazy install to create agent process launcher"
);
let connection_id = headers
.get("x-acp-connection-id")
.and_then(|value| value.to_str().ok())
.expect("connection id");
let (close_status, _, _) = send_request(
&test_app.app,
Method::DELETE,
"/v2/rpc",
None,
&[("x-acp-connection-id", connection_id)],
)
.await;
assert_eq!(close_status, StatusCode::NO_CONTENT);
}