chore: sync workspace changes

This commit is contained in:
Nathan Flurry 2026-01-27 05:06:33 -08:00
parent d24f983e2c
commit bf58891edf
139 changed files with 5454 additions and 8986 deletions

View file

@ -8,8 +8,7 @@
"url": "https://github.com/rivet-dev/sandbox-agent"
},
"bin": {
"sandbox-agent": "bin/sandbox-agent",
"sandbox-daemon": "bin/sandbox-agent"
"sandbox-agent": "bin/sandbox-agent"
},
"scripts": {
"test": "vitest run"

View file

@ -1,7 +1,4 @@
import type {
SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions,
} from "./spawn.ts";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
import type {
AgentInstallRequest,
AgentListResponse,
@ -21,29 +18,27 @@ import type {
const API_PREFIX = "/v1";
export interface SandboxDaemonClientOptions {
export interface SandboxAgentConnectOptions {
baseUrl: string;
token?: string;
fetch?: typeof fetch;
headers?: HeadersInit;
}
export interface SandboxDaemonConnectOptions {
baseUrl?: string;
token?: string;
export interface SandboxAgentStartOptions {
spawn?: SandboxAgentSpawnOptions | boolean;
fetch?: typeof fetch;
headers?: HeadersInit;
spawn?: SandboxDaemonSpawnOptions | boolean;
}
export class SandboxDaemonError extends Error {
export class SandboxAgentError extends Error {
readonly status: number;
readonly problem?: ProblemDetails;
readonly response: Response;
constructor(status: number, problem: ProblemDetails | undefined, response: Response) {
super(problem?.title ?? `Request failed with status ${status}`);
this.name = "SandboxDaemonError";
this.name = "SandboxAgentError";
this.status = status;
this.problem = problem;
this.response = response;
@ -60,14 +55,14 @@ type RequestOptions = {
signal?: AbortSignal;
};
export class SandboxDaemonClient {
export class SandboxAgent {
private readonly baseUrl: string;
private readonly token?: string;
private readonly fetcher: typeof fetch;
private readonly defaultHeaders?: HeadersInit;
private spawnHandle?: SandboxDaemonSpawnHandle;
private spawnHandle?: SandboxAgentSpawnHandle;
constructor(options: SandboxDaemonClientOptions) {
private constructor(options: SandboxAgentConnectOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.token = options.token;
this.fetcher = options.fetch ?? globalThis.fetch;
@ -78,23 +73,18 @@ 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,
});
}
static async connect(options: SandboxAgentConnectOptions): Promise<SandboxAgent> {
return new SandboxAgent(options);
}
const { spawnSandboxDaemon } = await import("./spawn.js");
const handle = await spawnSandboxDaemon(spawnOptions, options.fetch ?? globalThis.fetch);
const client = new SandboxDaemonClient({
static async start(options: SandboxAgentStartOptions = {}): Promise<SandboxAgent> {
const spawnOptions = normalizeSpawnOptions(options.spawn, true);
if (!spawnOptions.enabled) {
throw new Error("SandboxAgent.start requires spawn to be enabled.");
}
const { spawnSandboxAgent } = await import("./spawn.js");
const handle = await spawnSandboxAgent(spawnOptions, options.fetch ?? globalThis.fetch);
const client = new SandboxAgent({
baseUrl: handle.baseUrl,
token: handle.token,
fetch: options.fetch,
@ -277,7 +267,7 @@ export class SandboxDaemonClient {
const response = await this.fetcher(url, init);
if (!response.ok) {
const problem = await this.readProblem(response);
throw new SandboxDaemonError(response.status, problem, response);
throw new SandboxAgentError(response.status, problem, response);
}
return response;
@ -309,20 +299,10 @@ 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,
spawn: SandboxAgentSpawnOptions | boolean | undefined,
defaultEnabled: boolean,
): SandboxDaemonSpawnOptions => {
): SandboxAgentSpawnOptions => {
if (typeof spawn === "boolean") {
return { enabled: spawn };
}

View file

@ -51,10 +51,21 @@ export type webhooks = Record<string, never>;
export interface components {
schemas: {
AgentCapabilities: {
commandExecution: boolean;
errorEvents: boolean;
fileAttachments: boolean;
fileChanges: boolean;
images: boolean;
mcpTools: boolean;
permissions: boolean;
planMode: boolean;
questions: boolean;
reasoning: boolean;
sessionLifecycle: boolean;
streamingDeltas: boolean;
textMessages: boolean;
toolCalls: boolean;
toolResults: boolean;
};
AgentError: {
agent?: string | null;

View file

@ -1,12 +1,7 @@
export {
SandboxDaemonClient,
SandboxDaemonError,
connectSandboxDaemonClient,
createSandboxDaemonClient,
} from "./client.ts";
export { SandboxAgent, SandboxAgentError } from "./client.ts";
export type {
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
SandboxAgentConnectOptions,
SandboxAgentStartOptions,
} from "./client.ts";
export type {
AgentCapabilities,
@ -52,4 +47,4 @@ export type {
UniversalItem,
} from "./types.ts";
export type { components, paths } from "./generated/openapi.ts";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.ts";
export type { SandboxAgentSpawnOptions, SandboxAgentSpawnLogMode } from "./spawn.ts";

View file

@ -1,20 +1,20 @@
import type { ChildProcess } from "node:child_process";
import type { AddressInfo } from "node:net";
export type SandboxDaemonSpawnLogMode = "inherit" | "pipe" | "silent";
export type SandboxAgentSpawnLogMode = "inherit" | "pipe" | "silent";
export type SandboxDaemonSpawnOptions = {
export type SandboxAgentSpawnOptions = {
enabled?: boolean;
host?: string;
port?: number;
token?: string;
binaryPath?: string;
timeoutMs?: number;
log?: SandboxDaemonSpawnLogMode;
log?: SandboxAgentSpawnLogMode;
env?: Record<string, string>;
};
export type SandboxDaemonSpawnHandle = {
export type SandboxAgentSpawnHandle = {
baseUrl: string;
token: string;
child: ChildProcess;
@ -32,10 +32,10 @@ export function isNodeRuntime(): boolean {
return typeof process !== "undefined" && !!process.versions?.node;
}
export async function spawnSandboxDaemon(
options: SandboxDaemonSpawnOptions,
export async function spawnSandboxAgent(
options: SandboxAgentSpawnOptions,
fetcher?: typeof fetch,
): Promise<SandboxDaemonSpawnHandle> {
): Promise<SandboxAgentSpawnHandle> {
if (!isNodeRuntime()) {
throw new Error("Autospawn requires a Node.js runtime.");
}
@ -54,7 +54,7 @@ export async function spawnSandboxDaemon(
const connectHost = bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
const token = options.token ?? crypto.randomBytes(24).toString("hex");
const timeoutMs = options.timeoutMs ?? 15_000;
const logMode: SandboxDaemonSpawnLogMode = options.log ?? "inherit";
const logMode: SandboxAgentSpawnLogMode = options.log ?? "inherit";
const binaryPath =
options.binaryPath ??

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient, SandboxDaemonError } from "../src/client.ts";
import { SandboxAgent, SandboxAgentError } from "../src/client.ts";
function createMockFetch(
response: unknown,
@ -23,18 +23,18 @@ function createMockFetchError(status: number, problem: unknown): Mock<typeof fet
);
}
describe("SandboxDaemonClient", () => {
describe("constructor", () => {
it("creates client with baseUrl", () => {
const client = new SandboxDaemonClient({
describe("SandboxAgent", () => {
describe("connect", () => {
it("creates client with baseUrl", async () => {
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
expect(client).toBeInstanceOf(SandboxAgent);
});
it("strips trailing slash from baseUrl", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080/",
fetch: mockFetch,
});
@ -47,41 +47,33 @@ describe("SandboxDaemonClient", () => {
);
});
it("throws if fetch is not available", () => {
it("throws if fetch is not available", async () => {
const originalFetch = globalThis.fetch;
// @ts-expect-error - testing missing fetch
globalThis.fetch = undefined;
expect(() => {
new SandboxDaemonClient({
await expect(
SandboxAgent.connect({
baseUrl: "http://localhost:8080",
});
}).toThrow("Fetch API is not available");
})
).rejects.toThrow("Fetch API is not available");
globalThis.fetch = originalFetch;
});
});
describe("connect", () => {
it("creates client without spawn when baseUrl provided", async () => {
const client = await SandboxDaemonClient.connect({
baseUrl: "http://localhost:8080",
spawn: false,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("throws when no baseUrl and spawn disabled", async () => {
await expect(
SandboxDaemonClient.connect({ spawn: false })
).rejects.toThrow("baseUrl is required when autospawn is disabled");
describe("start", () => {
it("rejects when spawn disabled", async () => {
await expect(SandboxAgent.start({ spawn: false })).rejects.toThrow(
"SandboxAgent.start requires spawn to be enabled."
);
});
});
describe("getHealth", () => {
it("returns health response", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -100,7 +92,7 @@ describe("SandboxDaemonClient", () => {
it("returns agent list", async () => {
const agents = { agents: [{ id: "claude", installed: true }] };
const mockFetch = createMockFetch(agents);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -115,7 +107,7 @@ describe("SandboxDaemonClient", () => {
it("creates session with agent", async () => {
const response = { healthy: true, agentSessionId: "abc123" };
const mockFetch = createMockFetch(response);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -136,7 +128,7 @@ describe("SandboxDaemonClient", () => {
it("encodes session ID in URL", async () => {
const mockFetch = createMockFetch({ healthy: true });
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -155,7 +147,7 @@ describe("SandboxDaemonClient", () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -176,7 +168,7 @@ describe("SandboxDaemonClient", () => {
it("returns events", async () => {
const events = { events: [], hasMore: false };
const mockFetch = createMockFetch(events);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -188,7 +180,7 @@ describe("SandboxDaemonClient", () => {
it("passes query parameters", async () => {
const mockFetch = createMockFetch({ events: [], hasMore: false });
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -205,7 +197,7 @@ describe("SandboxDaemonClient", () => {
describe("authentication", () => {
it("includes authorization header when token provided", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
token: "test-token",
fetch: mockFetch,
@ -227,7 +219,7 @@ describe("SandboxDaemonClient", () => {
});
describe("error handling", () => {
it("throws SandboxDaemonError on non-ok response", async () => {
it("throws SandboxAgentError on non-ok response", async () => {
const problem = {
type: "error",
title: "Not Found",
@ -235,20 +227,20 @@ describe("SandboxDaemonClient", () => {
detail: "Session not found",
};
const mockFetch = createMockFetchError(404, problem);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await expect(client.getEvents("nonexistent")).rejects.toThrow(
SandboxDaemonError
SandboxAgentError
);
try {
await client.getEvents("nonexistent");
} catch (e) {
expect(e).toBeInstanceOf(SandboxDaemonError);
const error = e as SandboxDaemonError;
expect(e).toBeInstanceOf(SandboxAgentError);
const error = e as SandboxAgentError;
expect(error.status).toBe(404);
expect(error.problem?.title).toBe("Not Found");
}
@ -260,7 +252,7 @@ describe("SandboxDaemonClient", () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -284,7 +276,7 @@ describe("SandboxDaemonClient", () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});

View file

@ -3,8 +3,8 @@ import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type ChildProcess } from "node:child_process";
import { SandboxDaemonClient } from "../src/client.ts";
import { spawnSandboxDaemon, isNodeRuntime } from "../src/spawn.ts";
import { SandboxAgent } from "../src/client.ts";
import { spawnSandboxAgent, isNodeRuntime } from "../src/spawn.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -38,8 +38,8 @@ if (BINARY_PATH && !process.env.SANDBOX_AGENT_BIN) {
}
describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
it("spawns daemon and connects", async () => {
const handle = await spawnSandboxDaemon({
it("spawns server and connects", async () => {
const handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
@ -49,7 +49,7 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
expect(handle.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(handle.token).toBeTruthy();
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: handle.baseUrl,
token: handle.token,
});
@ -61,8 +61,8 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
}
});
it("SandboxDaemonClient.connect spawns automatically", async () => {
const client = await SandboxDaemonClient.connect({
it("SandboxAgent.start spawns automatically", async () => {
const client = await SandboxAgent.start({
spawn: { log: "silent", timeoutMs: 30000 },
});
@ -79,7 +79,7 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
});
it("lists available agents", async () => {
const client = await SandboxDaemonClient.connect({
const client = await SandboxAgent.start({
spawn: { log: "silent", timeoutMs: 30000 },
});
@ -95,31 +95,31 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
});
describe.skipIf(SKIP_INTEGRATION)("Integration: connect (remote mode)", () => {
let daemonProcess: ChildProcess;
let serverProcess: ChildProcess;
let baseUrl: string;
let token: string;
beforeAll(async () => {
// Start daemon manually to simulate remote server
const handle = await spawnSandboxDaemon({
// Start server manually to simulate remote server
const handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
daemonProcess = handle.child;
serverProcess = handle.child;
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
if (daemonProcess && daemonProcess.exitCode === null) {
daemonProcess.kill("SIGTERM");
if (serverProcess && serverProcess.exitCode === null) {
serverProcess.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
daemonProcess.kill("SIGKILL");
serverProcess.kill("SIGKILL");
resolve();
}, 5000);
daemonProcess.once("exit", () => {
serverProcess.once("exit", () => {
clearTimeout(timeout);
resolve();
});
@ -128,26 +128,17 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: connect (remote mode)", () => {
});
it("connects to remote server", async () => {
const client = await SandboxDaemonClient.connect({
const client = await SandboxAgent.connect({
baseUrl,
token,
spawn: false,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("creates client directly without spawn", () => {
const client = new SandboxDaemonClient({
baseUrl,
token,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("handles authentication", async () => {
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl,
token,
});
@ -157,7 +148,7 @@ describe.skipIf(SKIP_INTEGRATION)("Integration: connect (remote mode)", () => {
});
it("rejects invalid token on protected endpoints", async () => {
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl,
token: "invalid-token",
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient } from "../src/client.ts";
import { SandboxAgent } from "../src/client.ts";
import type { UniversalEvent } from "../src/types.ts";
function createMockResponse(chunks: string[]): Response {
@ -51,7 +51,7 @@ describe("SSE Parser", () => {
const event = createEvent(1);
const mockFetch = createMockFetch([`data: ${JSON.stringify(event)}\n\n`]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -73,7 +73,7 @@ describe("SSE Parser", () => {
`data: ${JSON.stringify(event2)}\n\n`,
]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -97,7 +97,7 @@ describe("SSE Parser", () => {
fullMessage.slice(10),
]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -118,7 +118,7 @@ describe("SSE Parser", () => {
`data: ${JSON.stringify(event1)}\n\ndata: ${JSON.stringify(event2)}\n\n`,
]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -139,7 +139,7 @@ describe("SSE Parser", () => {
`data: ${JSON.stringify(event)}\n\n`,
]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -158,7 +158,7 @@ describe("SSE Parser", () => {
`data: ${JSON.stringify(event)}\r\n\r\n`,
]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -174,7 +174,7 @@ describe("SSE Parser", () => {
it("handles empty stream", async () => {
const mockFetch = createMockFetch([]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
@ -190,7 +190,7 @@ describe("SSE Parser", () => {
it("passes query parameters", async () => {
const mockFetch = createMockFetch([]);
const client = new SandboxDaemonClient({
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});