diff --git a/app/api/_lib/createSandbox.ts b/app/api/_lib/createSandbox.ts new file mode 100644 index 00000000..981b0675 --- /dev/null +++ b/app/api/_lib/createSandbox.ts @@ -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 { + const sandbox = await Sandbox.create(); + if (files.length > 0) { + await sandbox.writeFiles(files); + } + return sandbox; +} diff --git a/app/api/_lib/readSourceFiles.ts b/app/api/_lib/readSourceFiles.ts new file mode 100644 index 00000000..cde9edc0 --- /dev/null +++ b/app/api/_lib/readSourceFiles.ts @@ -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; +} diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 022fa314..df8d3410 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -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"); @@ -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 ")) { @@ -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, diff --git a/app/api/exec/route.ts b/app/api/exec/route.ts new file mode 100644 index 00000000..d1f7f7f1 --- /dev/null +++ b/app/api/exec/route.ts @@ -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 = await res.json(); + return Object.entries(filesMap).map(([path, content]) => ({ + path: `${SANDBOX_CWD}/${path}`, + content: Buffer.from(content), + })); +} + +async function createAndSeedSandbox(): Promise { + 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 }, + ); + } +} diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index 0eba43fb..b8343815 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -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 = await response.json(); - for (const [path, content] of Object.entries(files)) { - bash.writeFile(path, content); - } -} - function getTheme(isDark: boolean) { return { background: isDark ? "#000" : "#fff", @@ -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 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, }: { @@ -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 => { + 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; diff --git a/app/components/terminal-parts/agent-command.ts b/app/components/terminal-parts/agent-command.ts index b19aadcc..2c9d58b5 100644 --- a/app/components/terminal-parts/agent-command.ts +++ b/app/components/terminal-parts/agent-command.ts @@ -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"; @@ -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, -) { +): (prompt: string) => Promise { const agentMessages: UIMessage[] = []; let messageIdCounter = 0; - const agentCmd = defineCommand("agent", async (args) => { - const prompt = args.join(" "); + return async (prompt: string): Promise => { if (!prompt) { return { stdout: "", @@ -335,7 +339,5 @@ export function createAgentCommand( exitCode: 1, }; } - }); - - return agentCmd; + }; } diff --git a/app/components/terminal-parts/index.ts b/app/components/terminal-parts/index.ts index 331e2f31..5786cc49 100644 --- a/app/components/terminal-parts/index.ts +++ b/app/components/terminal-parts/index.ts @@ -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"; diff --git a/app/components/terminal-parts/input-handler.ts b/app/components/terminal-parts/input-handler.ts index b3e97bb7..9de3ba8e 100644 --- a/app/components/terminal-parts/input-handler.ts +++ b/app/components/terminal-parts/input-handler.ts @@ -1,4 +1,3 @@ -import type { Bash } from "just-bash/browser"; import { track } from "@vercel/analytics"; import { HISTORY_KEY, MAX_HISTORY } from "./constants"; import { formatMarkdown } from "./markdown"; @@ -10,6 +9,14 @@ type Terminal = { onData: (callback: (data: string) => void) => void; }; +type ExecResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +type ExecFn = (command: string) => Promise; + // Find the start of the previous word function findPrevWordBoundary(str: string, pos: number): number { @@ -47,7 +54,7 @@ function getCompletionContext(cmd: string, cursorPos: number): { prefix: string; }; } -export function createInputHandler(term: Terminal, bash: Bash) { +export function createInputHandler(term: Terminal, exec: ExecFn) { const history: string[] = JSON.parse( sessionStorage.getItem(HISTORY_KEY) || "[]" ); @@ -198,7 +205,7 @@ export function createInputHandler(term: Terminal, bash: Bash) { candidates = commands; } else { // Complete files from current directory - const lsResult = await bash.exec("ls -1"); + const lsResult = await exec("ls -1"); candidates = lsResult.stdout .split("\n") .map((f) => f.trim()) @@ -272,7 +279,7 @@ export function createInputHandler(term: Terminal, bash: Bash) { if (trimmed === "clear") { term.write("\x1b[2J\x1b[3J\x1b[H"); } else { - const result = await bash.exec(trimmed); + const result = await exec(trimmed); if (result.stdout) term.write( formatMarkdown(colorizeUrls(result.stdout)).replace(/\n/g, "\r\n")