diff --git a/packages/bun-sandbox/package.json b/packages/bun-sandbox/package.json new file mode 100644 index 0000000000..ab0c375c75 --- /dev/null +++ b/packages/bun-sandbox/package.json @@ -0,0 +1,23 @@ +{ + "name": "bun-sandbox", + "version": "0.1.0", + "description": "Sandboxfile runtime for agent sandboxes", + "main": "src/index.ts", + "types": "src/index.ts", + "bin": { + "bun-sandbox": "src/cli.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "bun x tsc --noEmit" + }, + "keywords": [ + "sandbox", + "agent", + "bun" + ], + "license": "MIT", + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/packages/bun-sandbox/src/cli.ts b/packages/bun-sandbox/src/cli.ts new file mode 100644 index 0000000000..bd466cf7a6 --- /dev/null +++ b/packages/bun-sandbox/src/cli.ts @@ -0,0 +1,517 @@ +#!/usr/bin/env bun +/** + * Sandbox CLI + * + * Run agent sandboxes from Sandboxfile declarations. + * + * Usage: + * bun-sandbox [options] [sandboxfile] + * bun-sandbox run [sandboxfile] - Run the full sandbox lifecycle + * bun-sandbox test [sandboxfile] - Run only tests + * bun-sandbox infer [dir] - Infer a Sandboxfile from a project + * bun-sandbox validate [sandboxfile] - Validate a Sandboxfile + */ + +import { inferSandboxfile, parseSandboxfileFromPath, Sandbox, type Sandboxfile, type SandboxOptions } from "./index"; + +const HELP = ` +Sandbox CLI - Run agent sandboxes from Sandboxfile declarations + +Usage: + bun-sandbox [options] [sandboxfile] + bun-sandbox run [sandboxfile] Run the full sandbox lifecycle + bun-sandbox test [sandboxfile] Run only tests + bun-sandbox infer [dir] Infer a Sandboxfile from a project + bun-sandbox validate [sandboxfile] Validate a Sandboxfile + bun-sandbox extract [sandboxfile] Extract outputs to a directory + +Options: + -h, --help Show this help message + -v, --verbose Enable verbose logging + -w, --watch Watch for changes and restart + -o, --output Output directory for extracted files + -e, --env Set environment variable + --no-color Disable colored output + +Examples: + bun-sandbox Run sandbox from ./Sandboxfile + bun-sandbox run ./my-sandbox Run sandbox from custom path + bun-sandbox test Run only tests from ./Sandboxfile + bun-sandbox infer Generate Sandboxfile from current project + bun-sandbox validate ./Sandboxfile Check if Sandboxfile is valid +`; + +interface CLIOptions { + command: "run" | "test" | "infer" | "validate" | "extract" | "help"; + sandboxfile: string; + verbose: boolean; + watch: boolean; + outputDir?: string; + env: Record; + noColor: boolean; +} + +function parseArgs(args: string[]): CLIOptions { + const options: CLIOptions = { + command: "run", + sandboxfile: "Sandboxfile", + verbose: false, + watch: false, + env: {}, + noColor: false, + }; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === "-h" || arg === "--help") { + options.command = "help"; + return options; + } else if (arg === "-v" || arg === "--verbose") { + options.verbose = true; + } else if (arg === "-w" || arg === "--watch") { + options.watch = true; + } else if (arg === "-o" || arg === "--output") { + options.outputDir = args[++i]; + } else if (arg === "-e" || arg === "--env") { + const envArg = args[++i]; + const eqIdx = envArg.indexOf("="); + if (eqIdx > 0) { + options.env[envArg.slice(0, eqIdx)] = envArg.slice(eqIdx + 1); + } + } else if (arg === "--no-color") { + options.noColor = true; + } else if (arg === "run" || arg === "test" || arg === "infer" || arg === "validate" || arg === "extract") { + options.command = arg; + } else if (!arg.startsWith("-")) { + options.sandboxfile = arg; + } + + i++; + } + + return options; +} + +// Color helpers +const colors = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function color(text: string, c: keyof typeof colors, noColor: boolean): string { + if (noColor) return text; + return `${colors[c]}${text}${colors.reset}`; +} + +async function runCommand(options: CLIOptions): Promise { + const { noColor } = options; + + console.log(color("Sandbox", "cyan", noColor), color("v0.1.0", "dim", noColor)); + console.log(); + + // Check if Sandboxfile exists + const sandboxfilePath = options.sandboxfile; + const file = Bun.file(sandboxfilePath); + + if (!(await file.exists())) { + console.error(color(`Error: Sandboxfile not found: ${sandboxfilePath}`, "red", noColor)); + return 1; + } + + // Parse Sandboxfile + let config: Sandboxfile; + try { + config = await parseSandboxfileFromPath(sandboxfilePath); + } catch (err) { + console.error(color(`Error parsing Sandboxfile: ${err}`, "red", noColor)); + return 1; + } + + console.log(color(`Loaded: ${sandboxfilePath}`, "dim", noColor)); + console.log(color(`FROM: ${config.from || "host"}`, "dim", noColor)); + console.log(color(`WORKDIR: ${config.workdir || "."}`, "dim", noColor)); + console.log(); + + // Create sandbox + const sandboxOptions: SandboxOptions = { + verbose: options.verbose, + env: options.env, + onStdout: (service, data) => { + const prefix = color(`[${service}]`, "cyan", noColor); + process.stdout.write(`${prefix} ${data}`); + }, + onStderr: (service, data) => { + const prefix = color(`[${service}]`, "yellow", noColor); + process.stderr.write(`${prefix} ${data}`); + }, + onExit: (service, code) => { + const status = code === 0 ? color("exited", "green", noColor) : color(`exited(${code})`, "red", noColor); + console.log(color(`[${service}]`, "cyan", noColor), status); + }, + }; + + const sandbox = new Sandbox(config, sandboxOptions); + + // Handle SIGINT/SIGTERM + const cleanup = async () => { + console.log(); + console.log(color("Shutting down...", "yellow", noColor)); + await sandbox.stop(); + process.exit(0); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + // Run the sandbox + console.log(color("Starting sandbox...", "bold", noColor)); + console.log(); + + const result = await sandbox.run(); + + if (result.testResults) { + console.log(); + console.log(color("Test Results:", "bold", noColor)); + for (const test of result.testResults.results) { + const status = test.passed ? color("PASS", "green", noColor) : color("FAIL", "red", noColor); + console.log(` ${status} ${test.name}`); + } + console.log(); + } + + // If services are still running, wait for them + if (sandbox.isRunning()) { + console.log(color("Services running. Press Ctrl+C to stop.", "dim", noColor)); + + // Keep the process alive + await new Promise(() => {}); + } + + // Extract outputs if requested + if (options.outputDir) { + console.log(color(`Extracting outputs to ${options.outputDir}...`, "dim", noColor)); + const extracted = await sandbox.extractOutputs(options.outputDir); + console.log(color(`Extracted ${extracted.length} files`, "green", noColor)); + } + + return result.success ? 0 : 1; +} + +async function testCommand(options: CLIOptions): Promise { + const { noColor } = options; + + console.log(color("Sandbox Test", "cyan", noColor)); + console.log(); + + // Check if Sandboxfile exists + const sandboxfilePath = options.sandboxfile; + const file = Bun.file(sandboxfilePath); + + if (!(await file.exists())) { + console.error(color(`Error: Sandboxfile not found: ${sandboxfilePath}`, "red", noColor)); + return 1; + } + + // Parse Sandboxfile + let config: Sandboxfile; + try { + config = await parseSandboxfileFromPath(sandboxfilePath); + } catch (err) { + console.error(color(`Error parsing Sandboxfile: ${err}`, "red", noColor)); + return 1; + } + + if (config.tests.length === 0) { + console.log(color("No tests defined in Sandboxfile", "yellow", noColor)); + return 0; + } + + // Create sandbox + const sandboxOptions: SandboxOptions = { + verbose: options.verbose, + env: options.env, + onStdout: (service, data) => { + const prefix = color(`[${service}]`, "cyan", noColor); + process.stdout.write(`${prefix} ${data}`); + }, + onStderr: (service, data) => { + const prefix = color(`[${service}]`, "yellow", noColor); + process.stderr.write(`${prefix} ${data}`); + }, + }; + + const sandbox = new Sandbox(config, sandboxOptions); + + // Run setup first + console.log(color("Running setup...", "dim", noColor)); + const setupSuccess = await sandbox.runSetup(); + if (!setupSuccess) { + console.error(color("Setup failed", "red", noColor)); + return 1; + } + + // Start services if needed + if (config.services.length > 0) { + console.log(color("Starting services...", "dim", noColor)); + await sandbox.startServices(); + // Wait for services to be ready + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Run tests + console.log(color("Running tests...", "bold", noColor)); + console.log(); + + const testResults = await sandbox.runTests(); + + // Stop services + await sandbox.stop(); + + // Print results + console.log(); + console.log(color("Results:", "bold", noColor)); + for (const test of testResults.results) { + const status = test.passed ? color("PASS", "green", noColor) : color("FAIL", "red", noColor); + console.log(` ${status} ${test.name}`); + } + + console.log(); + const summary = testResults.passed + ? color(`All ${testResults.results.length} tests passed`, "green", noColor) + : color( + `${testResults.results.filter(t => !t.passed).length} of ${testResults.results.length} tests failed`, + "red", + noColor, + ); + console.log(summary); + + return testResults.passed ? 0 : 1; +} + +async function inferCommand(options: CLIOptions): Promise { + const { noColor } = options; + + console.log(color("Inferring Sandboxfile...", "cyan", noColor)); + console.log(); + + const dir = options.sandboxfile !== "Sandboxfile" ? options.sandboxfile : process.cwd(); + const config = await inferSandboxfile(dir); + + // Generate Sandboxfile content + let output = "# Sandboxfile (auto-generated)\n\n"; + + if (config.from) output += `FROM ${config.from}\n`; + if (config.workdir) output += `WORKDIR ${config.workdir}\n`; + output += "\n"; + + for (const cmd of config.runCommands) { + output += `RUN ${cmd}\n`; + } + if (config.runCommands.length > 0) output += "\n"; + + if (config.dev) { + output += `DEV ${config.dev.command}\n`; + } + + for (const service of config.services) { + output += `SERVICE ${service.name}`; + if (service.port) output += ` PORT=${service.port}`; + if (service.watch) output += ` WATCH=${service.watch}`; + output += ` ${service.command}\n`; + } + if (config.services.length > 0 || config.dev) output += "\n"; + + for (const test of config.tests) { + output += `TEST ${test.command}\n`; + } + if (config.tests.length > 0) output += "\n"; + + for (const out of config.outputs) { + output += `OUTPUT ${out}\n`; + } + if (config.outputs.length > 0) output += "\n"; + + for (const log of config.logs) { + output += `LOGS ${log}\n`; + } + if (config.logs.length > 0) output += "\n"; + + for (const net of config.net) { + output += `NET ${net}\n`; + } + if (config.net.length > 0) output += "\n"; + + for (const secret of config.secrets) { + output += `SECRET ${secret}\n`; + } + + console.log(output); + + // Optionally write to file + if (options.outputDir) { + const outPath = `${options.outputDir}/Sandboxfile`; + await Bun.write(outPath, output); + console.log(color(`Written to: ${outPath}`, "green", noColor)); + } + + return 0; +} + +async function validateCommand(options: CLIOptions): Promise { + const { noColor } = options; + + console.log(color("Validating Sandboxfile...", "cyan", noColor)); + + const sandboxfilePath = options.sandboxfile; + const file = Bun.file(sandboxfilePath); + + if (!(await file.exists())) { + console.error(color(`Error: Sandboxfile not found: ${sandboxfilePath}`, "red", noColor)); + return 1; + } + + try { + const config = await parseSandboxfileFromPath(sandboxfilePath); + + // Basic validation + const warnings: string[] = []; + const errors: string[] = []; + + if (!config.from) { + warnings.push("No FROM directive (defaulting to 'host')"); + } + + if (!config.workdir) { + warnings.push("No WORKDIR directive (defaulting to '.')"); + } + + if (config.runCommands.length === 0 && config.services.length === 0 && !config.dev && config.tests.length === 0) { + warnings.push("No commands defined (RUN, DEV, SERVICE, or TEST)"); + } + + if (config.outputs.length === 0) { + warnings.push("No OUTPUT paths defined (all changes will be ephemeral)"); + } + + if (config.net.length === 0) { + warnings.push("No NET hosts defined (network access will be denied)"); + } + + // Print results + console.log(); + + if (errors.length > 0) { + console.log(color("Errors:", "red", noColor)); + for (const err of errors) { + console.log(` ${color("x", "red", noColor)} ${err}`); + } + console.log(); + } + + if (warnings.length > 0) { + console.log(color("Warnings:", "yellow", noColor)); + for (const warn of warnings) { + console.log(` ${color("!", "yellow", noColor)} ${warn}`); + } + console.log(); + } + + // Print summary + console.log(color("Summary:", "bold", noColor)); + console.log(` FROM: ${config.from || "host"}`); + console.log(` WORKDIR: ${config.workdir || "."}`); + console.log(` RUN commands: ${config.runCommands.length}`); + console.log(` Services: ${config.services.length}`); + console.log(` Tests: ${config.tests.length}`); + console.log(` Outputs: ${config.outputs.length}`); + console.log(` Network hosts: ${config.net.length}`); + console.log(` Secrets: ${config.secrets.length}`); + console.log(); + + if (errors.length === 0) { + console.log(color("Sandboxfile is valid", "green", noColor)); + return 0; + } else { + console.log(color("Sandboxfile has errors", "red", noColor)); + return 1; + } + } catch (err) { + console.error(color(`Error: ${err}`, "red", noColor)); + return 1; + } +} + +async function extractCommand(options: CLIOptions): Promise { + const { noColor } = options; + + if (!options.outputDir) { + console.error(color("Error: --output directory required for extract command", "red", noColor)); + return 1; + } + + console.log(color("Extracting outputs...", "cyan", noColor)); + + const sandboxfilePath = options.sandboxfile; + const file = Bun.file(sandboxfilePath); + + if (!(await file.exists())) { + console.error(color(`Error: Sandboxfile not found: ${sandboxfilePath}`, "red", noColor)); + return 1; + } + + try { + const config = await parseSandboxfileFromPath(sandboxfilePath); + const sandbox = new Sandbox(config, { verbose: options.verbose }); + + const extracted = await sandbox.extractOutputs(options.outputDir); + + console.log(); + console.log(color(`Extracted ${extracted.length} files:`, "green", noColor)); + for (const f of extracted) { + console.log(` ${f}`); + } + + return 0; + } catch (err) { + console.error(color(`Error: ${err}`, "red", noColor)); + return 1; + } +} + +// Main entry point +async function main(): Promise { + const args = process.argv.slice(2); + const options = parseArgs(args); + + switch (options.command) { + case "help": + console.log(HELP); + return 0; + case "run": + return runCommand(options); + case "test": + return testCommand(options); + case "infer": + return inferCommand(options); + case "validate": + return validateCommand(options); + case "extract": + return extractCommand(options); + default: + console.log(HELP); + return 1; + } +} + +// Run if executed directly +const exitCode = await main(); +process.exit(exitCode); diff --git a/packages/bun-sandbox/src/index.ts b/packages/bun-sandbox/src/index.ts new file mode 100644 index 0000000000..cd046105fe --- /dev/null +++ b/packages/bun-sandbox/src/index.ts @@ -0,0 +1,787 @@ +/** + * Sandboxfile Runtime + * + * Executes agent sandboxes based on Sandboxfile declarations. + * Provides ephemeral environments with controlled network access, + * secret management, and output extraction. + */ + +// Types +export interface SandboxProcess { + name?: string; + command: string; + port?: number; + watch?: string; +} + +export interface SandboxService { + name: string; + command: string; + port?: number; + watch?: string; +} + +export interface Sandboxfile { + from?: string; + workdir?: string; + runCommands: string[]; + dev?: SandboxProcess; + services: SandboxService[]; + tests: SandboxProcess[]; + outputs: string[]; + logs: string[]; + net: string[]; + secrets: string[]; + infer?: string; +} + +export interface SandboxOptions { + /** Working directory for the sandbox */ + cwd?: string; + /** Environment variables to pass through */ + env?: Record; + /** Callback for stdout data */ + onStdout?: (service: string, data: string) => void; + /** Callback for stderr data */ + onStderr?: (service: string, data: string) => void; + /** Callback when a service exits */ + onExit?: (service: string, code: number | null) => void; + /** Enable verbose logging */ + verbose?: boolean; +} + +interface RunningProcess { + name: string; + proc: ReturnType; + type: "run" | "dev" | "service" | "test"; +} + +/** + * Sandbox Runtime - manages the lifecycle of a sandbox environment + */ +export class Sandbox { + private config: Sandboxfile; + private options: SandboxOptions; + private processes: Map = new Map(); + private workdir: string; + private secretValues: Map = new Map(); + private aborted = false; + + constructor(config: Sandboxfile, options: SandboxOptions = {}) { + this.config = config; + this.options = options; + this.workdir = this.resolveWorkdir(); + } + + private resolveWorkdir(): string { + const base = this.options.cwd || process.cwd(); + if (!this.config.workdir || this.config.workdir === ".") { + return base; + } + // Check if workdir is absolute + if (this.config.workdir.startsWith("/")) { + return this.config.workdir; + } + return `${base}/${this.config.workdir}`; + } + + private log(message: string): void { + if (this.options.verbose) { + console.log(`[sandbox] ${message}`); + } + } + + private buildEnv(): Record { + const env: Record = { + ...(process.env as Record), + ...this.options.env, + }; + + // Add secrets (values loaded from environment) + for (const secretName of this.config.secrets) { + const value = this.secretValues.get(secretName); + if (value !== undefined) { + env[secretName] = value; + } + } + + return env; + } + + /** + * Load secret values from the environment + * Secrets are loaded once at startup and redacted from inspection + */ + loadSecrets(): void { + for (const secretName of this.config.secrets) { + const value = process.env[secretName] || this.options.env?.[secretName]; + if (value !== undefined) { + this.secretValues.set(secretName, value); + this.log(`Loaded secret: ${secretName}`); + } else { + console.warn(`[sandbox] Warning: Secret ${secretName} not found in environment`); + } + } + } + + /** + * Validate network access for a given hostname + */ + isNetworkAllowed(hostname: string): boolean { + // If no NET rules, deny all external access + if (this.config.net.length === 0) { + return false; + } + + // Check if hostname matches any allowed pattern + for (const allowed of this.config.net) { + if (hostname === allowed) { + return true; + } + // Support wildcard subdomains (e.g., *.example.com) + if (allowed.startsWith("*.")) { + const domain = allowed.slice(2); + if (hostname.endsWith(domain) || hostname === domain.slice(1)) { + return true; + } + } + } + + return false; + } + + /** + * Parse a command string into argv array + */ + private parseCommand(cmd: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote = false; + let quoteChar = ""; + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i]; + + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; + } + + /** + * Spawn a process with the given command + */ + private async spawnProcess(name: string, command: string, type: RunningProcess["type"]): Promise { + const args = this.parseCommand(command); + const env = this.buildEnv(); + + this.log(`Starting ${type} "${name}": ${command}`); + + const proc = Bun.spawn({ + cmd: args, + cwd: this.workdir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const running: RunningProcess = { name, proc, type }; + this.processes.set(name, running); + + // Handle stdout + if (proc.stdout) { + this.streamOutput(name, proc.stdout, "stdout"); + } + + // Handle stderr + if (proc.stderr) { + this.streamOutput(name, proc.stderr, "stderr"); + } + + // Handle exit + proc.exited.then(code => { + this.log(`${type} "${name}" exited with code ${code}`); + this.processes.delete(name); + this.options.onExit?.(name, code); + }); + + return running; + } + + private async streamOutput( + name: string, + stream: ReadableStream, + type: "stdout" | "stderr", + ): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + if (type === "stdout") { + this.options.onStdout?.(name, text); + } else { + this.options.onStderr?.(name, text); + } + } + } catch { + // Stream closed, ignore + } + } + + /** + * Run setup commands (RUN directives) + */ + async runSetup(): Promise { + for (const cmd of this.config.runCommands) { + if (this.aborted) return false; + + this.log(`Running setup: ${cmd}`); + const args = this.parseCommand(cmd); + + const proc = Bun.spawn({ + cmd: args, + cwd: this.workdir, + env: this.buildEnv(), + stdout: "pipe", + stderr: "pipe", + }); + + // Stream output + if (proc.stdout) { + this.streamOutput("setup", proc.stdout, "stdout"); + } + if (proc.stderr) { + this.streamOutput("setup", proc.stderr, "stderr"); + } + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + console.error(`[sandbox] Setup command failed with code ${exitCode}: ${cmd}`); + return false; + } + } + + return true; + } + + /** + * Start all services defined in the Sandboxfile + */ + async startServices(): Promise { + for (const service of this.config.services) { + if (this.aborted) return; + await this.spawnProcess(service.name, service.command, "service"); + } + } + + /** + * Start the dev server if defined + */ + async startDev(): Promise { + if (!this.config.dev) return null; + + const name = this.config.dev.name || "dev"; + return this.spawnProcess(name, this.config.dev.command, "dev"); + } + + /** + * Run test commands + */ + async runTests(): Promise<{ + passed: boolean; + results: Array<{ name: string; passed: boolean; exitCode: number | null }>; + }> { + const results: Array<{ name: string; passed: boolean; exitCode: number | null }> = []; + + for (let i = 0; i < this.config.tests.length; i++) { + if (this.aborted) break; + + const test = this.config.tests[i]; + const name = test.name || `test-${i}`; + + this.log(`Running test: ${name}`); + const args = this.parseCommand(test.command); + + const proc = Bun.spawn({ + cmd: args, + cwd: this.workdir, + env: this.buildEnv(), + stdout: "pipe", + stderr: "pipe", + }); + + // Stream output + if (proc.stdout) { + this.streamOutput(name, proc.stdout, "stdout"); + } + if (proc.stderr) { + this.streamOutput(name, proc.stderr, "stderr"); + } + + const exitCode = await proc.exited; + const passed = exitCode === 0; + + results.push({ name, passed, exitCode }); + + if (!passed) { + this.log(`Test "${name}" failed with code ${exitCode}`); + } + } + + return { + passed: results.every(r => r.passed), + results, + }; + } + + /** + * Extract output files from the sandbox + */ + async extractOutputs(destDir: string): Promise { + const extracted: string[] = []; + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + + for (const pattern of this.config.outputs) { + const glob = new Bun.Glob(pattern); + const matches = glob.scanSync({ cwd: this.workdir }); + + for (const match of matches) { + const srcPath = path.join(this.workdir, match); + const destPath = path.join(destDir, match); + + // Ensure destination directory exists + await fs.mkdir(path.dirname(destPath), { recursive: true }); + + // Copy file + await fs.copyFile(srcPath, destPath); + extracted.push(match); + this.log(`Extracted: ${match}`); + } + } + + return extracted; + } + + /** + * Get log file paths matching LOGS patterns + */ + getLogFiles(): string[] { + const logFiles: string[] = []; + + for (const pattern of this.config.logs) { + const glob = new Bun.Glob(pattern); + const matches = glob.scanSync({ cwd: this.workdir }); + + for (const match of matches) { + logFiles.push(`${this.workdir}/${match}`); + } + } + + return logFiles; + } + + /** + * Tail log files + */ + async tailLogs(callback: (file: string, line: string) => void): Promise<() => void> { + const fs = await import("node:fs"); + const watchers: ReturnType[] = []; + const filePositions = new Map(); + + for (const logFile of this.getLogFiles()) { + try { + // Get initial file size + const stats = fs.statSync(logFile); + filePositions.set(logFile, stats.size); + + // Watch for changes + const watcher = fs.watch(logFile, async eventType => { + if (eventType === "change") { + const currentPos = filePositions.get(logFile) || 0; + const file = Bun.file(logFile); + const newContent = await file.slice(currentPos).text(); + + if (newContent) { + const lines = newContent.split("\n"); + for (const line of lines) { + if (line) callback(logFile, line); + } + filePositions.set(logFile, currentPos + newContent.length); + } + } + }); + + watchers.push(watcher); + } catch { + // File doesn't exist yet, ignore + } + } + + // Return cleanup function + return () => { + for (const watcher of watchers) { + watcher.close(); + } + }; + } + + /** + * Stop all running processes + */ + async stop(): Promise { + this.aborted = true; + + for (const [name, running] of this.processes) { + this.log(`Stopping ${running.type} "${name}"`); + running.proc.kill(); + } + + // Wait for all processes to exit + const exitPromises = Array.from(this.processes.values()).map(r => r.proc.exited); + await Promise.all(exitPromises); + + this.processes.clear(); + } + + /** + * Get the status of all running processes + */ + getStatus(): Array<{ name: string; type: string; pid: number }> { + return Array.from(this.processes.values()).map(r => ({ + name: r.name, + type: r.type, + pid: r.proc.pid, + })); + } + + /** + * Check if any services are still running + */ + isRunning(): boolean { + return this.processes.size > 0; + } + + /** + * Run the full sandbox lifecycle + */ + async run(): Promise<{ + success: boolean; + testResults?: Awaited>; + }> { + try { + // Load secrets + this.loadSecrets(); + + // Run setup commands + const setupSuccess = await this.runSetup(); + if (!setupSuccess) { + return { success: false }; + } + + // Start services + await this.startServices(); + + // Start dev server + await this.startDev(); + + // Run tests if defined + if (this.config.tests.length > 0) { + // Give services time to start + await new Promise(resolve => setTimeout(resolve, 1000)); + + const testResults = await this.runTests(); + return { success: testResults.passed, testResults }; + } + + return { success: true }; + } catch (err) { + console.error("[sandbox] Error:", err); + return { success: false }; + } + } +} + +/** + * Parse a Sandboxfile from a string + */ +export function parseSandboxfile(src: string): Sandboxfile { + const result: Sandboxfile = { + runCommands: [], + services: [], + tests: [], + outputs: [], + logs: [], + net: [], + secrets: [], + }; + + const lines = src.split("\n"); + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum].trim(); + + // Skip empty lines and comments + if (line.length === 0 || line.startsWith("#")) continue; + + const spaceIdx = line.indexOf(" "); + const directive = spaceIdx >= 0 ? line.slice(0, spaceIdx) : line; + const rest = spaceIdx >= 0 ? line.slice(spaceIdx + 1).trimStart() : ""; + + switch (directive) { + case "FROM": + if (!rest) throw new Error(`Line ${lineNum + 1}: FROM requires an argument`); + if (result.from !== undefined) throw new Error(`Line ${lineNum + 1}: Duplicate FROM directive`); + result.from = rest; + break; + + case "WORKDIR": + if (!rest) throw new Error(`Line ${lineNum + 1}: WORKDIR requires a path argument`); + if (result.workdir !== undefined) throw new Error(`Line ${lineNum + 1}: Duplicate WORKDIR directive`); + result.workdir = rest; + break; + + case "RUN": + if (!rest) throw new Error(`Line ${lineNum + 1}: RUN requires a command argument`); + result.runCommands.push(rest); + break; + + case "DEV": + if (!rest) throw new Error(`Line ${lineNum + 1}: DEV requires a command argument`); + if (result.dev !== undefined) throw new Error(`Line ${lineNum + 1}: Duplicate DEV directive`); + result.dev = parseProcess(rest, false, lineNum); + break; + + case "SERVICE": { + if (!rest) throw new Error(`Line ${lineNum + 1}: SERVICE requires a name and command`); + const proc = parseProcess(rest, true, lineNum); + if (!proc.name) throw new Error(`Line ${lineNum + 1}: SERVICE requires a name`); + result.services.push({ + name: proc.name, + command: proc.command, + ...(proc.port !== undefined && { port: proc.port }), + ...(proc.watch !== undefined && { watch: proc.watch }), + }); + break; + } + + case "TEST": + if (!rest) throw new Error(`Line ${lineNum + 1}: TEST requires a command argument`); + result.tests.push(parseProcess(rest, false, lineNum)); + break; + + case "OUTPUT": + if (!rest) throw new Error(`Line ${lineNum + 1}: OUTPUT requires a path argument`); + result.outputs.push(rest); + break; + + case "LOGS": + if (!rest) throw new Error(`Line ${lineNum + 1}: LOGS requires a path pattern argument`); + result.logs.push(rest); + break; + + case "NET": + if (!rest) throw new Error(`Line ${lineNum + 1}: NET requires a hostname argument`); + result.net.push(rest); + break; + + case "SECRET": + if (!rest) throw new Error(`Line ${lineNum + 1}: SECRET requires an environment variable name`); + if (!/^[A-Za-z0-9_]+$/.test(rest)) { + throw new Error(`Line ${lineNum + 1}: SECRET name must be a valid environment variable name`); + } + result.secrets.push(rest); + break; + + case "INFER": + if (!rest) throw new Error(`Line ${lineNum + 1}: INFER requires a pattern argument`); + if (result.infer !== undefined) throw new Error(`Line ${lineNum + 1}: Duplicate INFER directive`); + result.infer = rest; + break; + + default: + throw new Error(`Line ${lineNum + 1}: Unknown directive: ${directive}`); + } + } + + return result; +} + +function parseProcess(input: string, requireName: boolean, lineNum: number): SandboxProcess { + const result: SandboxProcess = { command: "" }; + let rest = input; + let hasName = false; + + while (rest.length > 0) { + const spaceIdx = rest.search(/[ \t]/); + const token = spaceIdx >= 0 ? rest.slice(0, spaceIdx) : rest; + + if (token.startsWith("PORT=")) { + const port = parseInt(token.slice(5), 10); + if (isNaN(port)) throw new Error(`Line ${lineNum + 1}: Invalid PORT value: ${token.slice(5)}`); + result.port = port; + } else if (token.startsWith("WATCH=")) { + result.watch = token.slice(6); + } else if (!hasName && !requireName) { + // For DEV/TEST, first non-option token starts the command + result.command = rest; + break; + } else if (!hasName) { + // First non-option token is the name + result.name = token; + hasName = true; + } else { + // Rest is the command + result.command = rest; + break; + } + + if (spaceIdx < 0) { + rest = ""; + } else { + rest = rest.slice(spaceIdx + 1).trimStart(); + } + } + + if (!result.command) { + throw new Error(`Line ${lineNum + 1}: Missing command in process definition`); + } + + return result; +} + +/** + * Parse a Sandboxfile from a file path + */ +export async function parseSandboxfileFromPath(path: string): Promise { + const file = Bun.file(path); + const content = await file.text(); + return parseSandboxfile(content); +} + +/** + * Create and run a sandbox from a Sandboxfile path + */ +export async function runSandbox(sandboxfilePath: string, options: SandboxOptions = {}): Promise { + const config = await parseSandboxfileFromPath(sandboxfilePath); + const sandbox = new Sandbox(config, options); + return sandbox; +} + +/** + * Infer a Sandboxfile from the current project + */ +export async function inferSandboxfile(cwd: string = process.cwd()): Promise { + const result: Sandboxfile = { + from: "host", + workdir: ".", + runCommands: [], + services: [], + tests: [], + outputs: [], + logs: [], + net: [], + secrets: [], + }; + + // Check for package.json + const packageJsonPath = `${cwd}/package.json`; + const packageJsonFile = Bun.file(packageJsonPath); + + if (await packageJsonFile.exists()) { + const packageJson = await packageJsonFile.json(); + + // Add install command + if (packageJson.dependencies || packageJson.devDependencies) { + result.runCommands.push("bun install"); + } + + // Check for common scripts + if (packageJson.scripts) { + if (packageJson.scripts.dev) { + result.dev = { command: "bun run dev" }; + } + if (packageJson.scripts.start && !packageJson.scripts.dev) { + result.dev = { command: "bun run start" }; + } + if (packageJson.scripts.test) { + result.tests.push({ command: "bun run test" }); + } + if (packageJson.scripts.build) { + result.runCommands.push("bun run build"); + } + } + + // Output package.json and common source directories + result.outputs.push("package.json"); + + const srcDir = Bun.file(`${cwd}/src`); + if (await srcDir.exists()) { + result.outputs.push("src/"); + } + + const libDir = Bun.file(`${cwd}/lib`); + if (await libDir.exists()) { + result.outputs.push("lib/"); + } + } + + // Check for bun.lockb + if (await Bun.file(`${cwd}/bun.lockb`).exists()) { + result.outputs.push("bun.lockb"); + } + + // Check for common log locations + const logsDir = Bun.file(`${cwd}/logs`); + if (await logsDir.exists()) { + result.logs.push("logs/*"); + } + + // Check for .env file to infer secrets + const envPath = `${cwd}/.env`; + if (await Bun.file(envPath).exists()) { + const envContent = await Bun.file(envPath).text(); + const secretPattern = /^([A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_API_KEY))=/gm; + let match; + while ((match = secretPattern.exec(envContent)) !== null) { + result.secrets.push(match[1]); + } + } + + return result; +} + +// Default export +export default { + Sandbox, + parseSandboxfile, + parseSandboxfileFromPath, + runSandbox, + inferSandboxfile, +}; diff --git a/test/js/bun/sandbox/sandbox-runtime.test.ts b/test/js/bun/sandbox/sandbox-runtime.test.ts new file mode 100644 index 0000000000..4f9256e113 --- /dev/null +++ b/test/js/bun/sandbox/sandbox-runtime.test.ts @@ -0,0 +1,356 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { bunExe, tempDir } from "harness"; + +// Import sandbox runtime from the bun-sandbox package +const sandboxModule = await import("../../../../packages/bun-sandbox/src/index"); +const { Sandbox, parseSandboxfile, inferSandboxfile } = sandboxModule; + +describe("Sandbox Runtime", () => { + let cleanup: (() => Promise) | null = null; + + afterEach(async () => { + if (cleanup) { + await cleanup(); + cleanup = null; + } + }); + + test("runs simple command", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +RUN echo "hello world" +`); + + let stdout = ""; + const sandbox = new Sandbox(config, { + onStdout: (_service, data) => { + stdout += data; + }, + }); + + cleanup = () => sandbox.stop(); + + const success = await sandbox.runSetup(); + expect(success).toBe(true); + expect(stdout.trim()).toBe("hello world"); + }); + + test("runs multiple RUN commands in sequence", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +RUN echo "first" +RUN echo "second" +RUN echo "third" +`); + + const outputs: string[] = []; + const sandbox = new Sandbox(config, { + onStdout: (_service, data) => { + outputs.push(data.trim()); + }, + }); + + cleanup = () => sandbox.stop(); + + const success = await sandbox.runSetup(); + expect(success).toBe(true); + expect(outputs).toEqual(["first", "second", "third"]); + }); + + test("fails on bad RUN command", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +RUN sh -c "exit 1" +`); + + const sandbox = new Sandbox(config, {}); + cleanup = () => sandbox.stop(); + + const success = await sandbox.runSetup(); + expect(success).toBe(false); + }); + + test("runs TEST commands", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +TEST echo "test passed" +`); + + let stdout = ""; + const sandbox = new Sandbox(config, { + onStdout: (_service, data) => { + stdout += data; + }, + }); + + cleanup = () => sandbox.stop(); + + const results = await sandbox.runTests(); + expect(results.passed).toBe(true); + expect(results.results).toHaveLength(1); + expect(results.results[0].passed).toBe(true); + expect(stdout.trim()).toBe("test passed"); + }); + + test("reports failed TEST", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +TEST sh -c "exit 0" +TEST sh -c "exit 1" +TEST sh -c "exit 0" +`); + + const sandbox = new Sandbox(config, {}); + cleanup = () => sandbox.stop(); + + const results = await sandbox.runTests(); + expect(results.passed).toBe(false); + expect(results.results).toHaveLength(3); + expect(results.results[0].passed).toBe(true); + expect(results.results[1].passed).toBe(false); + expect(results.results[2].passed).toBe(true); + }); + + test("starts and stops SERVICE", async () => { + using dir = tempDir("sandbox-test", { + "server.js": ` + const server = Bun.serve({ + port: 0, + fetch(req) { + return new Response("hello from service"); + }, + }); + console.log("SERVER_PORT=" + server.port); + `, + }); + + const config = parseSandboxfile(` +FROM host +WORKDIR ${dir} +SERVICE api ${bunExe()} server.js +`); + + let port: number | null = null; + const sandbox = new Sandbox(config, { + onStdout: (_service, data) => { + const match = data.match(/SERVER_PORT=(\d+)/); + if (match) { + port = parseInt(match[1], 10); + } + }, + }); + + cleanup = () => sandbox.stop(); + + await sandbox.startServices(); + + // Wait for service to start + await new Promise(r => setTimeout(r, 500)); + + expect(sandbox.isRunning()).toBe(true); + expect(sandbox.getStatus()).toHaveLength(1); + expect(sandbox.getStatus()[0].name).toBe("api"); + + // Test the service is responding + if (port) { + const response = await fetch(`http://localhost:${port}`); + const text = await response.text(); + expect(text).toBe("hello from service"); + } + + await sandbox.stop(); + expect(sandbox.isRunning()).toBe(false); + }); + + test("loads secrets from environment", async () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +SECRET TEST_SECRET +RUN sh -c "echo $TEST_SECRET" +`); + + let stdout = ""; + const sandbox = new Sandbox(config, { + env: { TEST_SECRET: "secret_value_123" }, + onStdout: (_service, data) => { + stdout += data; + }, + }); + + cleanup = () => sandbox.stop(); + + sandbox.loadSecrets(); + const success = await sandbox.runSetup(); + expect(success).toBe(true); + expect(stdout.trim()).toBe("secret_value_123"); + }); + + test("validates network access", () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +NET api.example.com +NET *.stripe.com +`); + + const sandbox = new Sandbox(config, {}); + + expect(sandbox.isNetworkAllowed("api.example.com")).toBe(true); + expect(sandbox.isNetworkAllowed("other.example.com")).toBe(false); + expect(sandbox.isNetworkAllowed("api.stripe.com")).toBe(true); + expect(sandbox.isNetworkAllowed("payments.stripe.com")).toBe(true); + expect(sandbox.isNetworkAllowed("evil.com")).toBe(false); + }); + + test("denies all network by default", () => { + const config = parseSandboxfile(` +FROM host +WORKDIR . +`); + + const sandbox = new Sandbox(config, {}); + + expect(sandbox.isNetworkAllowed("any.host.com")).toBe(false); + }); + + test("extracts output files", async () => { + using srcDir = tempDir("sandbox-src", { + "file1.txt": "content1", + "file2.txt": "content2", + "subdir/file3.txt": "content3", + }); + + using destDir = tempDir("sandbox-dest", {}); + + const config = parseSandboxfile(` +FROM host +WORKDIR ${srcDir} +OUTPUT *.txt +OUTPUT subdir/* +`); + + const sandbox = new Sandbox(config, {}); + cleanup = () => sandbox.stop(); + + const extracted = await sandbox.extractOutputs(String(destDir)); + + expect(extracted).toContain("file1.txt"); + expect(extracted).toContain("file2.txt"); + + // Verify files were copied + const file1 = Bun.file(`${destDir}/file1.txt`); + expect(await file1.text()).toBe("content1"); + }); + + test("runs workdir in temp directory", async () => { + using dir = tempDir("sandbox-workdir", { + "test.sh": "pwd", + }); + + const config = parseSandboxfile(` +FROM host +WORKDIR ${dir} +RUN pwd +`); + + let stdout = ""; + const sandbox = new Sandbox(config, { + onStdout: (_service, data) => { + stdout += data; + }, + }); + + cleanup = () => sandbox.stop(); + + await sandbox.runSetup(); + expect(stdout.trim()).toBe(String(dir)); + }); +}); + +describe("Sandbox Inference", () => { + test("infers from package.json with scripts", async () => { + using dir = tempDir("sandbox-infer", { + "package.json": JSON.stringify({ + name: "test-project", + scripts: { + dev: "bun run server.js", + test: "bun test", + build: "bun build ./src/index.ts", + }, + dependencies: { + "some-dep": "1.0.0", + }, + }), + }); + + const config = await inferSandboxfile(String(dir)); + + expect(config.from).toBe("host"); + expect(config.workdir).toBe("."); + expect(config.runCommands).toContain("bun install"); + expect(config.dev?.command).toBe("bun run dev"); + expect(config.tests.some(t => t.command === "bun run test")).toBe(true); + expect(config.outputs).toContain("package.json"); + }); + + test("infers secrets from .env file", async () => { + using dir = tempDir("sandbox-infer-secrets", { + "package.json": JSON.stringify({ name: "test" }), + ".env": ` +DATABASE_URL=postgres://localhost:5432/db +STRIPE_API_KEY=sk_test_123 +AUTH_SECRET=some_secret +NORMAL_VAR=not_a_secret +AWS_SECRET_KEY=aws_key +`, + }); + + const config = await inferSandboxfile(String(dir)); + + expect(config.secrets).toContain("STRIPE_API_KEY"); + expect(config.secrets).toContain("AUTH_SECRET"); + expect(config.secrets).toContain("AWS_SECRET_KEY"); + // NORMAL_VAR and DATABASE_URL don't match the pattern + expect(config.secrets).not.toContain("NORMAL_VAR"); + }); +}); + +describe("Sandbox Full Lifecycle", () => { + test("runs complete sandbox lifecycle", async () => { + using dir = tempDir("sandbox-lifecycle", { + "setup.sh": "echo 'setup complete' > setup.log", + "test.sh": "cat setup.log", + }); + + const config = parseSandboxfile(` +FROM host +WORKDIR ${dir} +RUN sh setup.sh +TEST sh test.sh +OUTPUT setup.log +`); + + let testOutput = ""; + const sandbox = new Sandbox(config, { + onStdout: (service, data) => { + if (service.startsWith("test")) { + testOutput += data; + } + }, + }); + + const result = await sandbox.run(); + + expect(result.success).toBe(true); + expect(result.testResults?.passed).toBe(true); + expect(testOutput.trim()).toBe("setup complete"); + + await sandbox.stop(); + }); +});