mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
refactor: rename engine/ to server/
This commit is contained in:
parent
016024c04b
commit
71ab40388c
37 changed files with 917 additions and 3 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["engine/packages/*"]
|
||||
members = ["server/packages/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
|
|
@ -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<SandboxDaemonClient> {
|
||||
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<AgentListResponse> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/agents`);
|
||||
}
|
||||
|
|
@ -171,6 +210,13 @@ export class SandboxDaemonClient {
|
|||
);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.spawnHandle) {
|
||||
await this.spawnHandle.dispose();
|
||||
this.spawnHandle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
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<SandboxDaemonClient> => {
|
||||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
221
sdks/typescript/src/spawn.ts
Normal file
221
sdks/typescript/src/spawn.ts
Normal file
|
|
@ -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<string, string>;
|
||||
};
|
||||
|
||||
export type SandboxDaemonSpawnHandle = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
child: ChildProcess;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
const PLATFORM_PACKAGES: Record<string, string> = {
|
||||
"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<SandboxDaemonSpawnHandle> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod agents;
|
||||
pub mod credentials;
|
||||
pub mod testing;
|
||||
127
server/packages/agent-management/src/testing.rs
Normal file
127
server/packages/agent-management/src/testing.rs
Normal file
|
|
@ -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<Vec<TestAgentConfig>, 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<String> {
|
||||
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<String>,
|
||||
openai_key: Option<String>,
|
||||
) -> 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal file
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal file
|
|
@ -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<String, Option<String>>,
|
||||
}
|
||||
|
||||
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<Value>) -> (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<Value>) -> 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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("<redacted>".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("<redacted>".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("<redacted>".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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue