diff --git a/Cargo.toml b/Cargo.toml index 5fecee4..f60797c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["engine/packages/*"] +members = ["server/packages/*"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index f90b3b4..e0d149c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes - **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, and more - **Lightweight, portable Rust binary**: Install anywhere with 1 curl command +## Architecture + +- TODO + - Embedded (runs agents locally) + - Sandboxed + ## Project Goals This project aims to solve 3 problems with agents: diff --git a/docs/typescript-sdk.mdx b/docs/typescript-sdk.mdx index 066e5d1..cab36a3 100644 --- a/docs/typescript-sdk.mdx +++ b/docs/typescript-sdk.mdx @@ -31,6 +31,24 @@ await client.postMessage("my-session", { message: "Hello" }); const events = await client.getEvents("my-session", { offset: 0, limit: 50 }); ``` +## Autospawn (Node only) + +```ts +import { connectSandboxDaemonClient } from "sandbox-agent"; + +const client = await connectSandboxDaemonClient({ + spawn: { enabled: true }, +}); + +await client.createSession("my-session", { agent: "claude" }); +await client.postMessage("my-session", { message: "Hello" }); + +await client.dispose(); +``` + +Autospawn uses the local `sandbox-agent` binary. Install `@sandbox-agent/cli` (recommended), or +set `SANDBOX_AGENT_BIN` to the binary path. + ## Endpoint mapping
diff --git a/scripts/release/main.ts b/scripts/release/main.ts index cbbf515..559dfb3 100755 --- a/scripts/release/main.ts +++ b/scripts/release/main.ts @@ -317,7 +317,7 @@ function publishCrates(rootDir: string, version: string) { for (const crate of CRATE_ORDER) { console.log(`==> Publishing sandbox-agent-${crate}`); - const crateDir = path.join(rootDir, "engine", "packages", crate); + const crateDir = path.join(rootDir, "server", "packages", crate); run("cargo", ["publish", "--allow-dirty"], { cwd: crateDir }); // Wait for crates.io index propagation console.log("Waiting 30s for index..."); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index f0f9e3d..e3a03f2 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,4 +1,8 @@ import type { components } from "./generated/openapi.js"; +import type { + SandboxDaemonSpawnHandle, + SandboxDaemonSpawnOptions, +} from "./spawn.js"; export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"]; export type AgentModeInfo = components["schemas"]["AgentModeInfo"]; @@ -25,6 +29,14 @@ export interface SandboxDaemonClientOptions { headers?: HeadersInit; } +export interface SandboxDaemonConnectOptions { + baseUrl?: string; + token?: string; + fetch?: typeof fetch; + headers?: HeadersInit; + spawn?: SandboxDaemonSpawnOptions | boolean; +} + export class SandboxDaemonError extends Error { readonly status: number; readonly problem?: ProblemDetails; @@ -53,6 +65,7 @@ export class SandboxDaemonClient { private readonly token?: string; private readonly fetcher: typeof fetch; private readonly defaultHeaders?: HeadersInit; + private spawnHandle?: SandboxDaemonSpawnHandle; constructor(options: SandboxDaemonClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); @@ -65,6 +78,32 @@ export class SandboxDaemonClient { } } + static async connect(options: SandboxDaemonConnectOptions): Promise { + const spawnOptions = normalizeSpawnOptions(options.spawn, !options.baseUrl); + if (!spawnOptions.enabled) { + if (!options.baseUrl) { + throw new Error("baseUrl is required when autospawn is disabled."); + } + return new SandboxDaemonClient({ + baseUrl: options.baseUrl, + token: options.token, + fetch: options.fetch, + headers: options.headers, + }); + } + + const { spawnSandboxDaemon } = await import("./spawn.js"); + const handle = await spawnSandboxDaemon(spawnOptions, options.fetch ?? globalThis.fetch); + const client = new SandboxDaemonClient({ + baseUrl: handle.baseUrl, + token: handle.token, + fetch: options.fetch, + headers: options.headers, + }); + client.spawnHandle = handle; + return client; + } + async listAgents(): Promise { return this.requestJson("GET", `${API_PREFIX}/agents`); } @@ -171,6 +210,13 @@ export class SandboxDaemonClient { ); } + async dispose(): Promise { + if (this.spawnHandle) { + await this.spawnHandle.dispose(); + this.spawnHandle = undefined; + } + } + private async requestJson(method: string, path: string, options: RequestOptions = {}): Promise { const response = await this.requestRaw(method, path, { query: options.query, @@ -252,3 +298,22 @@ export class SandboxDaemonClient { export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => { return new SandboxDaemonClient(options); }; + +export const connectSandboxDaemonClient = ( + options: SandboxDaemonConnectOptions, +): Promise => { + return SandboxDaemonClient.connect(options); +}; + +const normalizeSpawnOptions = ( + spawn: SandboxDaemonSpawnOptions | boolean | undefined, + defaultEnabled: boolean, +): SandboxDaemonSpawnOptions => { + if (typeof spawn === "boolean") { + return { enabled: spawn }; + } + if (spawn) { + return { enabled: spawn.enabled ?? defaultEnabled, ...spawn }; + } + return { enabled: defaultEnabled }; +}; diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index ea7a4ef..cd0df9d 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,4 +1,9 @@ -export { SandboxDaemonClient, SandboxDaemonError, createSandboxDaemonClient } from "./client.js"; +export { + SandboxDaemonClient, + SandboxDaemonError, + connectSandboxDaemonClient, + createSandboxDaemonClient, +} from "./client.js"; export type { AgentInfo, AgentInstallRequest, @@ -15,5 +20,8 @@ export type { ProblemDetails, QuestionReplyRequest, UniversalEvent, + SandboxDaemonClientOptions, + SandboxDaemonConnectOptions, } from "./client.js"; export type { components, paths } from "./generated/openapi.js"; +export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js"; diff --git a/sdks/typescript/src/spawn.ts b/sdks/typescript/src/spawn.ts new file mode 100644 index 0000000..8977be1 --- /dev/null +++ b/sdks/typescript/src/spawn.ts @@ -0,0 +1,221 @@ +import type { ChildProcess } from "node:child_process"; +import type { AddressInfo } from "node:net"; +import type { NodeRequire } from "node:module"; + +export type SandboxDaemonSpawnLogMode = "inherit" | "pipe" | "silent"; + +export type SandboxDaemonSpawnOptions = { + enabled?: boolean; + host?: string; + port?: number; + token?: string; + binaryPath?: string; + timeoutMs?: number; + log?: SandboxDaemonSpawnLogMode; + env?: Record; +}; + +export type SandboxDaemonSpawnHandle = { + baseUrl: string; + token: string; + child: ChildProcess; + dispose: () => Promise; +}; + +const PLATFORM_PACKAGES: Record = { + "darwin-arm64": "@sandbox-agent/cli-darwin-arm64", + "darwin-x64": "@sandbox-agent/cli-darwin-x64", + "linux-x64": "@sandbox-agent/cli-linux-x64", + "win32-x64": "@sandbox-agent/cli-win32-x64", +}; + +export function isNodeRuntime(): boolean { + return typeof process !== "undefined" && !!process.versions?.node; +} + +export async function spawnSandboxDaemon( + options: SandboxDaemonSpawnOptions, + fetcher?: typeof fetch, +): Promise { + if (!isNodeRuntime()) { + throw new Error("Autospawn requires a Node.js runtime."); + } + + const { + spawn, + } = await import("node:child_process"); + const crypto = await import("node:crypto"); + const fs = await import("node:fs"); + const path = await import("node:path"); + const net = await import("node:net"); + const { createRequire } = await import("node:module"); + + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? (await getFreePort(net, host)); + const token = options.token ?? crypto.randomBytes(24).toString("hex"); + const timeoutMs = options.timeoutMs ?? 15_000; + const logMode: SandboxDaemonSpawnLogMode = options.log ?? "inherit"; + + const binaryPath = + options.binaryPath ?? + resolveBinaryFromEnv(fs, path) ?? + resolveBinaryFromCliPackage(createRequire(import.meta.url), path, fs) ?? + resolveBinaryFromPath(fs, path); + + if (!binaryPath) { + throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN."); + } + + const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe"; + const args = ["--host", host, "--port", String(port), "--token", token]; + const child = spawn(binaryPath, args, { + stdio, + env: { + ...process.env, + ...(options.env ?? {}), + }, + }); + const cleanup = registerProcessCleanup(child); + + const baseUrl = `http://${host}:${port}`; + const ready = waitForHealth(baseUrl, fetcher ?? globalThis.fetch, timeoutMs, child); + + await ready; + + const dispose = async () => { + if (child.exitCode !== null) { + cleanup.dispose(); + return; + } + child.kill("SIGTERM"); + const exited = await waitForExit(child, 5_000); + if (!exited) { + child.kill("SIGKILL"); + } + cleanup.dispose(); + }; + + return { baseUrl, token, child, dispose }; +} + +function resolveBinaryFromEnv(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null { + const value = process.env.SANDBOX_AGENT_BIN; + if (!value) { + return null; + } + const resolved = path.resolve(value); + if (fs.existsSync(resolved)) { + return resolved; + } + return null; +} + +function resolveBinaryFromCliPackage( + require: NodeRequire, + path: typeof import("node:path"), + fs: typeof import("node:fs"), +): string | null { + const key = `${process.platform}-${process.arch}`; + const pkg = PLATFORM_PACKAGES[key]; + if (!pkg) { + return null; + } + try { + const pkgPath = require.resolve(`${pkg}/package.json`); + const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent"; + const resolved = path.join(path.dirname(pkgPath), "bin", bin); + return fs.existsSync(resolved) ? resolved : null; + } catch { + return null; + } +} + +function resolveBinaryFromPath(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null { + const pathEnv = process.env.PATH ?? ""; + const separator = process.platform === "win32" ? ";" : ":"; + const candidates = pathEnv.split(separator).filter(Boolean); + const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent"; + for (const dir of candidates) { + const resolved = path.join(dir, bin); + if (fs.existsSync(resolved)) { + return resolved; + } + } + return null; +} + +async function getFreePort(net: typeof import("node:net"), host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, host, () => { + const address = server.address() as AddressInfo; + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForHealth( + baseUrl: string, + fetcher: typeof fetch | undefined, + timeoutMs: number, + child: ChildProcess, +): Promise { + if (!fetcher) { + throw new Error("Fetch API is not available; provide a fetch implementation."); + } + const start = Date.now(); + let lastError: string | undefined; + + while (Date.now() - start < timeoutMs) { + if (child.exitCode !== null) { + throw new Error("sandbox-agent exited before becoming healthy."); + } + try { + const response = await fetcher(`${baseUrl}/v1/health`); + if (response.ok) { + return; + } + lastError = `status ${response.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown error"}).`); +} + +async function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null) { + return true; + } + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + child.once("exit", () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +function registerProcessCleanup(child: ChildProcess): { dispose: () => void } { + const handler = () => { + if (child.exitCode === null) { + child.kill("SIGTERM"); + } + }; + + process.once("exit", handler); + process.once("SIGINT", handler); + process.once("SIGTERM", handler); + + return { + dispose: () => { + process.off("exit", handler); + process.off("SIGINT", handler); + process.off("SIGTERM", handler); + }, + }; +} diff --git a/engine/packages/agent-credentials/Cargo.toml b/server/packages/agent-credentials/Cargo.toml similarity index 100% rename from engine/packages/agent-credentials/Cargo.toml rename to server/packages/agent-credentials/Cargo.toml diff --git a/engine/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs similarity index 100% rename from engine/packages/agent-credentials/src/lib.rs rename to server/packages/agent-credentials/src/lib.rs diff --git a/engine/packages/agent-management/Cargo.toml b/server/packages/agent-management/Cargo.toml similarity index 100% rename from engine/packages/agent-management/Cargo.toml rename to server/packages/agent-management/Cargo.toml diff --git a/engine/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs similarity index 100% rename from engine/packages/agent-management/src/agents.rs rename to server/packages/agent-management/src/agents.rs diff --git a/engine/packages/agent-management/src/credentials.rs b/server/packages/agent-management/src/credentials.rs similarity index 100% rename from engine/packages/agent-management/src/credentials.rs rename to server/packages/agent-management/src/credentials.rs diff --git a/engine/packages/agent-management/src/lib.rs b/server/packages/agent-management/src/lib.rs similarity index 68% rename from engine/packages/agent-management/src/lib.rs rename to server/packages/agent-management/src/lib.rs index 306105f..9ef62da 100644 --- a/engine/packages/agent-management/src/lib.rs +++ b/server/packages/agent-management/src/lib.rs @@ -1,2 +1,3 @@ pub mod agents; pub mod credentials; +pub mod testing; diff --git a/server/packages/agent-management/src/testing.rs b/server/packages/agent-management/src/testing.rs new file mode 100644 index 0000000..f334a34 --- /dev/null +++ b/server/packages/agent-management/src/testing.rs @@ -0,0 +1,127 @@ +use std::env; + +use thiserror::Error; + +use crate::agents::AgentId; +use crate::credentials::{AuthType, ExtractedCredentials, ProviderCredentials}; + +#[derive(Debug, Clone)] +pub struct TestAgentConfig { + pub agent: AgentId, + pub credentials: ExtractedCredentials, +} + +#[derive(Debug, Error)] +pub enum TestAgentConfigError { + #[error("no test agents configured (set SANDBOX_TEST_AGENTS)")] + NoAgentsConfigured, + #[error("unknown agent name: {0}")] + UnknownAgent(String), + #[error("missing credentials for {agent}: {missing}")] + MissingCredentials { agent: AgentId, missing: String }, +} + +const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS"; +const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY"; +const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY"; + +pub fn test_agents_from_env() -> Result, TestAgentConfigError> { + let raw_agents = env::var(AGENTS_ENV).unwrap_or_default(); + let mut agents = Vec::new(); + for entry in raw_agents.split(',') { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed == "all" { + agents.extend([ + AgentId::Claude, + AgentId::Codex, + AgentId::Opencode, + AgentId::Amp, + ]); + continue; + } + let agent = AgentId::parse(trimmed) + .ok_or_else(|| TestAgentConfigError::UnknownAgent(trimmed.to_string()))?; + agents.push(agent); + } + + if agents.is_empty() { + return Err(TestAgentConfigError::NoAgentsConfigured); + } + + let anthropic_key = read_env_key(ANTHROPIC_ENV); + let openai_key = read_env_key(OPENAI_ENV); + + let mut configs = Vec::new(); + for agent in agents { + let credentials = match agent { + AgentId::Claude | AgentId::Amp => { + let anthropic_key = anthropic_key.clone().ok_or_else(|| { + TestAgentConfigError::MissingCredentials { + agent, + missing: ANTHROPIC_ENV.to_string(), + } + })?; + credentials_with(anthropic_key, None) + } + AgentId::Codex => { + let openai_key = openai_key.clone().ok_or_else(|| { + TestAgentConfigError::MissingCredentials { + agent, + missing: OPENAI_ENV.to_string(), + } + })?; + credentials_with(None, Some(openai_key)) + } + AgentId::Opencode => { + if anthropic_key.is_none() && openai_key.is_none() { + return Err(TestAgentConfigError::MissingCredentials { + agent, + missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"), + }); + } + credentials_with(anthropic_key.clone(), openai_key.clone()) + } + }; + configs.push(TestAgentConfig { agent, credentials }); + } + + Ok(configs) +} + +fn read_env_key(name: &str) -> Option { + env::var(name).ok().and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn credentials_with( + anthropic_key: Option, + openai_key: Option, +) -> ExtractedCredentials { + let mut credentials = ExtractedCredentials::default(); + if let Some(key) = anthropic_key { + credentials.anthropic = Some(ProviderCredentials { + api_key: key, + source: "sandbox-test-env".to_string(), + auth_type: AuthType::ApiKey, + provider: "anthropic".to_string(), + }); + } + if let Some(key) = openai_key { + credentials.openai = Some(ProviderCredentials { + api_key: key, + source: "sandbox-test-env".to_string(), + auth_type: AuthType::ApiKey, + provider: "openai".to_string(), + }); + } + credentials +} diff --git a/engine/packages/agent-schema/Cargo.toml b/server/packages/agent-schema/Cargo.toml similarity index 100% rename from engine/packages/agent-schema/Cargo.toml rename to server/packages/agent-schema/Cargo.toml diff --git a/engine/packages/agent-schema/build.rs b/server/packages/agent-schema/build.rs similarity index 100% rename from engine/packages/agent-schema/build.rs rename to server/packages/agent-schema/build.rs diff --git a/engine/packages/agent-schema/src/lib.rs b/server/packages/agent-schema/src/lib.rs similarity index 100% rename from engine/packages/agent-schema/src/lib.rs rename to server/packages/agent-schema/src/lib.rs diff --git a/engine/packages/error/Cargo.toml b/server/packages/error/Cargo.toml similarity index 100% rename from engine/packages/error/Cargo.toml rename to server/packages/error/Cargo.toml diff --git a/engine/packages/error/src/lib.rs b/server/packages/error/src/lib.rs similarity index 100% rename from engine/packages/error/src/lib.rs rename to server/packages/error/src/lib.rs diff --git a/engine/packages/openapi-gen/Cargo.toml b/server/packages/openapi-gen/Cargo.toml similarity index 100% rename from engine/packages/openapi-gen/Cargo.toml rename to server/packages/openapi-gen/Cargo.toml diff --git a/engine/packages/openapi-gen/build.rs b/server/packages/openapi-gen/build.rs similarity index 100% rename from engine/packages/openapi-gen/build.rs rename to server/packages/openapi-gen/build.rs diff --git a/engine/packages/openapi-gen/src/lib.rs b/server/packages/openapi-gen/src/lib.rs similarity index 100% rename from engine/packages/openapi-gen/src/lib.rs rename to server/packages/openapi-gen/src/lib.rs diff --git a/engine/packages/openapi-gen/src/main.rs b/server/packages/openapi-gen/src/main.rs similarity index 100% rename from engine/packages/openapi-gen/src/main.rs rename to server/packages/openapi-gen/src/main.rs diff --git a/engine/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml similarity index 95% rename from engine/packages/sandbox-agent/Cargo.toml rename to server/packages/sandbox-agent/Cargo.toml index 3c621a4..39e1126 100644 --- a/engine/packages/sandbox-agent/Cargo.toml +++ b/server/packages/sandbox-agent/Cargo.toml @@ -33,4 +33,7 @@ tracing-logfmt = "0.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] +http-body-util = "0.1" +insta = "1.41" tempfile = "3.10" +tower = "0.4" diff --git a/engine/packages/sandbox-agent/src/credentials.rs b/server/packages/sandbox-agent/src/credentials.rs similarity index 100% rename from engine/packages/sandbox-agent/src/credentials.rs rename to server/packages/sandbox-agent/src/credentials.rs diff --git a/engine/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs similarity index 100% rename from engine/packages/sandbox-agent/src/lib.rs rename to server/packages/sandbox-agent/src/lib.rs diff --git a/engine/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs similarity index 100% rename from engine/packages/sandbox-agent/src/main.rs rename to server/packages/sandbox-agent/src/main.rs diff --git a/engine/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs similarity index 100% rename from engine/packages/sandbox-agent/src/router.rs rename to server/packages/sandbox-agent/src/router.rs diff --git a/engine/packages/sandbox-agent/tests/agents.rs b/server/packages/sandbox-agent/tests/agents.rs similarity index 100% rename from engine/packages/sandbox-agent/tests/agents.rs rename to server/packages/sandbox-agent/tests/agents.rs diff --git a/server/packages/sandbox-agent/tests/http_sse_snapshots.rs b/server/packages/sandbox-agent/tests/http_sse_snapshots.rs new file mode 100644 index 0000000..7198d90 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http_sse_snapshots.rs @@ -0,0 +1,465 @@ +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::Router; +use futures::StreamExt; +use http_body_util::BodyExt; +use serde_json::{json, Map, Value}; +use tempfile::TempDir; + +use sandbox_agent_agent_management::agents::{AgentId, AgentManager}; +use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig}; +use sandbox_agent_agent_credentials::ExtractedCredentials; +use sandbox_agent_core::router::{build_router, AppState, AuthConfig}; +use tower::ServiceExt; + +const PROMPT: &str = "Reply with exactly the single word OK."; + +struct TestApp { + app: Router, + _install_dir: TempDir, +} + +impl TestApp { + fn new() -> Self { + let install_dir = tempfile::tempdir().expect("create temp install dir"); + 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, + } + } +} + +struct EnvGuard { + saved: BTreeMap>, +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} + +fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard { + let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"]; + let mut saved = BTreeMap::new(); + for key in keys { + saved.insert(key.to_string(), std::env::var(key).ok()); + } + + match creds.anthropic.as_ref() { + Some(cred) => { + std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key); + std::env::set_var("CLAUDE_API_KEY", &cred.api_key); + } + None => { + std::env::remove_var("ANTHROPIC_API_KEY"); + std::env::remove_var("CLAUDE_API_KEY"); + } + } + + match creds.openai.as_ref() { + Some(cred) => { + std::env::set_var("OPENAI_API_KEY", &cred.api_key); + std::env::set_var("CODEX_API_KEY", &cred.api_key); + } + None => { + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("CODEX_API_KEY"); + } + } + + EnvGuard { saved } +} + +async fn send_json(app: &Router, method: Method, path: &str, body: Option) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path); + let 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(body).expect("request"); + let response = app + .clone() + .oneshot(request) + .await + .expect("request handled"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("read body") + .to_bytes(); + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).to_string())) + }; + (status, value) +} + +async fn send_status(app: &Router, method: Method, path: &str, body: Option) -> StatusCode { + let (status, _) = send_json(app, method, path, body).await; + status +} + +async fn install_agent(app: &Router, agent: AgentId) { + let status = send_status( + app, + Method::POST, + &format!("/v1/agents/{}/install", agent.as_str()), + Some(json!({})), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "install {agent}"); +} + +async fn create_session(app: &Router, agent: AgentId, session_id: &str) { + let status = send_status( + app, + Method::POST, + &format!("/v1/sessions/{session_id}"), + Some(json!({ + "agent": agent.as_str(), + "permissionMode": "bypass" + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create session {agent}"); +} + +async fn send_message(app: &Router, session_id: &str) { + let status = send_status( + app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PROMPT })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "send message"); +} + +async fn poll_events_until( + app: &Router, + session_id: &str, + timeout: Duration, +) -> Vec { + let start = Instant::now(); + let mut offset = 0u64; + let mut events = Vec::new(); + while start.elapsed() < timeout { + let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200"); + let (status, payload) = send_json(app, Method::GET, &path, None).await; + assert_eq!(status, StatusCode::OK, "poll events"); + let new_events = payload + .get("events") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !new_events.is_empty() { + if let Some(last) = new_events.last().and_then(|event| event.get("id")).and_then(Value::as_u64) { + offset = last; + } + events.extend(new_events); + if should_stop(&events) { + break; + } + } + tokio::time::sleep(Duration::from_millis(800)).await; + } + events +} + +async fn read_sse_events( + app: &Router, + session_id: &str, + timeout: Duration, +) -> Vec { + let request = Request::builder() + .method(Method::GET) + .uri(format!("/v1/sessions/{session_id}/events/sse?offset=0")) + .body(Body::empty()) + .expect("sse request"); + let response = app + .clone() + .oneshot(request) + .await + .expect("sse response"); + assert_eq!(response.status(), StatusCode::OK, "sse status"); + + let mut stream = response.into_body().into_data_stream(); + let mut buffer = String::new(); + let mut events = Vec::new(); + let start = Instant::now(); + loop { + let remaining = match timeout.checked_sub(start.elapsed()) { + Some(remaining) if !remaining.is_zero() => remaining, + _ => break, + }; + let next = tokio::time::timeout(remaining, stream.next()).await; + let chunk = match next { + Ok(Some(Ok(chunk))) => chunk, + Ok(Some(Err(_))) => break, + Ok(None) => break, + Err(_) => break, + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(idx) = buffer.find("\n\n") { + let block = buffer[..idx].to_string(); + buffer = buffer[idx + 2..].to_string(); + if let Some(event) = parse_sse_block(&block) { + events.push(event); + } + } + if should_stop(&events) { + break; + } + } + events +} + +fn parse_sse_block(block: &str) -> Option { + let mut data_lines = Vec::new(); + for line in block.lines() { + if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim_start()); + } + } + if data_lines.is_empty() { + return None; + } + let data = data_lines.join("\n"); + serde_json::from_str(&data).ok() +} + +fn should_stop(events: &[Value]) -> bool { + events.iter().any(|event| is_assistant_message(event) || is_error_event(event)) +} + +fn is_assistant_message(event: &Value) -> bool { + event + .get("data") + .and_then(|data| data.get("message")) + .and_then(|message| message.get("role")) + .and_then(Value::as_str) + .map(|role| role == "assistant") + .unwrap_or(false) +} + +fn is_error_event(event: &Value) -> bool { + event + .get("data") + .and_then(|data| data.get("error")) + .is_some() +} + +fn normalize_events(events: &[Value]) -> Value { + let normalized = events + .iter() + .enumerate() + .map(|(idx, event)| normalize_event(event, idx + 1)) + .collect::>(); + Value::Array(normalized) +} + +fn normalize_event(event: &Value, seq: usize) -> Value { + let mut map = Map::new(); + map.insert("seq".to_string(), Value::Number(seq.into())); + if let Some(agent) = event.get("agent").and_then(Value::as_str) { + map.insert("agent".to_string(), Value::String(agent.to_string())); + } + let data = event.get("data").unwrap_or(&Value::Null); + if let Some(message) = data.get("message") { + map.insert("kind".to_string(), Value::String("message".to_string())); + map.insert("message".to_string(), normalize_message(message)); + } else if let Some(started) = data.get("started") { + map.insert("kind".to_string(), Value::String("started".to_string())); + map.insert("started".to_string(), normalize_started(started)); + } else if let Some(error) = data.get("error") { + map.insert("kind".to_string(), Value::String("error".to_string())); + map.insert("error".to_string(), normalize_error(error)); + } else if let Some(question) = data.get("questionAsked") { + map.insert("kind".to_string(), Value::String("question".to_string())); + map.insert("question".to_string(), normalize_question(question)); + } else if let Some(permission) = data.get("permissionAsked") { + map.insert("kind".to_string(), Value::String("permission".to_string())); + map.insert("permission".to_string(), normalize_permission(permission)); + } else { + map.insert("kind".to_string(), Value::String("unknown".to_string())); + } + Value::Object(map) +} + +fn normalize_message(message: &Value) -> Value { + let mut map = Map::new(); + if let Some(role) = message.get("role").and_then(Value::as_str) { + map.insert("role".to_string(), Value::String(role.to_string())); + } + if let Some(parts) = message.get("parts").and_then(Value::as_array) { + let parts = parts.iter().map(normalize_part).collect::>(); + map.insert("parts".to_string(), Value::Array(parts)); + } else if message.get("raw").is_some() { + map.insert("unparsed".to_string(), Value::Bool(true)); + } + Value::Object(map) +} + +fn normalize_part(part: &Value) -> Value { + let mut map = Map::new(); + if let Some(part_type) = part.get("type").and_then(Value::as_str) { + map.insert("type".to_string(), Value::String(part_type.to_string())); + } + if let Some(name) = part.get("name").and_then(Value::as_str) { + map.insert("name".to_string(), Value::String(name.to_string())); + } + if part.get("text").is_some() { + map.insert("text".to_string(), Value::String("".to_string())); + } + if part.get("input").is_some() { + map.insert("input".to_string(), Value::Bool(true)); + } + if part.get("output").is_some() { + map.insert("output".to_string(), Value::Bool(true)); + } + Value::Object(map) +} + +fn normalize_started(started: &Value) -> Value { + let mut map = Map::new(); + if let Some(message) = started.get("message").and_then(Value::as_str) { + map.insert("message".to_string(), Value::String(message.to_string())); + } + Value::Object(map) +} + +fn normalize_error(error: &Value) -> Value { + let mut map = Map::new(); + if let Some(kind) = error.get("kind").and_then(Value::as_str) { + map.insert("kind".to_string(), Value::String(kind.to_string())); + } + if let Some(message) = error.get("message").and_then(Value::as_str) { + map.insert("message".to_string(), Value::String(message.to_string())); + } + Value::Object(map) +} + +fn normalize_question(question: &Value) -> Value { + let mut map = Map::new(); + if question.get("id").is_some() { + map.insert("id".to_string(), Value::String("".to_string())); + } + if let Some(questions) = question.get("questions").and_then(Value::as_array) { + map.insert("count".to_string(), Value::Number(questions.len().into())); + } + Value::Object(map) +} + +fn normalize_permission(permission: &Value) -> Value { + let mut map = Map::new(); + if permission.get("id").is_some() { + map.insert("id".to_string(), Value::String("".to_string())); + } + if let Some(value) = permission.get("permission").and_then(Value::as_str) { + map.insert("permission".to_string(), Value::String(value.to_string())); + } + Value::Object(map) +} + +fn snapshot_name(prefix: &str, agent: AgentId) -> String { + format!("{prefix}_{}", agent.as_str()) +} + +async fn run_http_events_snapshot(app: &Router, config: &TestAgentConfig) { + let _guard = apply_credentials(&config.credentials); + install_agent(app, config.agent).await; + + let session_id = format!("session-{}", config.agent.as_str()); + create_session(app, config.agent, &session_id).await; + send_message(app, &session_id).await; + + let events = poll_events_until(app, &session_id, Duration::from_secs(120)).await; + assert!( + !events.is_empty(), + "no events collected for {}", + config.agent + ); + assert!( + should_stop(&events), + "timed out waiting for assistant/error event for {}", + config.agent + ); + let normalized = normalize_events(&events); + insta::with_settings!({ + snapshot_suffix => snapshot_name("http_events", config.agent), + }, { + insta::assert_yaml_snapshot!(normalized); + }); +} + +async fn run_sse_events_snapshot(app: &Router, config: &TestAgentConfig) { + let _guard = apply_credentials(&config.credentials); + install_agent(app, config.agent).await; + + let session_id = format!("sse-{}", config.agent.as_str()); + create_session(app, config.agent, &session_id).await; + + let sse_task = { + let app = app.clone(); + let session_id = session_id.clone(); + tokio::spawn(async move { + read_sse_events(&app, &session_id, Duration::from_secs(120)).await + }) + }; + + send_message(app, &session_id).await; + + let events = sse_task.await.expect("sse task"); + assert!( + !events.is_empty(), + "no sse events collected for {}", + config.agent + ); + assert!( + should_stop(&events), + "timed out waiting for assistant/error event for {}", + config.agent + ); + let normalized = normalize_events(&events); + insta::with_settings!({ + snapshot_suffix => snapshot_name("sse_events", config.agent), + }, { + insta::assert_yaml_snapshot!(normalized); + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn http_events_snapshots() { + let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS"); + let app = TestApp::new(); + for config in &configs { + run_http_events_snapshot(&app.app, config).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sse_events_snapshots() { + let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS"); + let app = TestApp::new(); + for config in &configs { + run_sse_events_snapshot(&app.app, config).await; + } +} diff --git a/engine/packages/universal-agent-schema/Cargo.toml b/server/packages/universal-agent-schema/Cargo.toml similarity index 100% rename from engine/packages/universal-agent-schema/Cargo.toml rename to server/packages/universal-agent-schema/Cargo.toml diff --git a/engine/packages/universal-agent-schema/src/agents/amp.rs b/server/packages/universal-agent-schema/src/agents/amp.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/agents/amp.rs rename to server/packages/universal-agent-schema/src/agents/amp.rs diff --git a/engine/packages/universal-agent-schema/src/agents/claude.rs b/server/packages/universal-agent-schema/src/agents/claude.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/agents/claude.rs rename to server/packages/universal-agent-schema/src/agents/claude.rs diff --git a/engine/packages/universal-agent-schema/src/agents/codex.rs b/server/packages/universal-agent-schema/src/agents/codex.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/agents/codex.rs rename to server/packages/universal-agent-schema/src/agents/codex.rs diff --git a/engine/packages/universal-agent-schema/src/agents/mod.rs b/server/packages/universal-agent-schema/src/agents/mod.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/agents/mod.rs rename to server/packages/universal-agent-schema/src/agents/mod.rs diff --git a/engine/packages/universal-agent-schema/src/agents/opencode.rs b/server/packages/universal-agent-schema/src/agents/opencode.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/agents/opencode.rs rename to server/packages/universal-agent-schema/src/agents/opencode.rs diff --git a/engine/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs similarity index 100% rename from engine/packages/universal-agent-schema/src/lib.rs rename to server/packages/universal-agent-schema/src/lib.rs