const BOOT_FILES = { "package.json": { file: { contents: JSON.stringify( { type: "module", scripts: { demo: "node hello.js", }, dependencies: {}, devDependencies: {}, }, null, 2, ), }, }, "README.md": { file: { contents: "# Pi web sandbox\n\nThis filesystem and every spawned process run inside WebContainers in this browser tab.\n", }, }, "hello.js": { file: { contents: 'console.log("hello from the browser sandbox");\nconsole.log(2 + 2);\n', }, }, }; const MAX_OUTPUT_CHARS = 16000; const DEFAULT_TIMEOUT_MS = 10000; const DEFAULT_MAX_READ_LINES = 2000; const DEFAULT_RESULT_LIMIT = 200; function splitCommandLine(value) { const parts = []; let current = ""; let quote = ""; let escaping = false; for (const char of String(value || "")) { if (escaping) { current += char; escaping = false; continue; } if (char === "\\") { escaping = true; continue; } if (quote) { if (char === quote) { quote = ""; } else { current += char; } continue; } if (char === "\"" || char === "'") { quote = char; } else if (/\s/.test(char)) { if (current) { parts.push(current); current = ""; } } else { current += char; } } if (current) parts.push(current); return parts; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function globToRegExp(glob) { const source = String(glob || "*") .split("*") .map(escapeRegExp) .join(".*"); return new RegExp(`^${source}$`); } export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) { let instance = null; let booting = null; let root = ""; async function boot() { if (instance) return instance; if (booting) return booting; booting = (async () => { if (!globalThis.crossOriginIsolated) { throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers."); } onStatus("Booting WebContainer"); const { WebContainer } = await import("@webcontainer/api"); instance = await WebContainer.boot({ coep: "credentialless", workdirName: "workspace", }); root = instance.workdir; await instance.mount(BOOT_FILES); onLog(`sandbox booted at ${root}`); onStatus("Sandbox ready"); return instance; })(); try { return await booting; } finally { booting = null; } } function assertRelativePath(path, { allowDot = false } = {}) { let value = String(path ?? (allowDot ? "." : "")).trim(); if (value === "" && allowDot) value = "."; value = value.replace(/^\/+/, "").replace(/^workspace\/?/, ""); if (value === "." && allowDot) return "."; if (!value || value.includes("\0")) throw new Error("Path is required."); const segments = value.split("/").filter(Boolean); if (segments.some((segment) => segment === "..")) { throw new Error("Paths must stay inside the sandbox workspace."); } const normalized = segments.filter((segment) => segment !== ".").join("/"); if (!normalized && allowDot) return "."; if (!normalized) throw new Error("Path is required."); return normalized; } function toWorkspacePath(path, options) { return assertRelativePath(path, options); } async function pathExists(path) { const wc = await boot(); try { await wc.fs.stat(toWorkspacePath(path, { allowDot: true })); return true; } catch { return false; } } async function reset() { const wc = await boot(); const entries = await wc.fs.readdir("."); await Promise.all( entries.map((entry) => wc.fs.rm(entry, { force: true, recursive: true, }), ), ); await wc.mount(BOOT_FILES); onLog("sandbox reset"); return "Sandbox reset to the starter project."; } async function walkFiles(start = ".") { const wc = await boot(); const rootPath = toWorkspacePath(start, { allowDot: true }); const files = []; async function walk(current) { const entries = await wc.fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git") continue; const child = current === "." ? entry.name : `${current}/${entry.name}`; if (entry.isDirectory()) { await walk(child); } else { files.push(child); } } } const stat = await wc.fs.stat(rootPath); if (stat.isDirectory()) { await walk(rootPath); } else { files.push(rootPath); } return files; } async function ls(path = ".", limit = DEFAULT_RESULT_LIMIT) { const wc = await boot(); const target = toWorkspacePath(path || ".", { allowDot: true }); const stat = await wc.fs.stat(target); if (!stat.isDirectory()) return `file ${target}`; const entries = await wc.fs.readdir(target, { withFileTypes: true }); const capped = entries.slice(0, Number(limit || DEFAULT_RESULT_LIMIT)); const lines = capped.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`); if (entries.length > capped.length) { lines.push(`[Showing ${capped.length} of ${entries.length} entries. Use a larger limit to continue.]`); } return lines.join("\n") || "(empty)"; } async function readTextFile(path, { offset, limit } = {}) { const wc = await boot(); const target = toWorkspacePath(path); const text = await wc.fs.readFile(target, "utf-8"); const lines = text.split("\n"); const start = Math.max(0, Number(offset || 1) - 1); if (start >= lines.length) throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`); const maxLines = Number(limit || DEFAULT_MAX_READ_LINES); const selected = lines.slice(start, start + maxLines); let output = selected.join("\n"); if (start + selected.length < lines.length) { output += `\n\n[${lines.length - start - selected.length} more lines in file. Use offset=${start + selected.length + 1} to continue.]`; } return output; } async function writeFile(path, content) { const wc = await boot(); const relative = assertRelativePath(path); const parent = relative.split("/").slice(0, -1).join("/"); if (parent) { await wc.fs.mkdir(parent, { recursive: true }); } await wc.fs.writeFile(relative, String(content ?? "")); onLog(`wrote ${relative}`); return `Successfully wrote ${String(content ?? "").length} bytes to ${relative}`; } async function editFile(path, edits = []) { const wc = await boot(); const target = toWorkspacePath(path); if (!Array.isArray(edits) || edits.length === 0) { throw new Error("Edit tool input is invalid. edits must contain at least one replacement."); } const original = await wc.fs.readFile(target, "utf-8"); const replacements = []; for (const edit of edits) { const oldText = String(edit?.oldText ?? ""); const newText = String(edit?.newText ?? ""); if (!oldText) throw new Error("Every edit.oldText must be non-empty."); const first = original.indexOf(oldText); if (first === -1) throw new Error(`Could not edit file: ${path}. oldText was not found.`); if (original.indexOf(oldText, first + oldText.length) !== -1) { throw new Error(`Could not edit file: ${path}. oldText must match exactly one location.`); } replacements.push({ start: first, end: first + oldText.length, newText }); } replacements.sort((a, b) => b.start - a.start); for (let index = 1; index < replacements.length; index += 1) { if (replacements[index].end > replacements[index - 1].start) { throw new Error("Edit tool input is invalid. edits must not overlap."); } } let next = original; for (const replacement of replacements) { next = `${next.slice(0, replacement.start)}${replacement.newText}${next.slice(replacement.end)}`; } await wc.fs.writeFile(target, next); onLog(`edited ${target}`); return `Successfully replaced ${edits.length} block(s) in ${path}.`; } async function findFiles(pattern = "*", path = ".", limit = DEFAULT_RESULT_LIMIT) { const start = toWorkspacePath(path || ".", { allowDot: true }); if (!(await pathExists(start))) throw new Error(`Path does not exist: ${path || "."}`); const matcher = globToRegExp(pattern); const files = await walkFiles(start); const matches = files.filter((file) => matcher.test(file) || matcher.test(file.split("/").pop() || "")); const capped = matches.slice(0, Number(limit || DEFAULT_RESULT_LIMIT)); if (matches.length > capped.length) capped.push(`[Showing ${capped.length} of ${matches.length} matches. Use a larger limit to continue.]`); return capped.join("\n") || "(no matches)"; } async function grepFiles({ pattern, path = ".", glob = "*", ignoreCase = false, literal = false, context = 0, limit = DEFAULT_RESULT_LIMIT }) { if (!pattern) throw new Error("pattern is required."); const flags = ignoreCase ? "i" : ""; const matcher = literal ? new RegExp(escapeRegExp(pattern), flags) : new RegExp(pattern, flags); const globMatcher = globToRegExp(glob || "*"); const files = (await walkFiles(path || ".")).filter((file) => globMatcher.test(file) || globMatcher.test(file.split("/").pop() || "")); const matches = []; for (const file of files) { let text = ""; try { text = await readTextFile(file, { limit: DEFAULT_MAX_READ_LINES }); } catch { continue; } const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { if (!matcher.test(lines[index])) continue; matcher.lastIndex = 0; const before = Math.max(0, index - Number(context || 0)); const after = Math.min(lines.length - 1, index + Number(context || 0)); for (let lineIndex = before; lineIndex <= after; lineIndex += 1) { matches.push(`${file}:${lineIndex + 1}:${lines[lineIndex]}`); if (matches.length >= Number(limit || DEFAULT_RESULT_LIMIT)) { matches.push(`[Showing first ${matches.length - 1} matches. Use a larger limit to continue.]`); return matches.join("\n"); } } } } return matches.join("\n") || "(no matches)"; } async function spawnProcess(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) { const wc = await boot(); const cmd = String(command || "").trim(); if (!cmd) throw new Error("Command is required."); const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : []; onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`); const process = await wc.spawn(cmd, cleanArgs, { terminal: { cols: 96, rows: 28, }, }); let output = ""; const reader = process.output.getReader(); const pump = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; output += value; if (output.length > MAX_OUTPUT_CHARS) { output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`; process.kill(); break; } } })(); const timeout = new Promise((resolve) => { setTimeout(() => { process.kill(); resolve("timeout"); }, Number(timeoutMs) || DEFAULT_TIMEOUT_MS); }); const exitCode = await Promise.race([process.exit, timeout]); await pump.catch(() => {}); const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode; const result = { command: [cmd, ...cleanArgs].join(" "), exitCode: normalizedExitCode, output: output.trimEnd(), }; onLog(`exit ${normalizedExitCode}`); return result; } async function bash(command, timeoutSeconds) { const timeoutMs = timeoutSeconds ? Number(timeoutSeconds) * 1000 : DEFAULT_TIMEOUT_MS; try { return await spawnProcess("jsh", ["-c", command], timeoutMs); } catch (error) { const parts = splitCommandLine(command); if (parts.length === 0) throw error; return await spawnProcess(parts[0], parts.slice(1), timeoutMs); } } return { boot, reset, bash, editFile, findFiles, grepFiles, ls, listFiles: ls, readFile: (path) => readTextFile(path), readTextFile, writeFile, runCommand: spawnProcess, get isReady() { return Boolean(instance); }, }; }