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();
+ });
+});