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
42 changes: 42 additions & 0 deletions lib/sandbox/__tests__/processCreateSandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,48 @@ describe("processCreateSandbox", () => {
});
});

it("converts prompt to opencode run command", async () => {
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockResolvedValue({
sandboxId: "sbx_123",
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_123",
created_at: "2024-01-01T00:00:00.000Z",
},
error: null,
});
vi.mocked(triggerRunSandboxCommand).mockResolvedValue({
id: "run_prompt123",
});

const result = await processCreateSandbox({
accountId: "acc_123",
prompt: "create a hello world index.html",
});

expect(result).toEqual({
sandboxId: "sbx_123",
sandboxStatus: "running",
timeout: 600000,
createdAt: "2024-01-01T00:00:00.000Z",
runId: "run_prompt123",
});
expect(triggerRunSandboxCommand).toHaveBeenCalledWith({
command: "opencode",
args: ["run", "create a hello world index.html"],
cwd: undefined,
sandboxId: "sbx_123",
accountId: "acc_123",
});
});

it("throws when createSandbox fails", async () => {
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);
vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed"));
Expand Down
54 changes: 54 additions & 0 deletions lib/sandbox/__tests__/validateSandboxBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,60 @@ describe("validateSandboxBody", () => {
});
});

it("returns validated body when prompt is provided", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
});
vi.mocked(safeParseJson).mockResolvedValue({
prompt: "create a hello world index.html",
});

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

expect(result).toEqual({
accountId: "acc_123",
orgId: "org_456",
authToken: "token",
prompt: "create a hello world index.html",
});
});

it("returns error response when both command and prompt are provided", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token",
});
vi.mocked(safeParseJson).mockResolvedValue({
command: "ls",
prompt: "do something",
});

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

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

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

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

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("returns error response when command is empty string", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
Expand Down
11 changes: 9 additions & 2 deletions lib/sandbox/processCreateSandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectA
import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand";
import type { SandboxBody } from "@/lib/sandbox/validateSandboxBody";

type ProcessCreateSandboxInput = Pick<SandboxBody, "accountId" | "command" | "args" | "cwd">;
type ProcessCreateSandboxInput = Pick<
SandboxBody,
"accountId" | "command" | "args" | "cwd" | "prompt"
>;
type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string };

/**
Expand All @@ -17,7 +20,11 @@ type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string };
export async function processCreateSandbox(
input: ProcessCreateSandboxInput,
): Promise<ProcessCreateSandboxResult> {
const { accountId, command, args, cwd } = input;
const { accountId, prompt, cwd } = input;

// Convert prompt shortcut to opencode command
const command = prompt ? "opencode" : input.command;
const args = prompt ? ["run", prompt] : input.args;

// Get account's most recent snapshot if available
const accountSnapshots = await selectAccountSnapshots(accountId);
Expand Down
16 changes: 11 additions & 5 deletions lib/sandbox/validateSandboxBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import { validateAuthContext, type AuthContext } from "@/lib/auth/validateAuthCo
import { safeParseJson } from "@/lib/networking/safeParseJson";
import { z } from "zod";

export const sandboxBodySchema = z.object({
command: z.string().min(1, "command cannot be empty").optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
});
export const sandboxBodySchema = z
.object({
command: z.string().min(1, "command cannot be empty").optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
prompt: z.string().min(1, "prompt cannot be empty").optional(),
})
.refine(data => !(data.command && data.prompt), {
message: "Cannot specify both command and prompt",
path: ["prompt"],
Comment on lines +15 to +17
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

missing_fields is semantically incorrect for this refinement error path.

When this refinement fires, line 44 responds with { missing_fields: ["prompt"], error: "Cannot specify both command and prompt" }. The prompt field is not missing — it's conflicting. API consumers receiving missing_fields: ["prompt"] will be misled into thinking they need to add prompt, not remove one of the two conflicting params.

This is a pre-existing naming issue in the error response shape, but the new refinement makes it actively user-hostile because the field literally is present.

💡 Suggested fix — differentiate conflict errors from missing-field errors
  if (!result.success) {
    const firstError = result.error.issues[0];
+   const isConflict = firstError.code === "custom";
    return NextResponse.json(
      {
        status: "error",
-       missing_fields: firstError.path,
+       ...(isConflict
+         ? { conflicting_fields: firstError.path }
+         : { missing_fields: firstError.path }),
        error: firstError.message,
      },
      {
        status: 400,
        headers: getCorsHeaders(),
      },
    );
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sandbox/validateSandboxBody.ts` around lines 15 - 17, The refinement in
validateSandboxBody that uses .refine(..., { message: "Cannot specify both
command and prompt", path: ["prompt"] }) incorrectly maps to the response
shape's missing_fields; change the refinement to emit a distinct conflict
indicator (e.g., set path to ["conflicting_fields"] or attach a custom
issue/code like "conflict") and update the error-to-response mapping logic that
currently populates missing_fields to instead populate a new conflicting_fields
array when that conflict issue/code is present (include both "command" and
"prompt" or at least the conflicting field names). This keeps the message text,
but ensures the response shape distinguishes conflicts from missing fields and
uses identifiers in validateSandboxBody, the .refine call, and the
error-to-response mapping logic to locate and update the code.

});
Comment on lines +8 to +18
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

args + prompt combination passes validation but is silently dropped downstream.

The refinement only guards against command + prompt. A request body of { prompt: "...", args: ["extra"] } passes validation cleanly, but args is silently discarded in processCreateSandbox.ts (line 27: const args = prompt ? ["run", prompt] : input.args). A caller who passes args alongside prompt gets no feedback that their args were ignored.

Extend the refinement to also reject args + prompt:

🛡️ Proposed fix — also block args + prompt together
  .refine(data => !(data.command && data.prompt), {
    message: "Cannot specify both command and prompt",
    path: ["prompt"],
- });
+ })
+ .refine(data => !(data.args && data.prompt), {
+   message: "Cannot specify both args and prompt",
+   path: ["args"],
+ });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const sandboxBodySchema = z
.object({
command: z.string().min(1, "command cannot be empty").optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
prompt: z.string().min(1, "prompt cannot be empty").optional(),
})
.refine(data => !(data.command && data.prompt), {
message: "Cannot specify both command and prompt",
path: ["prompt"],
});
export const sandboxBodySchema = z
.object({
command: z.string().min(1, "command cannot be empty").optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
prompt: z.string().min(1, "prompt cannot be empty").optional(),
})
.refine(data => !(data.command && data.prompt), {
message: "Cannot specify both command and prompt",
path: ["prompt"],
})
.refine(data => !(data.args && data.prompt), {
message: "Cannot specify both args and prompt",
path: ["args"],
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sandbox/validateSandboxBody.ts` around lines 8 - 18, The schema's refine
currently only rejects command+prompt but allows args+prompt which are dropped
downstream; update sandboxBodySchema.refine predicate to reject any payload
where prompt is present with either command or a non-empty args (e.g. change
refine to: data => !(data.prompt && (data.command || (data.args &&
data.args.length > 0)))), and update the refine message/path accordingly (e.g.
message "Cannot specify prompt with command or args" and path ["prompt"] or
["args"] as appropriate) so callers get validation errors when supplying args
alongside prompt.


export type SandboxBody = z.infer<typeof sandboxBodySchema> & AuthContext;

Expand Down