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