Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/api/sandboxes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,21 @@ export async function OPTIONS() {
/**
* POST /api/sandboxes
*
* Creates a new ephemeral sandbox environment.
* Creates a new ephemeral sandbox environment and executes a command.
* Sandboxes are isolated Linux microVMs that can be used to evaluate
* account-generated code, run AI agent output safely, or execute reproducible tasks.
* The sandbox will automatically stop after the timeout period.
*
* Authentication: x-api-key header or Authorization Bearer token required.
*
* Request body:
* - prompt: string (required, min length 1) - The prompt to send to Claude Code
* - command: string (required) - The command to execute in the sandbox
* - args: string[] (optional) - Arguments to pass to the command
* - cwd: string (optional) - Working directory for command execution
*
* Response (200):
* - status: "success"
* - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt }]
* - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt, runId }]
Comment on lines 29 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document runId as optional.

createSandboxPostHandler omits runId when the trigger fails, but the docs imply it’s always present. Please update the response docs to clarify it’s optional (or may be null) on trigger failure.

Doc tweak
- * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt, runId }]
+ * - sandboxes: [{ sandboxId, sandboxStatus, timeout, createdAt, runId? }]
🤖 Prompt for AI Agents
In `@app/api/sandboxes/route.ts` around lines 29 - 36, Update the response
documentation to indicate that runId may be absent/null: modify the Response
schema comment in the file to show runId as optional (e.g., runId?: string |
null) for the sandboxes array returned by createSandboxPostHandler, since
createSandboxPostHandler can omit runId when the trigger fails; mention it may
be null or undefined to reflect trigger failure behavior.

*
* Error (400/401):
* - status: "error"
Expand Down
31 changes: 30 additions & 1 deletion lib/sandbox/__tests__/createSandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("createSandbox", () => {
vi.clearAllMocks();
});

it("creates sandbox with correct configuration", async () => {
it("creates sandbox with default configuration when no params provided", async () => {
await createSandbox();

expect(Sandbox.create).toHaveBeenCalledWith({
Expand All @@ -38,6 +38,35 @@ describe("createSandbox", () => {
});
});

it("creates sandbox from snapshot when source is provided", async () => {
await createSandbox({ source: { type: "snapshot", snapshotId: "snap_abc123" } });

expect(Sandbox.create).toHaveBeenCalledWith({
source: { type: "snapshot", snapshotId: "snap_abc123" },
timeout: 600000,
});
});

it("allows overriding default timeout", async () => {
await createSandbox({ timeout: 300000 });

expect(Sandbox.create).toHaveBeenCalledWith({
resources: { vcpus: 4 },
timeout: 300000,
runtime: "node22",
});
});

it("allows overriding default resources", async () => {
await createSandbox({ resources: { vcpus: 2 } });

expect(Sandbox.create).toHaveBeenCalledWith({
resources: { vcpus: 2 },
timeout: 600000,
runtime: "node22",
});
});

it("returns sandbox created response with sandboxStatus", async () => {
const result = await createSandbox();

Expand Down
111 changes: 95 additions & 16 deletions lib/sandbox/__tests__/createSandboxPostHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { validateSandboxBody } from "@/lib/sandbox/validateSandboxBody";
import { createSandbox } from "@/lib/sandbox/createSandbox";
import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox";
import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand";
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";

vi.mock("@/lib/sandbox/validateSandboxBody", () => ({
validateSandboxBody: vi.fn(),
Expand All @@ -24,6 +25,10 @@ vi.mock("@/lib/trigger/triggerRunSandboxCommand", () => ({
triggerRunSandboxCommand: vi.fn(),
}));

vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
selectAccountSnapshots: vi.fn(),
}));

/**
* Creates a mock NextRequest for testing.
*
Expand Down Expand Up @@ -51,13 +56,14 @@ describe("createSandboxPostHandler", () => {
expect(response.status).toBe(401);
});

it("returns 200 with sandboxes array on success", async () => {
it("returns 200 with sandboxes array including runId on success", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_123",
sandboxStatus: "running",
Expand All @@ -73,6 +79,9 @@ describe("createSandboxPostHandler", () => {
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_abc123",
});

const request = createMockRequest();
const response = await createSandboxPostHandler(request);
Expand All @@ -87,18 +96,27 @@ describe("createSandboxPostHandler", () => {
sandboxStatus: "running",
timeout: 600000,
createdAt: "2024-01-01T00:00:00.000Z",
runId: "run_abc123",
},
],
});
});

it("calls createSandbox without arguments", async () => {
it("calls createSandbox with snapshotId when account has snapshot", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([
{
id: "snap_record_123",
account_id: "acc_123",
snapshot_id: "snap_xyz",
created_at: "2024-01-01T00:00:00.000Z",
},
]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_456",
sandboxStatus: "running",
Expand All @@ -114,20 +132,57 @@ describe("createSandboxPostHandler", () => {
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_def456",
});

const request = createMockRequest();
await createSandboxPostHandler(request);

expect(createSandbox).toHaveBeenCalledWith();
expect(createSandbox).toHaveBeenCalledWith({ source: { type: "snapshot", snapshotId: "snap_xyz" } });
});

it("calls createSandbox with empty params when account has no snapshot", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_456",
sandboxStatus: "running",
timeout: 600000,
createdAt: "2024-01-01T00:00:00.000Z",
});
vi.mocked(insertAccountSandbox).mockResolvedValue({
data: {
id: "record_123",
account_id: "acc_123",
sandbox_id: "sbx_456",
created_at: "2024-01-01T00:00:00.000Z",
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_def456",
});

const request = createMockRequest();
await createSandboxPostHandler(request);

expect(createSandbox).toHaveBeenCalledWith({});
});

it("calls insertAccountSandbox with correct account_id and sandbox_id", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_456",
sandboxStatus: "running",
Expand All @@ -143,6 +198,9 @@ describe("createSandboxPostHandler", () => {
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_def456",
});

const request = createMockRequest();
await createSandboxPostHandler(request);
Expand All @@ -153,13 +211,16 @@ describe("createSandboxPostHandler", () => {
});
});

it("calls triggerRunSandboxCommand with prompt and sandboxId", async () => {
it("calls triggerRunSandboxCommand with command, args, cwd, sandboxId, and accountId", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
args: ["-la"],
cwd: "/home",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_789",
sandboxStatus: "running",
Expand All @@ -175,13 +236,19 @@ describe("createSandboxPostHandler", () => {
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_ghi789",
});

const request = createMockRequest();
await createSandboxPostHandler(request);

expect(triggerRunSandboxCommand).toHaveBeenCalledWith({
prompt: "tell me hello",
command: "ls",
args: ["-la"],
cwd: "/home",
sandboxId: "sbx_789",
accountId: "acc_123",
});
});

Expand All @@ -190,8 +257,9 @@ describe("createSandboxPostHandler", () => {
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed"));

const request = createMockRequest();
Expand All @@ -210,8 +278,9 @@ describe("createSandboxPostHandler", () => {
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_123",
sandboxStatus: "running",
Expand All @@ -231,13 +300,14 @@ describe("createSandboxPostHandler", () => {
});
});

it("returns 400 with error status when triggerRunSandboxCommand throws", async () => {
it("returns 200 without runId when triggerRunSandboxCommand throws", async () => {
vi.mocked(validateSandboxBody).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_123",
sandboxStatus: "running",
Expand All @@ -258,11 +328,20 @@ describe("createSandboxPostHandler", () => {
const request = createMockRequest();
const response = await createSandboxPostHandler(request);

expect(response.status).toBe(400);
// Sandbox was created successfully, so return 200 even if trigger fails
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
status: "error",
error: "Task trigger failed",
status: "success",
sandboxes: [
{
sandboxId: "sbx_123",
sandboxStatus: "running",
timeout: 600000,
createdAt: "2024-01-01T00:00:00.000Z",
// Note: runId is not included when trigger fails
},
],
});
});
});
37 changes: 31 additions & 6 deletions lib/sandbox/__tests__/validateSandboxBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ describe("validateSandboxBody", () => {
expect((result as NextResponse).status).toBe(401);
});

it("returns validated body with auth context when prompt is provided", async () => {
it("returns validated body with auth context when command is provided", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
});
vi.mocked(safeParseJson).mockResolvedValue({ prompt: "tell me hello" });
vi.mocked(safeParseJson).mockResolvedValue({ command: "ls" });

const request = createMockRequest();
const result = await validateSandboxBody(request);
Expand All @@ -56,11 +56,36 @@ describe("validateSandboxBody", () => {
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
prompt: "tell me hello",
command: "ls",
});
});

it("returns error response when prompt is missing", async () => {
it("returns validated body with optional args and cwd", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
});
vi.mocked(safeParseJson).mockResolvedValue({
command: "ls",
args: ["-la", "/home"],
cwd: "/tmp",
});

const request = createMockRequest();
const result = await validateSandboxBody(request);

expect(result).toEqual({
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
command: "ls",
args: ["-la", "/home"],
cwd: "/tmp",
});
});

it("returns error response when command is missing", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
Expand All @@ -75,13 +100,13 @@ describe("validateSandboxBody", () => {
expect((result as NextResponse).status).toBe(400);
});

it("returns error response when prompt is empty string", async () => {
it("returns error response when command is empty string", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
});
vi.mocked(safeParseJson).mockResolvedValue({ prompt: "" });
vi.mocked(safeParseJson).mockResolvedValue({ command: "" });

const request = createMockRequest();
const result = await validateSandboxBody(request);
Expand Down
Loading