Skip to content
Closed
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
43 changes: 5 additions & 38 deletions app/api/agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai";
import { createBashTool } from "bash-tool";
import { Sandbox } from "@vercel/sandbox";
import { readdirSync, readFileSync } from "fs";
import { dirname, join, relative } from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { createSandbox } from "@/lib/createSandbox";
import { readSourceFiles } from "@/lib/readSourceFiles";

const __dirname = dirname(fileURLToPath(import.meta.url));
const AGENT_DATA_DIR = join(__dirname, "./_agent-data");
Expand Down Expand Up @@ -36,35 +36,6 @@ Use cat to read files. Use head, tail to read parts of large files.

Keep responses concise. You have access to a full Linux environment with standard tools.`;

/**
* Recursively read all files from a directory, returning them in the format
* expected by Sandbox.writeFiles().
*/
function readSourceFiles(
dir: string,
baseDir?: string
): Array<{ path: string; content: Buffer }> {
const base = baseDir ?? dir;
const files: Array<{ path: string; content: Buffer }> = [];

for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and other large/irrelevant dirs
if (entry.name === "node_modules" || entry.name === ".git") continue;
files.push(...readSourceFiles(fullPath, base));
} else {
const relPath = relative(base, fullPath);
files.push({
path: join(SANDBOX_CWD, relPath),
content: readFileSync(fullPath),
});
}
}

return files;
}

export async function POST(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
Expand All @@ -80,14 +51,10 @@ export async function POST(req: Request) {
.pop();
console.log("Prompt:", lastUserMessage?.parts?.[0]?.text);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

User prompt is logged to the console.

console.log("Prompt:", lastUserMessage?.parts?.[0]?.text) logs user-submitted content to server logs. Depending on usage, prompts may contain sensitive or personally identifiable information. Consider removing this or gating it behind a debug flag.

🤖 Prompt for AI Agents
In `@app/api/agent/route.ts` at line 52, Remove the unconditional console.log that
prints user-submitted content (console.log("Prompt:",
lastUserMessage?.parts?.[0]?.text)); either delete it or gate it behind a
debug/config flag (e.g., process.env.DEBUG) or use a logger at debug level
(e.g., processLogger.debug) so prompts are not written to production logs;
locate the console.log call and update it to only run when debugging is enabled,
referencing the console.log call and lastUserMessage variable to find the exact
spot.


const sandbox = await Sandbox.create();
const files = readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD);
const sandbox = await createSandbox(files);
Comment on lines +54 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Synchronous filesystem traversal runs on every request, blocking the event loop.

readSourceFiles uses readdirSync/readFileSync and is invoked on every POST. For a non-trivial source tree this blocks the Node.js event loop, increasing latency for all concurrent requests. Since AGENT_DATA_DIR is static, consider reading the files once at module load time and caching the result:

♻️ Suggested caching approach
 const AGENT_DATA_DIR = join(__dirname, "./_agent-data");
 const SANDBOX_CWD = "/vercel/sandbox";
+
+// Read once at module load — AGENT_DATA_DIR is static
+const AGENT_FILES = readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD);

 // ... inside POST handler:
-  const files = readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD);
-  const sandbox = await createSandbox(files);
+  const sandbox = await createSandbox(AGENT_FILES);
📝 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
const files = readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD);
const sandbox = await createSandbox(files);
const sandbox = await createSandbox(AGENT_FILES);
🤖 Prompt for AI Agents
In `@app/api/agent/route.ts` around lines 54 - 55, readSourceFiles is doing
synchronous disk I/O on every POST which blocks the event loop; change the code
so AGENT_DATA_DIR is read once and cached at module load instead of per-request:
move the call to readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD) out of the request
handler in route.ts into top-level initialization, store the result in a
cachedFiles constant (or lazy-initialize it on first access), and then pass
cachedFiles into createSandbox(...) inside the handler (or reuse a cached
sandbox if appropriate), ensuring readSourceFiles, AGENT_DATA_DIR, SANDBOX_CWD
and createSandbox are the referenced symbols to modify.


try {
// Upload source files so the agent can explore them
const files = readSourceFiles(AGENT_DATA_DIR);
if (files.length > 0) {
await sandbox.writeFiles(files);
}

const bashToolkit = await createBashTool({
sandbox,
Expand Down
116 changes: 116 additions & 0 deletions app/api/exec/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Sandbox } from "@vercel/sandbox";
import { createSandbox } from "@/lib/createSandbox";

const SANDBOX_CWD = "/home/user";

async function fetchSourceFiles(): Promise<
Array<{ path: string; content: Buffer }>
> {
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
const res = await fetch(`${baseUrl}/api/fs`);
if (!res.ok) return [];
const filesMap: Record<string, string> = await res.json();
return Object.entries(filesMap).map(([path, content]) => ({
path: `${SANDBOX_CWD}/${path}`,
content: Buffer.from(content),
}));
}

async function createAndSeedSandbox(): Promise<Sandbox> {
let files: Array<{ path: string; content: Buffer }> = [];
try {
files = await fetchSourceFiles();
} catch {
// File seeding is best-effort
}

const sandbox = await createSandbox(files);

// Create convenience copies of top-level demo files
try {
await sandbox.runCommand({
cmd: "bash",
args: [
"-c",
[
`mkdir -p ${SANDBOX_CWD}/dirs/are/fun/author`,
`cp ${SANDBOX_CWD}/just-bash/README.md ${SANDBOX_CWD}/README.md 2>/dev/null || true`,
`cp ${SANDBOX_CWD}/just-bash/LICENSE ${SANDBOX_CWD}/LICENSE 2>/dev/null || true`,
`cp ${SANDBOX_CWD}/just-bash/package.json ${SANDBOX_CWD}/package.json 2>/dev/null || true`,
`echo 'https://x.com/cramforce' > ${SANDBOX_CWD}/dirs/are/fun/author/info.txt`,
].join(" && "),
],
cwd: SANDBOX_CWD,
});
} catch {
// Best-effort file setup
}

return sandbox;
}

export async function POST(req: Request) {
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { command, sandboxId } = await req.json();

if (!command || typeof command !== "string") {
return Response.json({ error: "Command is required" }, { status: 400 });
}

let sandbox: Sandbox;
let activeSandboxId: string;

if (sandboxId) {
try {
sandbox = await Sandbox.get({ sandboxId });
activeSandboxId = sandboxId;
} catch {
sandbox = await createAndSeedSandbox();
activeSandboxId = sandbox.sandboxId;
}
} else {
sandbox = await createAndSeedSandbox();
activeSandboxId = sandbox.sandboxId;
}
Comment on lines +67 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sandboxes are created but never stopped — potential resource leak.

When sandboxId is absent (or the existing one can't be found), createAndSeedSandbox() provisions a new sandbox. However, the sandbox is never explicitly stopped after the command completes. Unlike the agent route (which stops the sandbox after the stream ends), this endpoint leaves every sandbox running until the platform garbage-collects it. Under sustained traffic this could exhaust your sandbox quota.

Consider stopping the sandbox after the command finishes, or at minimum returning the sandboxId so the client can reuse it (which it does), and adding a TTL/cleanup mechanism.

🤖 Prompt for AI Agents
In `@app/api/exec/route.ts` around lines 67 - 81, The code creates new sandboxes
via createAndSeedSandbox() when sandboxId is missing or not found but never
stops them, causing resource leaks; update the exec route to (1) ensure any
sandbox you create is stopped after the command completes (e.g., call
sandbox.stop() or a provided Sandbox.stop(activeSandboxId) in the
finally/cleanup path), (2) return the activeSandboxId in the response so clients
can reuse an existing sandbox, and (3) add or trigger a TTL/cleanup mechanism
(e.g., enqueue the sandboxId for background cleanup or set an expiration) for
sandboxes created here to avoid long-lived resources. Ensure changes reference
the Sandbox instance, activeSandboxId and createAndSeedSandbox() locations so
the cleanup runs regardless of success/failure.


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,
});
Comment on lines +83 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

@vercel/sandbox runCommand timeout option documentation

💡 Result:

@vercel/sandbox does not expose a timeout option on sandbox.runCommand(). The runCommand() options include things like cwd, env, sudo, detached, stdout/stderr, and an AbortSignal (signal) to cancel the command, but no per-command timeout field. [1]

If you’re trying to avoid long-running work, you have two supported approaches:

  • Set the sandbox lifetime timeout when creating the sandbox: Sandbox.create({ timeout: <ms> }) (default is 5 minutes; plan maximums apply). [2][3]
  • Extend the sandbox lifetime while it’s running: sandbox.extendTimeout(<ms>). [2][3]

Source(s):

  • sandbox.runCommand() API/options (no timeout parameter): [1]
  • Sandbox lifetime timeout is configured on Sandbox.create() / extendTimeout(): [2][3]

🏁 Script executed:

# Find and examine the route.ts file mentioned in the review
find . -name "route.ts" -path "*api/exec*" | head -20

Repository: recoupable/bash

Length of output: 82


🏁 Script executed:

# Search for package.json to check the `@vercel/sandbox` version
find . -name "package.json" -type f | head -5

Repository: recoupable/bash

Length of output: 381


🏁 Script executed:

# Look for any timeout-related code in the codebase
rg "timeout" -i --type ts --type js | head -30

Repository: recoupable/bash

Length of output: 1837


🏁 Script executed:

cat -n ./app/api/exec/route.ts

Repository: recoupable/bash

Length of output: 4130


🏁 Script executed:

# Search for Sandbox.create and timeout configuration in the codebase
rg "Sandbox.create" -A 5 -B 2

Repository: recoupable/bash

Length of output: 1018


🏁 Script executed:

# Look for AbortSignal usage in the same file
rg "AbortSignal|AbortController" -A 3 -B 2

Repository: recoupable/bash

Length of output: 41


Add timeout protection to prevent long-running commands from blocking the serverless function.

sandbox.runCommand() lacks timeout protection. A user could submit a command like sleep 9999 or an infinite loop, consuming the entire serverless function execution time. While @vercel/sandbox does not expose a per-command timeout option, you can mitigate this in two ways:

  1. Set sandbox lifetime timeout when creating the sandbox in createSandbox.ts (line 29): Sandbox.create({ timeout: 30000 }) (adjust milliseconds as needed).
  2. Use AbortSignal to cancel long-running commands in the runCommand call (lines 84–88): implement an AbortController with a timeout and pass signal: controller.signal to runCommand.

Either approach prevents indefinite blocking. The first is simpler; the second offers per-command granularity.

🤖 Prompt for AI Agents
In `@app/api/exec/route.ts` around lines 83 - 98, The sandbox.runCommand calls can
hang on long commands; update sandbox creation in createSandbox.ts to include a
lifetime timeout (e.g., Sandbox.create({ timeout: 30000 })) and add per-command
cancellation by creating an AbortController around sandbox.runCommand (use a
timeout to call controller.abort() and pass signal: controller.signal to
sandbox.runCommand) so runCommand is aborted if it exceeds the intended
duration; modify the code paths that call sandbox.runCommand (the runCommand
invocation in route.ts) to use this AbortController and ensure any controller
cleanup is handled.

} catch (error) {
return Response.json({
stdout: "",
stderr: error instanceof Error ? error.message : "Execution failed",
exitCode: 1,
sandboxId: activeSandboxId,
});
}
} catch (error) {
console.error("[/api/exec] Error:", error);
return Response.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 },
);
}
}
127 changes: 93 additions & 34 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
"use client";

import { useEffect, useRef } from "react";
import { Bash } from "just-bash/browser";
import { getTerminalData } from "./TerminalData";
import {
createStaticCommands,
createAgentCommand,
CMD_ABOUT,
CMD_INSTALL,
CMD_GITHUB,
} from "./terminal-content";
import {
createAgentHandler,
createInputHandler,
showWelcome,
} from "./terminal-parts";
import { LiteTerminal } from "./lite-terminal";

async function fetchFiles(bash: Bash) {
const response = await fetch("/api/fs");
const files: Record<string, string> = await response.json();
for (const [path, content] of Object.entries(files)) {
bash.writeFile(path, content);
}
}

function getTheme(isDark: boolean) {
return {
background: isDark ? "#000" : "#fff",
Expand All @@ -30,6 +24,19 @@ function getTheme(isDark: boolean) {
};
}

type ExecResult = {
stdout: string;
stderr: string;
exitCode: number;
};

// Static commands handled client-side (no sandbox needed)
const staticCommands: Record<string, () => ExecResult> = {
about: () => ({ stdout: CMD_ABOUT, stderr: "", exitCode: 0 }),
install: () => ({ stdout: CMD_INSTALL, stderr: "", exitCode: 0 }),
github: () => ({ stdout: CMD_GITHUB, stderr: "", exitCode: 0 }),
};

export default function TerminalComponent({
getAccessToken,
}: {
Expand All @@ -49,31 +56,83 @@ export default function TerminalComponent({
});
term.open(container);

// Create commands
const { aboutCmd, installCmd, githubCmd } = createStaticCommands();
const agentCmd = createAgentCommand(term, getAccessToken);

// Files from DOM
const files = {
"/home/user/README.md": getTerminalData("file-readme"),
"/home/user/LICENSE": getTerminalData("file-license"),
"/home/user/package.json": getTerminalData("file-package-json"),
"/home/user/AGENTS.md": getTerminalData("file-agents-md"),
"/home/user/wtf-is-this.md": getTerminalData("file-wtf-is-this"),
"/home/user/dirs/are/fun/author/info.txt": "https://x.com/cramforce\n",
};
// Agent handler
const agentHandler = createAgentHandler(term, getAccessToken);

const bash = new Bash({
customCommands: [aboutCmd, installCmd, githubCmd, agentCmd],
files,
cwd: "/home/user",
});
// Sandbox session ID (persisted across commands)
let sandboxId: string | null = null;

// Unified exec function - all commands go through sandbox
const exec = async (command: string): Promise<ExecResult> => {
const trimmed = command.trim();
const firstWord = trimmed.split(/\s+/)[0];

// Set up input handling
const inputHandler = createInputHandler(term, bash);
// Static commands (about, install, github) - no sandbox needed
if (firstWord in staticCommands) {
return staticCommands[firstWord]();
}

// Agent command - uses its own API endpoint
if (firstWord === "agent") {
let prompt = trimmed.slice(5).trim();
// Strip surrounding quotes
if (
(prompt.startsWith('"') && prompt.endsWith('"')) ||
(prompt.startsWith("'") && prompt.endsWith("'"))
) {
prompt = prompt.slice(1, -1);
}
return agentHandler(prompt);
}

// All other commands → sandbox
const token = await getAccessToken();
if (!token) {
return {
stdout: "",
stderr: "Error: Not authenticated. Please log in and try again.\n",
exitCode: 1,
};
}

try {
const res = await fetch("/api/exec", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ command: trimmed, sandboxId }),
});

if (!res.ok) {
return {
stdout: "",
stderr: `Error: ${res.status} ${res.statusText}\n`,
exitCode: 1,
};
}

const result = await res.json();
if (result.sandboxId) {
sandboxId = result.sandboxId;
}
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
exitCode: result.exitCode ?? 0,
};
} catch (error) {
return {
stdout: "",
stderr: `Error: ${error instanceof Error ? error.message : "Unknown error"}\n`,
exitCode: 1,
};
}
};

// Load additional files from API into bash filesystem
void fetchFiles(bash);
// Set up input handling with unified exec
const inputHandler = createInputHandler(term, exec);

// Track cleanup state
let disposed = false;
Expand Down
18 changes: 10 additions & 8 deletions app/components/terminal-parts/agent-command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { defineCommand } from "just-bash/browser";
import { MAX_TOOL_OUTPUT_LINES } from "./constants";
import { formatMarkdown } from "./markdown";

type ExecResult = {
stdout: string;
stderr: string;
exitCode: number;
};

type UIMessage = {
id: string;
role: "user" | "assistant";
Expand All @@ -17,15 +22,14 @@ function formatForTerminal(text: string): string {
return text.replace(/\t/g, " ").replace(/\r?\n/g, "\r\n");
}

export function createAgentCommand(
export function createAgentHandler(
term: TerminalWriter,
getAccessToken: () => Promise<string | null>,
) {
): (prompt: string) => Promise<ExecResult> {
const agentMessages: UIMessage[] = [];
let messageIdCounter = 0;

const agentCmd = defineCommand("agent", async (args) => {
const prompt = args.join(" ");
return async (prompt: string): Promise<ExecResult> => {
if (!prompt) {
return {
stdout: "",
Expand Down Expand Up @@ -335,7 +339,5 @@ export function createAgentCommand(
exitCode: 1,
};
}
});

return agentCmd;
};
}
3 changes: 1 addition & 2 deletions app/components/terminal-parts/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export { ASCII_ART, HISTORY_KEY, MAX_HISTORY, MAX_TOOL_OUTPUT_LINES } from "./constants";
export { createStaticCommands } from "./commands";
export { createAgentCommand } from "./agent-command";
export { createAgentHandler } from "./agent-command";
export { createInputHandler } from "./input-handler";
export { showWelcome } from "./welcome";
export { formatMarkdown } from "./markdown";
Loading