Skip to content
Open
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
14 changes: 14 additions & 0 deletions app/api/_lib/createSandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Sandbox } from "@vercel/sandbox";

/**
* Create a Vercel Sandbox and seed it with files.
*/
export async function createSandbox(
files: Array<{ path: string; content: Buffer }>
): Promise<Sandbox> {
const sandbox = await Sandbox.create();
if (files.length > 0) {
await sandbox.writeFiles(files);
}
return sandbox;
}
31 changes: 31 additions & 0 deletions app/api/_lib/readSourceFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readdirSync, readFileSync } from "fs";
import { join, relative } from "path";

/**
* Recursively read all files from a directory, returning them in the format
* expected by Sandbox.writeFiles().
*/
export function readSourceFiles(
dir: string,
destDir: 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()) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
files.push(...readSourceFiles(fullPath, destDir, base));
} else {
const relPath = relative(base, fullPath);
files.push({
path: join(destDir, relPath),
content: readFileSync(fullPath),
});
}
}

return files;
}
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);

const sandbox = await Sandbox.create();
const files = readSourceFiles(AGENT_DATA_DIR, SANDBOX_CWD);
const sandbox = await createSandbox(files);

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;
}

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,
});
}
} catch (error) {
console.error("[/api/exec] Error:", error);
return Response.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 },
);
}
Comment on lines +82 to +115
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 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 timeout when calling Sandbox.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 -5

Repository: 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 -90

Repository: recoupable/bash

Length of output: 3389


🏁 Script executed:

# Search for the sandbox creation function
rg -A 20 "createAndSeedSandbox" app/api/exec/route.ts

Repository: 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").

}
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
Loading