feat: add runSandboxCommand task for background sandbox execution#17
Conversation
Move sandbox command execution to Trigger.dev task to allow the API to respond faster while sandbox operations run asynchronously. - Add runSandboxCommandTask with schemaTask for typed payloads - Add sandbox helper functions (installClaudeCode, runClaudeCode) - Add Zod schemas for payload validation - Add @vercel/sandbox and ms dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds Claude Code support: new dependencies, sandbox helpers to install and run Anthropic's Claude Code CLI and SDK inside Vercel sandboxes, Zod schemas for payload/result typing, and a background schemaTask that manages the sandbox lifecycle and executes prompts. Changes
Sequence DiagramsequenceDiagram
actor Runner as Task\ Runner
participant Task as runSandboxCommandTask
participant SandboxLib as Vercel\ Sandbox
participant Installer as installClaudeCode
participant Executor as runClaudeCode
participant Logger as Logger
Runner->>Task: invoke(payload: { prompt, sandboxId })
Task->>Logger: log start
Task->>SandboxLib: Sandbox.get(sandboxId)
Task->>Logger: log connected
Task->>Installer: installClaudeCode(sandbox)
Installer->>SandboxLib: run npm install commands
Installer->>Logger: log install complete
Task->>Executor: runClaudeCode(sandbox, prompt)
Executor->>SandboxLib: write script & run claude with ANTHROPIC_API_KEY
Executor->>Logger: log execution result
Task->>Logger: construct SandboxResult / log success
Task->>SandboxLib: stop sandbox (finally)
Task->>Runner: return SandboxResult
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The API will create the sandbox and return the sandboxId immediately. This task connects to the existing sandbox and runs the work asynchronously. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/sandboxes/runClaudeCode.ts`:
- Around line 30-40: After calling sandbox.runCommand(...) (the invocation that
runs "ralph-once.sh") you must check result.exitCode and fail the task when it's
non‑zero: inspect the returned result from sandbox.runCommand, and if
result.exitCode !== 0 throw an Error (include the exit code and any available
stdout/stderr from result in the error message) instead of only logging; keep
the existing logger.log for successful runs but ensure non‑zero exit codes cause
the function to throw so failures are not silent.
🧹 Nitpick comments (4)
src/sandboxes/installClaudeCode.ts (1)
10-31: Pin Claude Code and Anthropic SDK versions for reproducible sandbox installations.Installing latest versions at runtime can introduce breaking changes. The project already pins critical dependencies (
@trigger.dev/sdk@4.3.3,trigger.dev@4.3.3,pnpm@9.10.0); apply the same rigor to sandbox package installations by using environment variables or hardcoded versions (e.g.,@anthropic-ai/claude-code@X.Y.Z).Proposed diff
export async function installClaudeCode(sandbox: Sandbox): Promise<void> { + const claudeCodeVersion = process.env.CLAUDE_CODE_VERSION; + const anthropicSdkVersion = process.env.ANTHROPIC_SDK_VERSION; + + if (!claudeCodeVersion || !anthropicSdkVersion) { + throw new Error( + "Missing CLAUDE_CODE_VERSION or ANTHROPIC_SDK_VERSION for deterministic installs" + ); + } + logger.log("Installing Claude Code CLI globally"); const installCLI = await sandbox.runCommand({ cmd: "npm", - args: ["install", "-g", "@anthropic-ai/claude-code"], + args: ["install", "-g", `@anthropic-ai/claude-code@${claudeCodeVersion}`], sudo: true, }); @@ const installSDK = await sandbox.runCommand({ cmd: "npm", - args: ["install", "@anthropic-ai/sdk"], + args: ["install", `@anthropic-ai/sdk@${anthropicSdkVersion}`], });src/schemas/sandboxSchema.ts (2)
3-6: Trim prompt before enforcing non-empty.Line 4 allows whitespace-only prompts. Zod supports
.trim()chaining on string schemas, so consider trimming first to avoid "blank" prompts passing validation.Proposed diff
export const runSandboxCommandPayloadSchema = z.object({ - prompt: z.string().min(1, "prompt is required"), + prompt: z.string().trim().min(1, "prompt is required"), accountId: z.string().uuid("accountId must be a valid UUID"), });
12-16: Tighten result schema types (datetime + nonnegative timeout).
createdAtandtimeoutlack validation. Usez.iso.datetime()for ISO 8601 date validation andz.number().int().nonnegative()to ensure timeout is a nonnegative integer.Proposed diff
export const sandboxResultSchema = z.object({ sandboxId: z.string(), sandboxStatus: z.string(), - timeout: z.number(), - createdAt: z.string(), + timeout: z.number().int().nonnegative(), + createdAt: z.iso.datetime(), });src/tasks/runSandboxCommandTask.ts (1)
66-69: Correct the line numbers and verify the actual risk.The code in question is at lines 59-62 (not 66-69). While
sandbox.stop()is idempotent and resolves even if already stopped, the concern about error masking is technically valid: if the underlying API fails duringstop(), it could override the original error. However,@vercel/sandbox.stop()is designed to be safe to call multiple times—any failures would only come from the underlying API layer (auth/network issues), not because the sandbox is already stopped. In practice, when an error occurs in the try block, the sandbox is typically already in a failed or stopped state, makingstop()likely to resolve cleanly.The proposed fix is a reasonable defensive practice to ensure the original error isn't lost if the API layer fails during cleanup, though the practical risk is low. Consider this refactor as optional, not critical.
| const result = await sandbox.runCommand({ | ||
| cmd: "sh", | ||
| args: ["ralph-once.sh"], | ||
| env: { | ||
| ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "", | ||
| }, | ||
| }); | ||
|
|
||
| logger.log("Claude Code execution completed", { | ||
| exitCode: result.exitCode, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd "runClaudeCode.ts" --type fRepository: Recoupable-com/Recoup-Tasks
Length of output: 101
🏁 Script executed:
cat -n src/sandboxes/runClaudeCode.tsRepository: Recoupable-com/Recoup-Tasks
Length of output: 1383
🌐 Web query:
@vercel/sandbox runCommand exitCode stderr stdout API
💡 Result:
In @vercel/sandbox, sandbox.runCommand(...) returns a CommandFinished (when not detached) that gives you:
exitCode: number(0 = success) [1][2]stdout(): Promise<string>andstderr(): Promise<string>to read the full output after it runs [2][3]logs(): AsyncIterable<{ stream: "stdout" | "stderr"; data: string }>to stream output incrementally [3]output("stdout" | "stderr" | "both")to collect one/both streams as a string [2][3]
Basic usage:
import { Sandbox } from "@vercel/sandbox";
const sandbox = await Sandbox.create();
const result = await sandbox.runCommand("node", ["-e", "console.log('hi'); console.error('oops')"]);
console.log(result.exitCode); // number
console.log(await result.stdout()); // "hi\n"
console.log(await result.stderr()); // "oops\n"
await sandbox.stop();Detached mode (returns a running Command; call wait() to get CommandFinished with a populated exitCode): [1][3]
const cmd = await sandbox.runCommand({ cmd: "sleep", args: ["5"], detached: true });
for await (const log of cmd.logs()) { /* stream stdout/stderr */ }
const done = await cmd.wait();
console.log(done.exitCode);runCommand overloads / params:
runCommand(command: string, args?: string[], opts?: { signal?: AbortSignal }): Promise<CommandFinished>[1]runCommand({ cmd, args, cwd, env, sudo, stdout, stderr, signal }): Promise<CommandFinished>[1]runCommand({ ..., detached: true }): Promise<Command>[1]
Sources: [1] Sandbox.runCommand reference, [2] CommandFinished reference, [3] Command reference.
Fail the task when Claude Code exits non‑zero.
The code currently logs the exit code but doesn't check for failure. When sandbox.runCommand() returns a non-zero exitCode, the function completes successfully instead of throwing an error, causing silent failures.
Proposed fix
const result = await sandbox.runCommand({
cmd: "sh",
args: ["ralph-once.sh"],
env: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
},
});
+ if (result.exitCode !== 0) {
+ logger.error("Claude Code execution failed", {
+ exitCode: result.exitCode,
+ });
+ throw new Error(`Claude Code failed with exit code ${result.exitCode}`);
+ }
+
logger.log("Claude Code execution completed", {
exitCode: result.exitCode,
});📝 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.
| const result = await sandbox.runCommand({ | |
| cmd: "sh", | |
| args: ["ralph-once.sh"], | |
| env: { | |
| ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "", | |
| }, | |
| }); | |
| logger.log("Claude Code execution completed", { | |
| exitCode: result.exitCode, | |
| }); | |
| const result = await sandbox.runCommand({ | |
| cmd: "sh", | |
| args: ["ralph-once.sh"], | |
| env: { | |
| ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "", | |
| }, | |
| }); | |
| if (result.exitCode !== 0) { | |
| logger.error("Claude Code execution failed", { | |
| exitCode: result.exitCode, | |
| }); | |
| throw new Error(`Claude Code failed with exit code ${result.exitCode}`); | |
| } | |
| logger.log("Claude Code execution completed", { | |
| exitCode: result.exitCode, | |
| }); |
🤖 Prompt for AI Agents
In `@src/sandboxes/runClaudeCode.ts` around lines 30 - 40, After calling
sandbox.runCommand(...) (the invocation that runs "ralph-once.sh") you must
check result.exitCode and fail the task when it's non‑zero: inspect the returned
result from sandbox.runCommand, and if result.exitCode !== 0 throw an Error
(include the exit code and any available stdout/stderr from result in the error
message) instead of only logging; keep the existing logger.log for successful
runs but ensure non‑zero exit codes cause the function to throw so failures are
not silent.
Pass VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID explicitly to Sandbox.get() for authentication in Trigger.dev environment. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/schemas/sandboxSchema.ts`:
- Around line 3-6: The schema runSandboxCommandPayloadSchema currently only
enforces sandboxId as a non-empty string; change its sandboxId validator to
enforce a UUID (use z.string().uuid("sandboxId must be a valid UUID")) so
invalid IDs are rejected by Zod validation, leaving prompt validation unchanged
and keeping the schema defined in src/schemas/sandboxSchema.ts.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SDK doesn't auto-infer credentials in Trigger.dev environment. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
runSandboxCommandTaskto move sandbox execution from Recoup-API to background processingChanges
src/tasks/runSandboxCommandTask.ts- Background task usingschemaTaskfor typed payloadssrc/sandbox/installClaudeCode.ts,src/sandbox/runClaudeCode.tssrc/schemas/sandboxSchema.ts- Zod validation for task payload@vercel/sandbox,ms,typescript,@types/msTask Configuration
run-sandbox-command{ prompt: string, accountId: string (UUID) }How to trigger from Recoup-API
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Dependencies