feat: route all commands through Vercel Sandbox#7
Conversation
Remove client-side just-bash/browser dependency for command execution. All terminal commands now go through /api/exec which creates and reuses a Vercel Sandbox VM, eliminating split logic between local and remote command execution. - Add /api/exec endpoint that creates/reuses sandbox sessions - Refactor input-handler to accept generic exec function - Refactor agent-command to standalone handler (no defineCommand) - Update Terminal.tsx to route commands: static (local) → agent (API) → sandbox (API) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughThis change introduces a new server API endpoint for executing shell commands in sandboxes with Bearer token authentication and replaces client-side Bash execution with a unified routing layer that directs commands to static handlers, an agent handler, or the sandboxed server endpoint. Changes
Sequence DiagramsequenceDiagram
participant User
participant Terminal as Terminal Component
participant Router as Command Router
participant Agent as Agent Handler
participant API as /api/exec Endpoint
participant Sandbox as Sandbox Engine
participant Auth as Auth Service
User->>Terminal: Enter command
Terminal->>Router: Route command
alt Static Command
Router->>Terminal: Execute locally (about, install, github)
else Agent Command
Router->>Agent: Delegate to agent handler
Agent->>Auth: Validate auth token
Auth-->>Agent: Token valid
Agent->>API: Send agent request
API->>Sandbox: Execute in sandbox
Sandbox-->>API: Return output
API-->>Agent: Response
Agent-->>Terminal: ExecResult
else Regular Command
Router->>Auth: Check authorization
Auth-->>Router: Token valid
Router->>API: POST /api/exec with sandboxId
API->>Sandbox: Fetch/create sandbox & execute
Sandbox-->>API: Command output
API-->>Router: JSON response (stdout, stderr, exitCode)
Router-->>Terminal: ExecResult
end
Terminal->>User: Display output
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 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 |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@app/api/exec/route.ts`:
- Around line 61-65: The POST handler currently only checks that authHeader
starts with "Bearer " but never extracts or validates the token; update the POST
function to extract the token (const token = authHeader.slice(7).trim()) and
validate it before proceeding by either verifying a JWT (using your JWT
secret/public key or a library) or calling your identity provider/token
introspection endpoint; if verification fails, return a 401 Response.json({
error: "Unauthorized" }, { status: 401 }); keep all existing behavior for
authorized requests and ensure failures are short-circuited immediately in POST.
- Line 67: The code directly destructures the request body with `const {
command, sandboxId } = await req.json();` which throws on malformed JSON; wrap
the `await req.json()` call in a try/catch, handle JSON parse errors by
returning an HTTP 400 response (or calling the existing error responder) with a
clear message, and validate that `command` and `sandboxId` exist after parsing
before proceeding so bad or missing payloads are rejected gracefully.
- Around line 6-7: The code uses import.meta.url to build __dirname and compute
AGENT_DATA_DIR which can break when Next.js bundles API routes; change the
resolution to use a stable base (e.g., process.cwd() or a configured env var) to
compute the absolute path to the generated _agent-data folder instead of relying
on import.meta.url. Update the constants in route.ts (the __dirname and
AGENT_DATA_DIR definitions) to derive AGENT_DATA_DIR from process.cwd() or a
known APP_ROOT/env var and add fallback/error logging if the directory is
missing so runtime path resolution is robust in both dev and production.
- Around line 88-112: Add a per-command timeout using AbortController around the
sandbox.runCommand call: create an AbortController (e.g., ac), start a
setTimeout to call ac.abort() after the desired timeout (e.g., 30_000 ms), pass
ac.signal in the options to sandbox.runCommand (alongside
cmd/args/cwd/SANDBOX_CWD), and ensure you clearTimeout(timeout) in a finally
block so the timer is cleaned up; keep the existing stdout/stderr retrieval and
error handling (the catch can continue to return error.message or "Execution
failed").
🧹 Nitpick comments (4)
app/api/exec/route.ts (1)
10-32: Unbounded recursive file read with no size/count limits.
readSourceFilesrecursively reads all files underAGENT_DATA_DIRinto memory (asBuffers) with no cap on file count or total size. If the agent data directory grows, this could cause high memory usage or slow sandbox creation.Consider adding a file count limit or total size cap, or at minimum logging a warning if the directory is unexpectedly large.
app/components/terminal-parts/agent-command.ts (1)
4-8:ExecResulttype is duplicated across three files.The same
ExecResulttype is defined inagent-command.ts,input-handler.ts, andTerminal.tsx. Consider extracting it to a shared types module (e.g., interminal-parts/types.ts) and re-exporting it from the barrel file.app/components/terminal-parts/input-handler.ts (1)
206-213: Tab completion triggers a sandbox API round-trip on every Tab press.
exec("ls -1")now makes a network request to/api/execfor file completion. This introduces noticeable latency compared to the previous client-side approach. Users who press Tab frequently may perceive the shell as sluggish.Consider caching the
ls -1result for a short TTL (e.g., invalidate after the next command execution), or debouncing repeated Tab presses.app/components/Terminal.tsx (1)
76-86: Minor: quote-stripping only handles balanced, non-nested quotes.The quote removal on Lines 79–84 handles
"..."and'...'but won't handle escaped quotes, mismatched quotes, or nested quotes. This is likely fine for a terminal prompt input, but worth noting.
app/api/exec/route.ts
Outdated
| export async function POST(req: Request) { | ||
| const authHeader = req.headers.get("Authorization"); | ||
| if (!authHeader?.startsWith("Bearer ")) { | ||
| return Response.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } |
There was a problem hiding this comment.
Bearer token is accepted but never validated.
The auth check on Line 63 only verifies the Authorization header starts with "Bearer " but never extracts or validates the actual token value. Any request with Authorization: Bearer anything will pass. Since this endpoint executes arbitrary shell commands in a sandbox, this is effectively an unauthenticated command-execution endpoint.
Consider verifying the token against your auth provider (e.g., decode/verify a JWT, or check against an identity provider).
🤖 Prompt for AI Agents
In `@app/api/exec/route.ts` around lines 61 - 65, The POST handler currently only
checks that authHeader starts with "Bearer " but never extracts or validates the
token; update the POST function to extract the token (const token =
authHeader.slice(7).trim()) and validate it before proceeding by either
verifying a JWT (using your JWT secret/public key or a library) or calling your
identity provider/token introspection endpoint; if verification fails, return a
401 Response.json({ error: "Unauthorized" }, { status: 401 }); keep all existing
behavior for authorized requests and ensure failures are short-circuited
immediately in POST.
app/api/exec/route.ts
Outdated
| return Response.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const { command, sandboxId } = await req.json(); |
There was a problem hiding this comment.
Unhandled JSON parse error on malformed request body.
await req.json() will throw if the body is not valid JSON, resulting in an unhandled exception and a 500 response instead of a clean 400 error.
Proposed fix
- const { command, sandboxId } = await req.json();
+ let command: string | undefined;
+ let sandboxId: string | undefined;
+ try {
+ ({ command, sandboxId } = await req.json());
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }🤖 Prompt for AI Agents
In `@app/api/exec/route.ts` at line 67, The code directly destructures the request
body with `const { command, sandboxId } = await req.json();` which throws on
malformed JSON; wrap the `await req.json()` call in a try/catch, handle JSON
parse errors by returning an HTTP 400 response (or calling the existing error
responder) with a clear message, and validate that `command` and `sandboxId`
exist after parsing before proceeding so bad or missing payloads are rejected
gracefully.
|
|
||
| try { | ||
| const result = await sandbox.runCommand({ | ||
| cmd: "bash", | ||
| args: ["-c", command], | ||
| cwd: SANDBOX_CWD, | ||
| }); | ||
|
|
||
| const stdout = await result.stdout(); | ||
| const stderr = await result.stderr(); | ||
|
|
||
| return Response.json({ | ||
| stdout, | ||
| stderr, | ||
| exitCode: result.exitCode, | ||
| sandboxId: activeSandboxId, | ||
| }); | ||
| } catch (error) { | ||
| return Response.json({ | ||
| stdout: "", | ||
| stderr: error instanceof Error ? error.message : "Execution failed", | ||
| exitCode: 1, | ||
| sandboxId: activeSandboxId, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
@vercel/sandbox runCommand timeout option documentation
💡 Result:
@vercel/sandbox does not document a timeout option on sandbox.runCommand(...). The runCommand API supports cancellation via an AbortSignal (opts.signal / params.signal), but no per-command timeout field is listed. [1]
Timeout is configured at the sandbox level instead:
- Set initial sandbox lifetime with
timeoutwhen callingSandbox.create({ timeout: ... })(milliseconds). [1][2] - Extend a running sandbox with
sandbox.extendTimeout(...). [1][2] - By default, a sandbox times out after 5 minutes (plan maximums apply). [2]
Example (per-command “timeout” via AbortController):
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 30_000);
try {
await sandbox.runCommand({ cmd: "bash", args: ["-lc", "sleep 999"], signal: ac.signal });
} finally {
clearTimeout(t);
}Sources: [1] [2]
🏁 Script executed:
# Find the file and examine the full context around lines 88-112
fd -t f "route.ts" | grep -E "app/api/exec" | head -5Repository: recoupable/bash
Length of output: 80
🏁 Script executed:
# Search for sandbox initialization and configuration
rg -A 5 -B 5 "sandbox.runCommand" app/Repository: recoupable/bash
Length of output: 1778
🏁 Script executed:
cat -n app/api/exec/route.ts | head -90Repository: recoupable/bash
Length of output: 3389
🏁 Script executed:
# Search for the sandbox creation function
rg -A 20 "createAndSeedSandbox" app/api/exec/route.tsRepository: recoupable/bash
Length of output: 1434
Add per-command timeout using AbortSignal to prevent hanging commands.
Currently, sandbox.runCommand() at line 90 has no timeout protection. While the sandbox has a default 5-minute lifetime, individual commands can hang indefinitely within that window (e.g., cat with no file, sleep 999999, infinite loops). The @vercel/sandbox SDK supports cancellation via AbortSignal—use AbortController to implement a per-command timeout:
Example fix
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), 30_000); // 30s timeout
try {
const result = await sandbox.runCommand({
cmd: "bash",
args: ["-c", command],
cwd: SANDBOX_CWD,
signal: ac.signal,
});
// ... rest of code
} finally {
clearTimeout(timeout);
}🤖 Prompt for AI Agents
In `@app/api/exec/route.ts` around lines 88 - 112, Add a per-command timeout using
AbortController around the sandbox.runCommand call: create an AbortController
(e.g., ac), start a setTimeout to call ac.abort() after the desired timeout
(e.g., 30_000 ms), pass ac.signal in the options to sandbox.runCommand
(alongside cmd/args/cwd/SANDBOX_CWD), and ensure you clearTimeout(timeout) in a
finally block so the timer is cleaned up; keep the existing stdout/stderr
retrieval and error handling (the catch can continue to return error.message or
"Execution failed").
The previous approach read _agent-data from disk via __dirname, which doesn't work in serverless since each route is bundled independently. Now fetches files via the existing /api/fs endpoint. Added top-level try/catch so errors return useful messages instead of raw 500s. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DRY: Move readSourceFiles and createSandbox into app/api/_lib/ so both /api/agent and /api/exec reuse the same sandbox creation logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
/api/execendpoint that creates and reuses Vercel Sandbox VMs for command executionjust-bash/browserdependency — all terminal commands now execute server-side in a real sandboxinput-handler.tsto accept a generic exec function instead of theBashtypeagent-command.tsto export a standalone handler (removesdefineCommanddependency)ls,cat,grep, and all other commands uniformly go through the sandboxTest plan
ls,cat,grep, and other bash commands execute correctly via sandboxagentcommand still works (streams response, multi-turn chat)about,install,github) return expected outputclearstill clears the terminal locallyls -1)?agent=query parameter still triggers agent command🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes