Compare commits

..

12 Commits

Author SHA1 Message Date
Claude Bot
c31f2ef407 fix: track duped PTY master FDs for error cleanup
Add duped master FDs to to_close_on_error list so they are properly
cleaned up if spawn fails after duping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:48:18 +00:00
autofix-ci[bot]
064b1d36d1 [autofix.ci] apply automated fixes 2025-12-03 11:37:01 +00:00
Claude Bot
e67ed9de05 fix: clarify Writable.zig PTY comment for Windows
Update comment to clearly state PTY stdin is not supported on Windows
rather than misleadingly mentioning "falls back to pipe".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:35:11 +00:00
Claude Bot
021dac5748 fix: address CodeRabbit PTY lifecycle and docs review comments
- Fix PTY master FD leak: close original master FD after spawn succeeds
  since parent uses dup()'d copies for stdio/extra_fds
- Fix PosixSpawnResult.close: correct type signature and add PTY handling
- Harmonize shell PTY behavior: change POSIX stdin panic to return ignore
  (consistent with Windows behavior)
- Update docs: add "Not supported with spawnSync" to PTY table entries
- Update docs: expand TS type comments with platform/spawnSync notes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:34:37 +00:00
Claude Bot
bc239627a1 fix: handle PTY case in Writable.zig Windows switch
Add .pty case to the Windows switch statement in Writable.zig.
On Windows, PTY falls back to pipe behavior, so stdin with PTY
returns ignore (same as other unsupported stdin types).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:21:56 +00:00
Claude Bot
0245de5c78 fix: address CodeRabbit review comments for PTY support
- Fix docs example consistency: use array form consistently
- Add spawnSync warning to TypeScript PTY type definitions
- Fix Windows compile error in js_bun_spawn_bindings.zig
- Fix extra_fds PTY to use dup() for consistency
- Add isNumber() type check for PTY width/height options
- Fix error message consistency in stdio.zig
- Fix switch case overlap in shell/subproc.zig (remove .pipe/.readable_stream that were already handled)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:11:03 +00:00
autofix-ci[bot]
6d4965c79b [autofix.ci] apply automated fixes 2025-12-03 10:48:11 +00:00
Claude Bot
fff68a0fb1 docs: update PTY docs to say it falls back to pipe on Windows
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:46:30 +00:00
Claude Bot
74bea006fd feat(spawn): finalize PTY support with docs and tests
- Add comprehensive PTY documentation with examples:
  - Basic usage
  - Multiple PTY streams (stdin/stdout/stderr)
  - Custom terminal dimensions (width/height)
  - Colored output from git, grep, etc.
  - Platform support table
  - Limitations section

- Make PTY fall back to pipe on Windows (instead of error)
- Add spawnSync error for PTY (not supported)
- Add tests for spawnSync PTY error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:44:04 +00:00
Claude Bot
5d775066b2 test(spawn): expand PTY test coverage
Add tests for:
- stderr: 'pty' only
- stdin: 'pty' only (stdout/stderr not PTY)
- PTY object syntax with custom width/height dimensions
- ANSI color output detection
- Multiple concurrent PTY spawns
- Windows error handling (skipped on non-Windows)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:27:26 +00:00
Claude Bot
f773816d02 cleanup: remove unused PTY code, add types and docs
- Remove unused uws import from PipeReader.zig
- Remove unused PTY helper functions (setWinSize, getWinSize, setControllingTerminal)
- Remove unused ioctl constants (TIOCSWINSZ, TIOCSCTTY, TIOCGWINSZ)
- Change spawn_bindings log visibility back to hidden
- Add "pty" to TypeScript types in bun.d.ts
- Add PTY documentation to docs/runtime/child-process.mdx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:24:36 +00:00
Claude Bot
ee4ace7159 feat(spawn): add PTY support for stdin, stdout, stderr
Add support for `stdin: "pty"`, `stdout: "pty"`, and `stderr: "pty"` options
in `Bun.spawn()`. This allows spawned processes to see `process.stdout.isTTY === true`.

Key implementation details:
- PTY creation via openpty/forkpty syscalls in sys.zig
- When multiple stdios use PTY, they share the same master FD
- Handle epoll EEXIST gracefully when FD is already registered
- Timer-based polling fallback for shared PTY FDs
- EIO treated as normal EOF when PTY slave closes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:08:06 +00:00
26 changed files with 655 additions and 5678 deletions

View File

@@ -39,18 +39,19 @@ const text = await proc.stdout.text();
console.log(text); // "const input = "hello world".repeat(400); ..."
```
| Value | Description |
| ------------------------ | ------------------------------------------------ |
| `null` | **Default.** Provide no input to the subprocess |
| `"pipe"` | Return a `FileSink` for fast incremental writing |
| `"inherit"` | Inherit the `stdin` of the parent process |
| `Bun.file()` | Read from the specified file |
| `TypedArray \| DataView` | Use a binary buffer as input |
| `Response` | Use the response `body` as input |
| `Request` | Use the request `body` as input |
| `ReadableStream` | Use a readable stream as input |
| `Blob` | Use a blob as input |
| `number` | Read from the file with a given file descriptor |
| Value | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `null` | **Default.** Provide no input to the subprocess |
| `"pipe"` | Return a `FileSink` for fast incremental writing |
| `"inherit"` | Inherit the `stdin` of the parent process |
| `"pty"` | Use a pseudo-terminal (PTY). Child sees `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`. |
| `Bun.file()` | Read from the specified file |
| `TypedArray \| DataView` | Use a binary buffer as input |
| `Response` | Use the response `body` as input |
| `Request` | Use the request `body` as input |
| `ReadableStream` | Use a readable stream as input |
| `Blob` | Use a blob as input |
| `number` | Read from the file with a given file descriptor |
The `"pipe"` option lets incrementally write to the subprocess's input stream from the parent process.
@@ -105,13 +106,121 @@ console.log(text); // => "1.3.3\n"
Configure the output stream by passing one of the following values to `stdout/stderr`:
| Value | Description |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `"pipe"` | **Default for `stdout`.** Pipe the output to a `ReadableStream` on the returned `Subprocess` object |
| `"inherit"` | **Default for `stderr`.** Inherit from the parent process |
| `"ignore"` | Discard the output |
| `Bun.file()` | Write to the specified file |
| `number` | Write to the file with the given file descriptor |
| Value | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `"pipe"` | **Default for `stdout`.** Pipe the output to a `ReadableStream` on the returned `Subprocess` object |
| `"inherit"` | **Default for `stderr`.** Inherit from the parent process |
| `"ignore"` | Discard the output |
| `"pty"` | Use a pseudo-terminal (PTY). Child sees `process.stdout.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`. |
| `Bun.file()` | Write to the specified file |
| `number` | Write to the file with the given file descriptor |
## Pseudo-terminal (PTY)
Use `"pty"` to spawn a process with a pseudo-terminal, making the child process believe it's running in an interactive terminal. This causes `process.stdout.isTTY` to be `true` in the child, which is useful for:
- Getting colored output from CLI tools that detect TTY
- Running interactive programs that require a terminal
- Testing terminal-dependent behavior
### Basic usage
```ts
const proc = Bun.spawn(["ls", "--color=auto"], {
stdout: "pty",
});
// The child process sees process.stdout.isTTY === true
const output = await proc.stdout.text();
console.log(output); // includes ANSI color codes
```
### Checking isTTY in child process
```ts
const proc = Bun.spawn(["bun", "-e", "console.log('isTTY:', process.stdout.isTTY)"], {
stdout: "pty",
});
const output = await proc.stdout.text();
console.log(output); // "isTTY: true"
```
### Multiple PTY streams
You can use PTY for stdin, stdout, and/or stderr. When multiple streams use PTY, they share the same underlying pseudo-terminal:
```ts
const code = `
console.log("stdout.isTTY:", process.stdout.isTTY);
console.log("stdin.isTTY:", process.stdin.isTTY);
console.log("stderr.isTTY:", process.stderr.isTTY);
`;
const proc = Bun.spawn(["bun", "-e", code], {
stdin: "pty",
stdout: "pty",
stderr: "pty",
});
const output = await proc.stdout.text();
// stdout.isTTY: true
// stdin.isTTY: true
// stderr.isTTY: true
```
### Custom terminal dimensions
Specify terminal width and height using the object syntax:
```ts
const code = `
console.log("columns:", process.stdout.columns);
console.log("rows:", process.stdout.rows);
`;
const proc = Bun.spawn(["bun", "-e", code], {
stdout: {
type: "pty",
width: 120, // columns
height: 40, // rows
},
});
const output = await proc.stdout.text();
// columns: 120
// rows: 40
```
### Getting colored output from git, grep, etc.
Many CLI tools detect whether they're running in a TTY and only emit colors when they are:
```ts
// Without PTY - no colors
const noColor = Bun.spawn(["git", "status"], { stdout: "pipe" });
console.log(await noColor.stdout.text()); // plain text
// With PTY - colors enabled
const withColor = Bun.spawn(["git", "status"], { stdout: "pty" });
console.log(await withColor.stdout.text()); // includes ANSI color codes
```
### Platform support
| Platform | PTY Support |
| -------- | -------------------------------------------- |
| macOS | ✅ Full support |
| Linux | ✅ Full support |
| Windows | ⚠️ Falls back to `"pipe"` (no TTY semantics) |
On Windows, `"pty"` silently falls back to `"pipe"` behavior. The child process will see `process.stdout.isTTY` as `undefined`. This allows cross-platform code to work without errors, though TTY-dependent features won't work on Windows.
### Limitations
- **Not supported with `spawnSync`**: PTY requires asynchronous I/O. Using `"pty"` with `Bun.spawnSync()` will throw an error.
- **Line endings**: PTY converts `\n` to `\r\n` on output (standard terminal behavior).
- **No dynamic resize**: Terminal dimensions are set at spawn time and cannot be changed after.
## Exit handling
@@ -413,6 +522,7 @@ namespace SpawnOptions {
| "pipe"
| "inherit"
| "ignore"
| "pty" // use a pseudo-terminal (macOS/Linux only, falls back to "pipe" on Windows, not supported with spawnSync)
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -423,6 +533,7 @@ namespace SpawnOptions {
| "pipe"
| "inherit"
| "ignore"
| "pty" // use a pseudo-terminal (macOS/Linux only, falls back to "pipe" on Windows, not supported with spawnSync)
| null // equivalent to "ignore"
| undefined // to use default
| BunFile

View File

@@ -1,23 +0,0 @@
{
"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"
}
}

View File

@@ -1,517 +0,0 @@
#!/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 <dir> Output directory for extracted files
-e, --env <KEY=VAL> 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<string, string>;
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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);

View File

@@ -1,787 +0,0 @@
/**
* 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<string, string>;
/** 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<typeof Bun.spawn>;
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<string, RunningProcess> = new Map();
private workdir: string;
private secretValues: Map<string, string> = 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<string, string> {
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...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<RunningProcess> {
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<Uint8Array>,
type: "stdout" | "stderr",
): Promise<void> {
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<boolean> {
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<void> {
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<RunningProcess | null> {
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<string[]> {
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<typeof fs.watch>[] = [];
const filePositions = new Map<string, number>();
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<void> {
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<ReturnType<Sandbox["runTests"]>>;
}> {
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<Sandboxfile> {
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<Sandbox> {
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<Sandboxfile> {
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,
};

View File

@@ -1,663 +0,0 @@
/**
* Isolated Sandbox Runtime
*
* Provides true process isolation using Linux namespaces:
* - User namespace for unprivileged operation
* - Mount namespace with overlayfs for ephemeral filesystem
* - Network namespace with firewall rules
* - PID namespace for process isolation
* - UTS namespace for hostname isolation
*
* Requirements:
* - Linux kernel with user namespace support
* - bubblewrap (bwrap) or fuse-overlayfs for unprivileged overlay
*/
import type { Sandboxfile, SandboxOptions } from "./index";
export interface IsolatedSandboxOptions extends SandboxOptions {
/** Use real Linux namespace isolation (requires bwrap or root) */
isolated?: boolean;
/** Root filesystem to use as base (default: /) */
rootfs?: string;
/** Extract outputs to this directory after sandbox exits */
extractDir?: string;
}
interface OverlayDirs {
baseDir: string;
upperDir: string;
workDir: string;
mergedDir: string;
}
/**
* Check available isolation methods
*/
export async function checkIsolationSupport(): Promise<{
bwrap: boolean;
unshare: boolean;
fuseOverlayfs: boolean;
userNamespaces: boolean;
}> {
const check = async (cmd: string[]): Promise<boolean> => {
try {
const proc = Bun.spawn({ cmd, stdout: "ignore", stderr: "ignore" });
return (await proc.exited) === 0;
} catch {
return false;
}
};
const [bwrap, unshare, fuseOverlayfs] = await Promise.all([
check(["which", "bwrap"]),
check(["which", "unshare"]),
check(["which", "fuse-overlayfs"]),
]);
// Check user namespace support
let userNamespaces = false;
try {
const file = Bun.file("/proc/sys/kernel/unprivileged_userns_clone");
if (await file.exists()) {
const content = await file.text();
userNamespaces = content.trim() === "1";
} else {
// If sysctl doesn't exist, try to check /proc/self/uid_map writability
// or just assume it's available on modern kernels
userNamespaces = true;
}
} catch {
userNamespaces = false;
}
return { bwrap, unshare, fuseOverlayfs, userNamespaces };
}
/**
* Create overlay filesystem directories
*/
async function createOverlayDirs(prefix: string): Promise<OverlayDirs & { cleanup: () => Promise<void> }> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const crypto = await import("node:crypto");
const sandboxId = crypto.randomBytes(8).toString("hex");
const baseDir = `/tmp/bun-sandbox-${prefix}-${sandboxId}`;
const upperDir = path.join(baseDir, "upper");
const workDir = path.join(baseDir, "work");
const mergedDir = path.join(baseDir, "merged");
await fs.mkdir(upperDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.mkdir(mergedDir, { recursive: true });
const cleanup = async () => {
// Try to unmount first
try {
const umount = Bun.spawn({
cmd: ["fusermount", "-u", mergedDir],
stdout: "ignore",
stderr: "ignore",
});
await umount.exited;
} catch {}
try {
const umount = Bun.spawn({
cmd: ["umount", "-l", mergedDir],
stdout: "ignore",
stderr: "ignore",
});
await umount.exited;
} catch {}
// Remove directories
try {
await fs.rm(baseDir, { recursive: true, force: true });
} catch {}
};
return { baseDir, upperDir, workDir, mergedDir, cleanup };
}
/**
* Get modified files from overlay upper directory
*/
async function getModifiedFiles(upperDir: string): Promise<string[]> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const files: string[] = [];
async function walk(dir: string, prefix: string = ""): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.join(prefix, entry.name);
if (entry.isDirectory()) {
await walk(fullPath, relativePath);
} else if (entry.isFile()) {
files.push(relativePath);
}
}
} catch {
// Ignore errors
}
}
await walk(upperDir);
return files;
}
/**
* Copy modified files from overlay to destination
*/
async function extractModifiedFiles(upperDir: string, destDir: string, patterns: string[]): Promise<string[]> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const extracted: string[] = [];
const modifiedFiles = await getModifiedFiles(upperDir);
for (const file of modifiedFiles) {
// Check if file matches any output pattern
let matches = false;
for (const pattern of patterns) {
const glob = new Bun.Glob(pattern);
if (glob.match(file)) {
matches = true;
break;
}
}
if (matches) {
const srcPath = path.join(upperDir, file);
const destPath = path.join(destDir, file);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(srcPath, destPath);
extracted.push(file);
}
}
return extracted;
}
/**
* Build bwrap command arguments for isolation
*/
function buildBwrapArgs(
config: Sandboxfile,
rootfs: string,
workdir: string,
overlayDirs: OverlayDirs | null,
): string[] {
const args: string[] = ["bwrap"];
// User namespace with UID/GID 1000
args.push("--unshare-user", "--uid", "1000", "--gid", "1000");
// Mount namespace
args.push("--unshare-pid", "--unshare-uts", "--unshare-ipc");
// Hostname
args.push("--hostname", "sandbox");
// Network isolation if no NET hosts specified
if (config.net.length === 0) {
args.push("--unshare-net");
}
// Root filesystem setup
if (overlayDirs) {
// Use fuse-overlayfs for unprivileged overlay
// For now, just bind the rootfs and hope writes go somewhere useful
args.push("--ro-bind", rootfs, "/");
args.push("--bind", overlayDirs.upperDir, workdir);
} else {
// Simple bind mount
args.push("--ro-bind", rootfs, "/");
}
// Essential mounts
args.push("--proc", "/proc");
args.push("--dev", "/dev");
args.push("--tmpfs", "/tmp");
args.push("--tmpfs", "/run");
// Writable home directory
args.push("--tmpfs", "/home");
args.push("--tmpfs", "/root");
// Working directory - make it writable
args.push("--bind", workdir, workdir);
args.push("--chdir", workdir);
// Die with parent process
args.push("--die-with-parent");
// Clear environment except what we set
args.push("--clearenv");
return args;
}
/**
* Build unshare command arguments for isolation
*/
function buildUnshareArgs(config: Sandboxfile): string[] {
const args: string[] = ["unshare", "--user", "--map-root-user", "--mount", "--pid", "--fork", "--uts", "--ipc"];
// Network isolation if no NET hosts specified
if (config.net.length === 0) {
args.push("--net");
}
return args;
}
export interface IsolatedSandboxResult {
success: boolean;
exitCode: number;
stdout: string;
stderr: string;
modifiedFiles: string[];
extractedFiles: string[];
}
/**
* Run a command in an isolated sandbox using bwrap
*/
export async function runIsolatedBwrap(
command: string[],
config: Sandboxfile,
options: IsolatedSandboxOptions = {},
): Promise<IsolatedSandboxResult> {
const rootfs = options.rootfs || "/";
const workdir = options.cwd || config.workdir || process.cwd();
// Create overlay directories for capturing writes
const overlay = await createOverlayDirs("bwrap");
try {
// Build bwrap arguments
const bwrapArgs = buildBwrapArgs(config, rootfs, workdir, overlay);
// Add environment variables
const env: Record<string, string> = {
HOME: "/home/sandbox",
USER: "sandbox",
PATH: "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin",
TERM: "xterm-256color",
LANG: "C.UTF-8",
...options.env,
};
// Add secrets
for (const secret of config.secrets) {
const value = process.env[secret];
if (value) {
env[secret] = value;
}
}
// Add env vars to bwrap args
for (const [key, value] of Object.entries(env)) {
bwrapArgs.push("--setenv", key, value);
}
// Add the command
bwrapArgs.push(...command);
if (options.verbose) {
console.log("[sandbox] Running:", bwrapArgs.join(" "));
}
// Run the sandboxed command
const proc = Bun.spawn({
cmd: bwrapArgs,
stdout: "pipe",
stderr: "pipe",
});
// Stream output if callbacks provided
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
if (proc.stdout) {
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
stdoutChunks.push(text);
options.onStdout?.("sandbox", text);
}
} catch {}
})();
}
if (proc.stderr) {
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
stderrChunks.push(text);
options.onStderr?.("sandbox", text);
}
} catch {}
})();
}
const exitCode = await proc.exited;
// Get modified files
const modifiedFiles = await getModifiedFiles(overlay.upperDir);
// Extract outputs if requested
let extractedFiles: string[] = [];
if (options.extractDir && config.outputs.length > 0) {
extractedFiles = await extractModifiedFiles(overlay.upperDir, options.extractDir, config.outputs);
}
return {
success: exitCode === 0,
exitCode: exitCode ?? 1,
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
modifiedFiles,
extractedFiles,
};
} finally {
await overlay.cleanup();
}
}
/**
* Run a command in an isolated sandbox using unshare
*/
export async function runIsolatedUnshare(
command: string[],
config: Sandboxfile,
options: IsolatedSandboxOptions = {},
): Promise<IsolatedSandboxResult> {
const workdir = options.cwd || config.workdir || process.cwd();
// Create overlay directories
const overlay = await createOverlayDirs("unshare");
try {
// Build unshare arguments
const unshareArgs = buildUnshareArgs(config);
// Build environment
const env: Record<string, string> = {
HOME: workdir,
USER: "root",
PATH: "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin",
TERM: "xterm-256color",
LANG: "C.UTF-8",
...options.env,
};
// Add secrets
for (const secret of config.secrets) {
const value = process.env[secret];
if (value) {
env[secret] = value;
}
}
if (options.verbose) {
console.log("[sandbox] Running:", [...unshareArgs, ...command].join(" "));
}
// Run the sandboxed command
const proc = Bun.spawn({
cmd: [...unshareArgs, ...command],
cwd: workdir,
env,
stdout: "pipe",
stderr: "pipe",
});
// Collect output
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
if (proc.stdout) {
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
stdoutChunks.push(text);
options.onStdout?.("sandbox", text);
}
} catch {}
})();
}
if (proc.stderr) {
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
stderrChunks.push(text);
options.onStderr?.("sandbox", text);
}
} catch {}
})();
}
const exitCode = await proc.exited;
return {
success: exitCode === 0,
exitCode: exitCode ?? 1,
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
modifiedFiles: [],
extractedFiles: [],
};
} finally {
await overlay.cleanup();
}
}
/**
* Run a command in the best available isolated sandbox
*/
export async function runIsolated(
command: string[],
config: Sandboxfile,
options: IsolatedSandboxOptions = {},
): Promise<IsolatedSandboxResult> {
const support = await checkIsolationSupport();
if (options.verbose) {
console.log("[sandbox] Isolation support:", support);
}
// Try bwrap first (best unprivileged option)
if (support.bwrap) {
try {
return await runIsolatedBwrap(command, config, options);
} catch (e) {
if (options.verbose) {
console.warn("[sandbox] bwrap failed:", e);
}
}
}
// Try unshare
if (support.unshare && support.userNamespaces) {
try {
return await runIsolatedUnshare(command, config, options);
} catch (e) {
if (options.verbose) {
console.warn("[sandbox] unshare failed:", e);
}
}
}
// Fallback: no isolation
console.warn("[sandbox] WARNING: Running without isolation. Install bubblewrap: apt install bubblewrap");
const proc = Bun.spawn({
cmd: command,
cwd: options.cwd || config.workdir || process.cwd(),
env: {
...process.env,
...options.env,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return {
success: exitCode === 0,
exitCode: exitCode ?? 1,
stdout,
stderr,
modifiedFiles: [],
extractedFiles: [],
};
}
/**
* IsolatedSandbox class - full sandbox lifecycle with isolation
*/
export class IsolatedSandbox {
private config: Sandboxfile;
private options: IsolatedSandboxOptions;
private secretValues: Map<string, string> = new Map();
constructor(config: Sandboxfile, options: IsolatedSandboxOptions = {}) {
this.config = config;
this.options = { isolated: true, ...options };
}
/**
* Load secrets from environment (they won't be visible in /proc inside sandbox)
*/
loadSecrets(): void {
for (const secret of this.config.secrets) {
const value = process.env[secret];
if (value) {
this.secretValues.set(secret, value);
} else {
console.warn(`[sandbox] Secret not found: ${secret}`);
}
}
}
/**
* Run setup commands (RUN directives) in isolated environment
*/
async runSetup(): Promise<boolean> {
for (const cmd of this.config.runCommands) {
const result = await runIsolated(["sh", "-c", cmd], this.config, {
...this.options,
env: {
...this.options.env,
...Object.fromEntries(this.secretValues),
},
});
if (!result.success) {
console.error(`[sandbox] Setup failed: ${cmd}`);
console.error(result.stderr);
return false;
}
}
return true;
}
/**
* Run test commands in isolated environment
*/
async runTests(): Promise<{
passed: boolean;
results: Array<{ name: string; passed: boolean; exitCode: number }>;
}> {
const results: Array<{ name: string; passed: boolean; exitCode: number }> = [];
for (let i = 0; i < this.config.tests.length; i++) {
const test = this.config.tests[i];
const name = test.name || `test-${i}`;
const result = await runIsolated(["sh", "-c", test.command], this.config, {
...this.options,
env: {
...this.options.env,
...Object.fromEntries(this.secretValues),
},
});
results.push({
name,
passed: result.success,
exitCode: result.exitCode,
});
}
return {
passed: results.every(r => r.passed),
results,
};
}
/**
* Run full sandbox lifecycle
*/
async run(): Promise<{
success: boolean;
testResults?: Awaited<ReturnType<IsolatedSandbox["runTests"]>>;
}> {
this.loadSecrets();
const setupSuccess = await this.runSetup();
if (!setupSuccess) {
return { success: false };
}
if (this.config.tests.length > 0) {
const testResults = await this.runTests();
return { success: testResults.passed, testResults };
}
return { success: true };
}
}
export default {
IsolatedSandbox,
runIsolated,
runIsolatedBwrap,
runIsolatedUnshare,
checkIsolationSupport,
};

View File

@@ -1,610 +0,0 @@
/**
* Linux Sandbox Implementation
*
* Uses Linux namespaces for proper isolation:
* - User namespace: UID/GID mapping for unprivileged operation
* - Mount namespace: Overlayfs for ephemeral filesystem
* - Network namespace: Isolated network with controlled egress
* - PID namespace: Process isolation
* - UTS namespace: Hostname isolation
*/
// Linux namespace flags
const CLONE_NEWUSER = 0x10000000;
const CLONE_NEWNS = 0x00020000;
const CLONE_NEWNET = 0x40000000;
const CLONE_NEWPID = 0x20000000;
const CLONE_NEWUTS = 0x04000000;
const CLONE_NEWIPC = 0x08000000;
// Mount flags
const MS_BIND = 4096;
const MS_REC = 16384;
const MS_PRIVATE = 1 << 18;
const MS_RDONLY = 1;
const MS_NOSUID = 2;
const MS_NODEV = 4;
const MS_NOEXEC = 8;
// Syscall numbers (x86_64)
const SYS_unshare = 272;
const SYS_mount = 165;
const SYS_umount2 = 166;
const SYS_pivot_root = 155;
const SYS_chroot = 161;
const SYS_setns = 308;
export interface SandboxConfig {
/** Root directory for the sandbox (will be overlaid) */
rootfs: string;
/** Working directory inside the sandbox */
workdir: string;
/** Directories to bind mount read-only */
readonlyBinds?: string[];
/** Directories to bind mount read-write */
writableBinds?: string[];
/** Environment variables */
env?: Record<string, string>;
/** Allowed network hosts (empty = no network) */
allowedHosts?: string[];
/** Command to run */
command: string[];
/** UID inside the sandbox (default: 1000) */
uid?: number;
/** GID inside the sandbox (default: 1000) */
gid?: number;
/** Hostname inside the sandbox */
hostname?: string;
}
export interface SandboxResult {
exitCode: number;
stdout: string;
stderr: string;
/** Files modified in the overlay (to extract) */
modifiedFiles: string[];
}
/**
* Check if we can use unprivileged user namespaces
*/
export async function canCreateUserNamespace(): Promise<boolean> {
try {
const file = Bun.file("/proc/sys/kernel/unprivileged_userns_clone");
if (await file.exists()) {
const content = await file.text();
return content.trim() === "1";
}
// If file doesn't exist, try to check by attempting unshare
return true;
} catch {
return false;
}
}
/**
* Setup UID/GID mapping for user namespace
*/
async function setupUidGidMapping(pid: number, uid: number, gid: number): Promise<void> {
const currentUid = process.getuid?.() ?? 1000;
const currentGid = process.getgid?.() ?? 1000;
// Write uid_map: <uid_inside> <uid_outside> <count>
await Bun.write(`/proc/${pid}/uid_map`, `${uid} ${currentUid} 1\n`);
// Must write "deny" to setgroups before writing gid_map
await Bun.write(`/proc/${pid}/setgroups`, "deny\n");
// Write gid_map
await Bun.write(`/proc/${pid}/gid_map`, `${gid} ${currentGid} 1\n`);
}
/**
* Create overlay filesystem structure
*/
async function setupOverlayfs(
lowerDir: string,
workDir: string,
): Promise<{ upperDir: string; mergedDir: string; cleanup: () => Promise<void> }> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const crypto = await import("node:crypto");
// Create temporary directories for overlay
const sandboxId = crypto.randomBytes(8).toString("hex");
const baseDir = `/tmp/bun-sandbox-${sandboxId}`;
const upperDir = path.join(baseDir, "upper");
const overlayWorkDir = path.join(baseDir, "work");
const mergedDir = path.join(baseDir, "merged");
await fs.mkdir(upperDir, { recursive: true });
await fs.mkdir(overlayWorkDir, { recursive: true });
await fs.mkdir(mergedDir, { recursive: true });
const cleanup = async () => {
try {
// Unmount merged directory
const proc = Bun.spawn({
cmd: ["umount", "-l", mergedDir],
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {
// Ignore unmount errors
}
try {
await fs.rm(baseDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
};
return { upperDir, mergedDir, cleanup };
}
/**
* Create a helper script that runs inside the namespace
*/
function createNamespaceHelper(config: SandboxConfig): string {
const script = `#!/bin/sh
set -e
# Mount proc
mount -t proc proc /proc
# Mount tmpfs for /tmp
mount -t tmpfs tmpfs /tmp
# Mount devpts for /dev/pts
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts
# Set hostname
hostname "${config.hostname || "sandbox"}"
# Change to workdir
cd "${config.workdir}"
# Execute the command
exec ${config.command.map(arg => `"${arg.replace(/"/g, '\\"')}"`).join(" ")}
`;
return script;
}
/**
* Low-level sandbox using unshare (requires root or CAP_SYS_ADMIN)
*/
export async function runSandboxedRoot(config: SandboxConfig): Promise<SandboxResult> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
// Setup overlay filesystem
const { upperDir, mergedDir, cleanup } = await setupOverlayfs(config.rootfs, config.workdir);
try {
// Mount overlayfs
const mountProc = Bun.spawn({
cmd: [
"mount",
"-t",
"overlay",
"overlay",
"-o",
`lowerdir=${config.rootfs},upperdir=${upperDir},workdir=${path.dirname(upperDir)}/work`,
mergedDir,
],
});
const mountExit = await mountProc.exited;
if (mountExit !== 0) {
throw new Error(`Failed to mount overlayfs: exit code ${mountExit}`);
}
// Build unshare command with all namespaces
const unshareArgs = [
"unshare",
"--user",
"--map-root-user",
"--mount",
"--net",
"--pid",
"--fork",
"--uts",
"--ipc",
`--root=${mergedDir}`,
`--wd=${config.workdir}`,
];
// Add environment variables
const env: Record<string, string> = {
...config.env,
HOME: "/root",
PATH: "/usr/local/bin:/usr/bin:/bin",
TERM: "xterm-256color",
};
// Run the command
const proc = Bun.spawn({
cmd: [...unshareArgs, ...config.command],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Find modified files in upperDir
const modifiedFiles: string[] = [];
async function walkDir(dir: string, prefix: string = ""): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.join(prefix, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath, relativePath);
} else {
modifiedFiles.push(relativePath);
}
}
} catch {
// Ignore errors
}
}
await walkDir(upperDir);
return {
exitCode: exitCode ?? 1,
stdout,
stderr,
modifiedFiles,
};
} finally {
await cleanup();
}
}
/**
* Unprivileged sandbox using bwrap (bubblewrap) if available
*/
export async function runSandboxedBwrap(config: SandboxConfig): Promise<SandboxResult> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
// Check if bwrap is available
const whichProc = Bun.spawn({
cmd: ["which", "bwrap"],
stdout: "pipe",
stderr: "ignore",
});
const whichExit = await whichProc.exited;
if (whichExit !== 0) {
throw new Error("bubblewrap (bwrap) not found. Install it with: apt install bubblewrap");
}
// Setup overlay filesystem
const { upperDir, mergedDir, cleanup } = await setupOverlayfs(config.rootfs, config.workdir);
try {
// Build bwrap command
const bwrapArgs = [
"bwrap",
// User namespace with UID/GID mapping
"--unshare-user",
"--uid",
String(config.uid ?? 1000),
"--gid",
String(config.gid ?? 1000),
// Mount namespace
"--unshare-pid",
"--unshare-uts",
"--unshare-ipc",
// Hostname
"--hostname",
config.hostname || "sandbox",
// Root filesystem (bind mount the lower dir as base)
"--ro-bind",
config.rootfs,
"/",
// Overlay upper layer for writes
"--overlay-src",
config.rootfs,
"--tmp-overlay",
"/",
// Essential mounts
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--tmpfs",
"/run",
// Working directory
"--chdir",
config.workdir,
// Die with parent
"--die-with-parent",
];
// Add readonly binds
for (const bind of config.readonlyBinds || []) {
bwrapArgs.push("--ro-bind", bind, bind);
}
// Add writable binds
for (const bind of config.writableBinds || []) {
bwrapArgs.push("--bind", bind, bind);
}
// Network namespace (isolated by default)
if (!config.allowedHosts || config.allowedHosts.length === 0) {
bwrapArgs.push("--unshare-net");
}
// Environment variables
const env: Record<string, string> = {
HOME: "/home/sandbox",
PATH: "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin",
TERM: "xterm-256color",
...config.env,
};
// Run the command
const proc = Bun.spawn({
cmd: [...bwrapArgs, ...config.command],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// For bwrap with --tmp-overlay, files are in tmpfs and lost
// We would need a different approach to extract modified files
const modifiedFiles: string[] = [];
return {
exitCode: exitCode ?? 1,
stdout,
stderr,
modifiedFiles,
};
} finally {
await cleanup();
}
}
/**
* Simple sandbox using unshare command (works on most Linux systems)
*/
export async function runSandboxedUnshare(config: SandboxConfig): Promise<SandboxResult> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const crypto = await import("node:crypto");
// Create sandbox workspace
const sandboxId = crypto.randomBytes(8).toString("hex");
const workspaceDir = `/tmp/bun-sandbox-${sandboxId}`;
const upperDir = path.join(workspaceDir, "upper");
const workDir = path.join(workspaceDir, "work");
const mergedDir = path.join(workspaceDir, "merged");
await fs.mkdir(upperDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.mkdir(mergedDir, { recursive: true });
const cleanup = async () => {
try {
// Try to unmount
const umountProc = Bun.spawn({
cmd: ["umount", "-l", mergedDir],
stdout: "ignore",
stderr: "ignore",
});
await umountProc.exited;
} catch {
// Ignore
}
try {
await fs.rm(workspaceDir, { recursive: true, force: true });
} catch {
// Ignore
}
};
try {
// Build environment
const env: Record<string, string> = {
HOME: config.workdir,
PATH: "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin",
TERM: "xterm-256color",
...config.env,
};
// Build unshare command
// Using --user --map-root-user for unprivileged namespaces
const unshareArgs = ["unshare", "--user", "--map-root-user", "--mount", "--pid", "--fork", "--uts", "--ipc"];
// If no network hosts allowed, isolate network
if (!config.allowedHosts || config.allowedHosts.length === 0) {
unshareArgs.push("--net");
}
// Create a shell script to setup the mount namespace
const setupScript = `
#!/bin/sh
set -e
# Make all mounts private
mount --make-rprivate /
# Mount overlay if we have fuse-overlayfs or can use kernel overlay
if command -v fuse-overlayfs >/dev/null 2>&1; then
fuse-overlayfs -o lowerdir=${config.rootfs},upperdir=${upperDir},workdir=${workDir} ${mergedDir}
cd ${mergedDir}
# Pivot root
mkdir -p ${mergedDir}/old_root
pivot_root ${mergedDir} ${mergedDir}/old_root
umount -l /old_root || true
rmdir /old_root || true
fi
# Mount essential filesystems
mount -t proc proc /proc 2>/dev/null || true
mount -t sysfs sysfs /sys 2>/dev/null || true
mount -t tmpfs tmpfs /tmp 2>/dev/null || true
mount -t tmpfs tmpfs /run 2>/dev/null || true
# Setup /dev
mount -t tmpfs -o mode=755 tmpfs /dev 2>/dev/null || true
mknod -m 666 /dev/null c 1 3 2>/dev/null || true
mknod -m 666 /dev/zero c 1 5 2>/dev/null || true
mknod -m 666 /dev/random c 1 8 2>/dev/null || true
mknod -m 666 /dev/urandom c 1 9 2>/dev/null || true
mknod -m 666 /dev/tty c 5 0 2>/dev/null || true
ln -sf /proc/self/fd /dev/fd 2>/dev/null || true
ln -sf /proc/self/fd/0 /dev/stdin 2>/dev/null || true
ln -sf /proc/self/fd/1 /dev/stdout 2>/dev/null || true
ln -sf /proc/self/fd/2 /dev/stderr 2>/dev/null || true
# Set hostname
hostname ${config.hostname || "sandbox"} 2>/dev/null || true
# Change to workdir
cd ${config.workdir}
# Run the command
exec "$@"
`;
const setupScriptPath = path.join(workspaceDir, "setup.sh");
await Bun.write(setupScriptPath, setupScript);
await fs.chmod(setupScriptPath, 0o755);
// Run with unshare
const proc = Bun.spawn({
cmd: [...unshareArgs, "/bin/sh", setupScriptPath, ...config.command],
env,
cwd: config.workdir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Find modified files
const modifiedFiles: string[] = [];
async function walkDir(dir: string, prefix: string = ""): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.join(prefix, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath, relativePath);
} else {
modifiedFiles.push(relativePath);
}
}
} catch {
// Ignore
}
}
await walkDir(upperDir);
return {
exitCode: exitCode ?? 1,
stdout,
stderr,
modifiedFiles,
};
} finally {
await cleanup();
}
}
/**
* Main sandbox function - tries different methods based on availability
*/
export async function runSandboxed(config: SandboxConfig): Promise<SandboxResult> {
// Check for bwrap first (most portable unprivileged option)
const hasBwrap = await (async () => {
const proc = Bun.spawn({
cmd: ["which", "bwrap"],
stdout: "ignore",
stderr: "ignore",
});
return (await proc.exited) === 0;
})();
if (hasBwrap) {
try {
return await runSandboxedBwrap(config);
} catch (e) {
console.warn("bwrap sandbox failed, falling back:", e);
}
}
// Try unshare-based sandbox
const canUnshare = await canCreateUserNamespace();
if (canUnshare) {
try {
return await runSandboxedUnshare(config);
} catch (e) {
console.warn("unshare sandbox failed, falling back:", e);
}
}
// Fallback: no isolation, just run the command with warning
console.warn(
"WARNING: Running without sandbox isolation. Install bubblewrap or enable unprivileged user namespaces.",
);
const proc = Bun.spawn({
cmd: config.command,
cwd: config.workdir,
env: {
...config.env,
PATH: "/usr/local/bin:/usr/bin:/bin",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return {
exitCode: exitCode ?? 1,
stdout,
stderr,
modifiedFiles: [],
};
}
export default {
runSandboxed,
runSandboxedBwrap,
runSandboxedUnshare,
runSandboxedRoot,
canCreateUserNamespace,
};

View File

@@ -1740,9 +1740,9 @@ declare module "bun" {
* @default "esm"
*/
format?: /**
* ECMAScript Module format
*/
| "esm"
* ECMAScript Module format
*/
| "esm"
/**
* CommonJS format
* **Experimental**
@@ -3316,10 +3316,10 @@ declare module "bun" {
function color(
input: ColorInput,
outputFormat?: /**
* True color ANSI color string, for use in terminals
* @example \x1b[38;2;100;200;200m
*/
| "ansi"
* True color ANSI color string, for use in terminals
* @example \x1b[38;2;100;200;200m
*/
| "ansi"
| "ansi-16"
| "ansi-16m"
/**
@@ -5335,6 +5335,7 @@ declare module "bun" {
| "pipe"
| "inherit"
| "ignore"
| "pty"
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -5348,6 +5349,7 @@ declare module "bun" {
| "pipe"
| "inherit"
| "ignore"
| "pty"
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -5405,14 +5407,16 @@ declare module "bun" {
* - `"ignore"`, `null`, `undefined`: The process will have no standard input (default)
* - `"pipe"`: The process will have a new {@link FileSink} for standard input
* - `"inherit"`: The process will inherit the standard input of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`, `Blob`, `Bun.file()`, `Response`, `Request`: The process will read from buffer/stream.
* - `number`: The process will read from the file descriptor
*
* For stdout and stdin you may pass:
* For stdout and stderr you may pass:
*
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdout.isTTY === true` / `process.stderr.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5427,6 +5431,7 @@ declare module "bun" {
* - `"ignore"`, `null`, `undefined`: The process will have no standard input
* - `"pipe"`: The process will have a new {@link FileSink} for standard input
* - `"inherit"`: The process will inherit the standard input of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`, `Blob`: The process will read from the buffer
* - `number`: The process will read from the file descriptor
*
@@ -5439,6 +5444,7 @@ declare module "bun" {
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdout.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5451,6 +5457,7 @@ declare module "bun" {
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stderr.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5650,17 +5657,11 @@ declare module "bun" {
maxBuffer?: number;
}
interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable> extends BaseOptions<
In,
Out,
Err
> {}
interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {}
interface SpawnOptions<In extends Writable, Out extends Readable, Err extends Readable> extends BaseOptions<
In,
Out,
Err
> {
interface SpawnOptions<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {
/**
* If true, stdout and stderr pipes will not automatically start reading
* data. Reading will only begin when you access the `stdout` or `stderr`

View File

@@ -635,6 +635,16 @@ pub fn spawnMaybeSync(
.stdout_maxbuf = subprocess.stdout_maxbuf,
};
if (comptime Environment.isPosix) {
log("After subprocess init: stdout state={s}, stdin FD={?d}, stdout FD={?d}", .{
@tagName(subprocess.stdout),
if (spawned.stdin) |fd| fd.native() else null,
if (spawned.stdout) |fd| fd.native() else null,
});
} else {
log("After subprocess init: stdout state={s}", .{@tagName(subprocess.stdout)});
}
subprocess.process.setExitHandler(subprocess);
promise_for_stream.ensureStillAlive();
@@ -997,7 +1007,7 @@ pub fn appendEnvpFromJS(globalThis: *jsc.JSGlobalObject, object: *jsc.JSObject,
}
}
const log = Output.scoped(.Subprocess, .hidden);
const log = Output.scoped(.spawn_bindings, .hidden);
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
const IPC = @import("../../ipc.zig");

View File

@@ -1004,6 +1004,13 @@ pub const PosixSpawnOptions = struct {
pipe: bun.FileDescriptor,
// TODO: remove this entry, it doesn't seem to be used
dup2: struct { out: bun.jsc.Subprocess.StdioKind, to: bun.jsc.Subprocess.StdioKind },
/// Pseudo-terminal with optional window size configuration
pty: PtyConfig,
pub const PtyConfig = struct {
width: u16 = 80,
height: u16 = 24,
};
};
pub fn deinit(_: *const PosixSpawnOptions) void {
@@ -1104,15 +1111,21 @@ pub const PosixSpawnResult = struct {
extra_pipes: std.array_list.Managed(bun.FileDescriptor) = std.array_list.Managed(bun.FileDescriptor).init(bun.default_allocator),
memfds: [3]bool = .{ false, false, false },
/// PTY master file descriptor if PTY was requested for any stdio.
/// The child process has the slave side; parent uses this for I/O.
pty_master: ?bun.FileDescriptor = null,
// ESRCH can happen when requesting the pidfd
has_exited: bool = false,
pub fn close(this: *WindowsSpawnResult) void {
pub fn close(this: *PosixSpawnResult) void {
if (this.pty_master) |fd| {
fd.close();
this.pty_master = null;
}
for (this.extra_pipes.items) |fd| {
fd.close();
}
this.extra_pipes.clearAndFree();
}
@@ -1301,6 +1314,38 @@ pub fn spawnProcessPosix(
var dup_stdout_to_stderr: bool = false;
// Check if any stdio uses PTY and create a single PTY pair if needed
var pty_slave: ?bun.FileDescriptor = null;
var pty_master: ?bun.FileDescriptor = null;
for (stdio_options) |opt| {
if (opt == .pty) {
// Create PTY pair with the configured window size
const winsize = bun.sys.WinSize{
.ws_col = opt.pty.width,
.ws_row = opt.pty.height,
};
const pty_pair = try bun.sys.openpty(&winsize).unwrap();
pty_master = pty_pair.master;
pty_slave = pty_pair.slave;
log("PTY created: master={d}, slave={d}", .{ pty_pair.master.native(), pty_pair.slave.native() });
// Track for cleanup
try to_close_at_end.append(pty_pair.slave);
try to_close_on_error.append(pty_pair.master);
// Set master to non-blocking for async operations
if (!options.sync) {
try bun.sys.setNonblocking(pty_pair.master).unwrap();
}
spawned.pty_master = pty_pair.master;
break;
}
}
for (0..3) |i| {
const stdio = stdios[i];
const fileno = bun.FD.fromNative(@intCast(i));
@@ -1417,6 +1462,29 @@ pub fn spawnProcessPosix(
try actions.dup2(fd, fileno);
stdio.* = fd;
},
.pty => {
// Use the slave side of the PTY for this stdio
// The PTY pair was already created above
const slave = pty_slave.?;
try actions.dup2(slave, fileno);
// The parent gets the master side for I/O.
// Each stdio gets its own dup'd FD so they can register with epoll independently.
// stderr is ignored if stdout already has PTY (they share the same stream).
if (i == 2 and stdio_options[1] == .pty) {
// stdout is also PTY, stderr becomes ignore (user reads both from stdout)
stdio.* = null;
log("PTY stderr: ignored (stdout has PTY)", .{});
} else {
// dup() the master FD so each stdio has its own FD for epoll
const duped = try bun.sys.dup(pty_master.?).unwrap();
if (!options.sync) {
try bun.sys.setNonblocking(duped).unwrap();
}
try to_close_on_error.append(duped);
stdio.* = duped;
log("PTY {s}: duped master={d}", .{ if (i == 0) "stdin" else if (i == 1) "stdout" else "stderr", duped.native() });
}
},
}
}
@@ -1463,6 +1531,19 @@ pub fn spawnProcessPosix(
try extra_fds.append(fd);
},
.pty => {
// Use existing PTY slave (should have been created from primary stdio)
if (pty_slave) |slave| {
try actions.dup2(slave, fileno);
// dup() the master FD so each extra_fd has its own FD for epoll
const duped = try bun.sys.dup(pty_master.?).unwrap();
if (!options.sync) {
try bun.sys.setNonblocking(duped).unwrap();
}
try to_close_on_error.append(duped);
try extra_fds.append(duped);
}
},
}
}
@@ -1494,6 +1575,13 @@ pub fn spawnProcessPosix(
spawned.extra_pipes = extra_fds;
extra_fds = std.array_list.Managed(bun.FileDescriptor).init(bun.default_allocator);
// Parent uses dup()'d copies of the PTY master for stdio/extra_fds;
// the original master FD is no longer needed and should be closed
// to avoid leaking one FD per PTY spawn.
if (pty_master) |fd| {
fd.close();
}
if (comptime Environment.isLinux) {
// If it's spawnSync and we want to block the entire thread
// don't even bother with pidfd. It's not necessary.

View File

@@ -14,6 +14,16 @@ pub const Stdio = union(enum) {
pipe,
ipc,
readable_stream: jsc.WebCore.ReadableStream,
/// Pseudo-terminal: creates a PTY master/slave pair for the spawned process.
/// The child gets the slave side, parent gets the master side for I/O.
pty: PtyOptions,
pub const PtyOptions = struct {
/// Terminal width in columns (default: 80)
width: u16 = 80,
/// Terminal height in rows (default: 24)
height: u16 = 24,
};
const log = bun.sys.syslog;
@@ -192,6 +202,7 @@ pub const Stdio = union(enum) {
.path => |pathlike| .{ .path = pathlike.slice() },
.inherit => .{ .inherit = {} },
.ignore => .{ .ignore = {} },
.pty => |pty_opts| .{ .pty = .{ .width = pty_opts.width, .height = pty_opts.height } },
},
};
}
@@ -244,6 +255,7 @@ pub const Stdio = union(enum) {
.path => |pathlike| .{ .path = pathlike.slice() },
.inherit => .{ .inherit = {} },
.ignore => .{ .ignore = {} },
.pty => .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) }, // PTY falls back to pipe on Windows
.memfd => @panic("This should never happen"),
},
@@ -346,8 +358,18 @@ pub const Stdio = union(enum) {
out_stdio.* = Stdio{ .pipe = {} };
} else if (str.eqlComptime("ipc")) {
out_stdio.* = Stdio{ .ipc = {} };
} else if (str.eqlComptime("pty")) {
if (is_sync) {
return globalThis.throwInvalidArguments("PTY is not supported with spawnSync", .{});
}
// On Windows, PTY falls back to pipe (no real PTY support)
if (comptime Environment.isWindows) {
out_stdio.* = Stdio{ .pipe = {} };
} else {
out_stdio.* = Stdio{ .pty = .{} };
}
} else {
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', Bun.file(pathOrFd), number, or null", .{});
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', 'pty', Bun.file(pathOrFd), number, or null", .{});
}
return;
} else if (value.isNumber()) {
@@ -436,7 +458,56 @@ pub const Stdio = union(enum) {
return;
}
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'ignore', or null", .{});
// Check for PTY object: { type: "pty", width?: number, height?: number }
if (value.isObject()) {
if (try value.getTruthy(globalThis, "type")) |type_val| {
if (type_val.isString()) {
const type_str = try type_val.getZigString(globalThis);
if (type_str.eqlComptime("pty")) {
if (is_sync) {
return globalThis.throwInvalidArguments("PTY is not supported with spawnSync", .{});
}
// On Windows, PTY falls back to pipe (no real PTY support)
if (comptime Environment.isWindows) {
out_stdio.* = Stdio{ .pipe = {} };
return;
}
var pty_opts: PtyOptions = .{};
if (try value.get(globalThis, "width")) |width_val| {
if (!width_val.isUndefinedOrNull()) {
if (!width_val.isNumber()) {
return globalThis.throwInvalidArguments("PTY width must be a number", .{});
}
const width = width_val.toInt32();
if (width <= 0 or width > std.math.maxInt(u16)) {
return globalThis.throwInvalidArguments("PTY width must be a positive integer <= 65535", .{});
}
pty_opts.width = @intCast(width);
}
}
if (try value.get(globalThis, "height")) |height_val| {
if (!height_val.isUndefinedOrNull()) {
if (!height_val.isNumber()) {
return globalThis.throwInvalidArguments("PTY height must be a number", .{});
}
const height = height_val.toInt32();
if (height <= 0 or height > std.math.maxInt(u16)) {
return globalThis.throwInvalidArguments("PTY height must be a positive integer <= 65535", .{});
}
pty_opts.height = @intCast(height);
}
}
out_stdio.* = Stdio{ .pty = pty_opts };
return;
}
}
}
}
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', 'pty', Bun.file(pathOrFd), number, or null", .{});
}
pub fn extractBlob(stdio: *Stdio, globalThis: *jsc.JSGlobalObject, blob: jsc.WebCore.Blob.Any, i: i32) bun.JSError!void {

View File

@@ -282,6 +282,7 @@ pub fn getStdin(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSVa
}
pub fn getStdout(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
log("getStdout: subprocess={*}, stdout ptr={*}, stdout state={s}", .{ this, &this.stdout, @tagName(this.stdout) });
this.observable_getters.insert(.stdout);
// NOTE: ownership of internal buffers is transferred to the JSValue, which
// gets cached on JSSubprocess (created via bindgen). This makes it

View File

@@ -1,3 +1,5 @@
const log = Output.scoped(.Readable, .hidden);
pub const Readable = union(enum) {
fd: bun.FileDescriptor,
memfd: bun.FileDescriptor,
@@ -57,7 +59,8 @@ pub const Readable = union(enum) {
}
}
return switch (stdio) {
log("Readable.init: stdio={s}, result={?d}, subprocess={*}, stdout state={s}", .{ @tagName(stdio), if (comptime Environment.isPosix) (if (result) |r| r.native() else null) else @as(?c_int, null), process, @tagName(process.stdout) });
const readable = switch (stdio) {
.inherit => Readable{ .inherit = {} },
.ignore, .ipc, .path => Readable{ .ignore = {} },
.fd => |fd| if (Environment.isPosix) Readable{ .fd = result.? } else Readable{ .fd = fd },
@@ -67,10 +70,22 @@ pub const Readable = union(enum) {
.array_buffer, .blob => Output.panic("TODO: implement ArrayBuffer & Blob support in Stdio readable", .{}),
.capture => Output.panic("TODO: implement capture support in Stdio readable", .{}),
.readable_stream => Readable{ .ignore = {} }, // ReadableStream is handled separately
.pty => if (Environment.isPosix and result == null) blk: {
// When stdout and stderr both use PTY, they share the same master FD.
// stderr's result will be null - ignore it since stdout handles reading.
log("PTY with null result -> ignore", .{});
break :blk Readable{ .ignore = {} };
} else blk: {
log("PTY with result -> creating pipe reader", .{});
break :blk Readable{ .pipe = PipeReader.createForPty(event_loop, process, result, max_size) }; // PTY master - use read() not recv()
},
};
log("Readable.init returning: {s}", .{@tagName(readable)});
return readable;
}
pub fn onClose(this: *Readable, _: ?bun.sys.Error) void {
pub fn onClose(this: *Readable, err: ?bun.sys.Error) void {
log("onClose called, current state={s}, err={?s}", .{ @tagName(this.*), if (err) |e| @tagName(e.getErrno()) else null });
this.* = .closed;
}
@@ -116,6 +131,7 @@ pub const Readable = union(enum) {
pub fn toJS(this: *Readable, globalThis: *jsc.JSGlobalObject, exited: bool) bun.JSError!JSValue {
_ = exited; // autofix
log("Readable.toJS: this={*}, state={s}", .{ this, @tagName(this.*) });
switch (this.*) {
// should only be reachable when the entire output is buffered.
.memfd => return this.toBufferedValue(globalThis),
@@ -139,6 +155,7 @@ pub const Readable = union(enum) {
return jsc.WebCore.ReadableStream.fromOwnedSlice(globalThis, own, 0);
},
else => {
log("Readable.toJS returning undefined for state={s}", .{@tagName(this.*)});
return .js_undefined;
},
}

View File

@@ -14,6 +14,8 @@ state: union(enum) {
err: bun.sys.Error,
} = .{ .pending = {} },
stdio_result: StdioResult,
/// True if this is a PTY master (character device, not socket - use read() not recv())
is_pty: bool = false,
pub const IOReader = bun.io.BufferedReader;
pub const Poll = IOReader;
@@ -34,12 +36,21 @@ pub fn detach(this: *PipeReader) void {
}
pub fn create(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf) *PipeReader {
return createWithOptions(event_loop, process, result, limit, false);
}
pub fn createForPty(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf) *PipeReader {
return createWithOptions(event_loop, process, result, limit, true);
}
fn createWithOptions(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf, is_pty: bool) *PipeReader {
var this = bun.new(PipeReader, .{
.ref_count = .init(),
.process = process,
.reader = IOReader.init(@This()),
.event_loop = event_loop,
.stdio_result = result,
.is_pty = is_pty,
});
MaxBuf.addToPipereader(limit, &this.reader.maxbuf);
if (Environment.isWindows) {
@@ -63,6 +74,13 @@ pub fn start(this: *PipeReader, process: *Subprocess, event_loop: *jsc.EventLoop
return this.reader.startWithCurrentPipe();
}
// Set PTY flag BEFORE start() so that onError can check it during registerPoll()
if (comptime Environment.isPosix) {
if (this.is_pty) {
this.reader.flags.is_pty = true;
}
}
switch (this.reader.start(this.stdio_result.?, true)) {
.err => |err| {
return .{ .err = err };
@@ -70,8 +88,11 @@ pub fn start(this: *PipeReader, process: *Subprocess, event_loop: *jsc.EventLoop
.result => {
if (comptime Environment.isPosix) {
const poll = this.reader.handle.poll;
poll.flags.insert(.socket);
this.reader.flags.socket = true;
// PTY is a character device, not a socket - use read() not recv()
if (!this.is_pty) {
poll.flags.insert(.socket);
this.reader.flags.socket = true;
}
this.reader.flags.nonblocking = true;
this.reader.flags.pollable = true;
poll.flags.insert(.nonblocking);
@@ -167,6 +188,13 @@ pub fn toBuffer(this: *PipeReader, globalThis: *jsc.JSGlobalObject) jsc.JSValue
}
pub fn onReaderError(this: *PipeReader, err: bun.sys.Error) void {
// For PTY, EIO is expected when the child exits (slave side closes).
// Treat it as a normal EOF, not an error.
if (this.is_pty and err.getErrno() == .IO) {
this.onReaderDone();
return;
}
if (this.state == .done) {
bun.default_allocator.free(this.state.done);
}

View File

@@ -153,6 +153,10 @@ pub const Writable = union(enum) {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// PTY stdin is not supported on Windows; return ignore
return Writable{ .ignore = {} };
},
}
}
@@ -228,6 +232,30 @@ pub const Writable = union(enum) {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// PTY uses pipe-like semantics, but with the PTY master fd
const pipe = jsc.WebCore.FileSink.create(event_loop, result.?);
switch (pipe.writer.start(pipe.fd, true)) {
.result => {},
.err => {
pipe.deref();
return error.UnexpectedCreatingStdin;
},
}
// PTY master is a character device, NOT a socket
// Do NOT set .socket flag - PTY uses write() not send()
subprocess.weak_file_sink_stdin_ptr = pipe;
subprocess.ref();
subprocess.flags.has_stdin_destructor_called = false;
subprocess.flags.deref_on_stdin_destroyed = true;
return Writable{
.pipe = pipe,
};
},
}
}

View File

@@ -94,7 +94,9 @@ const PosixBufferedReader = struct {
memfd: bool = false,
use_pread: bool = false,
is_paused: bool = false,
_: u6 = 0,
/// True if reading from PTY master - treat EIO as EOF
is_pty: bool = false,
_: u5 = 0,
};
pub fn init(comptime Type: type) PosixBufferedReader {
@@ -270,6 +272,13 @@ const PosixBufferedReader = struct {
}
pub fn onError(this: *PosixBufferedReader, err: bun.sys.Error) void {
// For PTY, EIO is expected when the child exits (slave side closes).
// Treat it as a normal EOF.
if (this.flags.is_pty and err.getErrno() == .IO) {
this.closeWithoutReporting();
this.done();
return;
}
this.vtable.onReaderError(err);
}
@@ -760,7 +769,7 @@ pub const WindowsBufferedReader = struct {
return Type.onReaderError(@as(*Type, @ptrCast(@alignCast(this))), err);
}
fn loop(this: *anyopaque) *Async.Loop {
return Type.loop(@as(*Type, @alignCast(@ptrCast(this))));
return Type.loop(@as(*Type, @ptrCast(@alignCast(this))));
}
};
return .{

View File

@@ -1,55 +0,0 @@
//! Sandbox module for agent sandboxes.
//!
//! This module provides tools for creating and managing ephemeral agent environments
//! based on Sandboxfile declarations.
//!
//! Features:
//! - Sandboxfile parser for declarative sandbox configuration
//! - Linux namespace isolation (user, mount, PID, network, UTS, IPC)
//! - Overlayfs for copy-on-write filesystem
//! - Seccomp BPF for syscall filtering
//!
//! Example:
//! ```zig
//! const sandbox = @import("sandbox");
//!
//! // Parse a Sandboxfile
//! var parser = sandbox.Parser.init(allocator, path, src);
//! const config = try parser.parse();
//!
//! // Run isolated command
//! const result = try sandbox.executor.runIsolated(allocator, &.{"echo", "hello"}, .{});
//! ```
const builtin = @import("builtin");
// Sandboxfile parser
pub const sandboxfile = @import("sandbox/sandboxfile.zig");
pub const Sandboxfile = sandboxfile.Sandboxfile;
pub const Parser = sandboxfile.Parser;
pub const validate = sandboxfile.validate;
// Linux-specific isolation
pub const linux = if (builtin.os.tag == .linux) @import("sandbox/linux.zig") else struct {};
pub const executor = if (builtin.os.tag == .linux) @import("sandbox/executor.zig") else struct {};
// Re-export common types
pub const SandboxConfig = if (builtin.os.tag == .linux) linux.SandboxConfig else struct {};
pub const SandboxResult = if (builtin.os.tag == .linux) executor.SandboxResult else struct {};
/// Check if Linux namespace isolation is available
pub fn isIsolationAvailable() bool {
if (builtin.os.tag != .linux) return false;
// Check if unprivileged user namespaces are enabled
const file = std.fs.openFileAbsolute("/proc/sys/kernel/unprivileged_userns_clone", .{}) catch return true;
defer file.close();
var buf: [2]u8 = undefined;
const n = file.read(&buf) catch return false;
if (n > 0 and buf[0] == '1') return true;
return false;
}
const std = @import("std");

View File

@@ -1,420 +0,0 @@
//! Sandbox Executor
//!
//! Creates and manages sandboxed processes using Linux namespaces.
//! This module handles the fork/clone, namespace setup, and process lifecycle.
const std = @import("std");
const builtin = @import("builtin");
const bun = @import("bun");
const linux = std.os.linux;
const posix = std.posix;
const sandbox_linux = @import("linux.zig");
const SandboxConfig = sandbox_linux.SandboxConfig;
const Allocator = std.mem.Allocator;
const fd_t = posix.fd_t;
const pid_t = posix.pid_t;
// ============================================================================
// Pipe Management
// ============================================================================
const Pipe = struct {
read_fd: fd_t,
write_fd: fd_t,
fn create() !Pipe {
const fds = try posix.pipe();
return Pipe{
.read_fd = fds[0],
.write_fd = fds[1],
};
}
fn closeRead(self: *Pipe) void {
if (self.read_fd != -1) {
posix.close(self.read_fd);
self.read_fd = -1;
}
}
fn closeWrite(self: *Pipe) void {
if (self.write_fd != -1) {
posix.close(self.write_fd);
self.write_fd = -1;
}
}
fn close(self: *Pipe) void {
self.closeRead();
self.closeWrite();
}
};
// ============================================================================
// Sandbox Process
// ============================================================================
pub const SandboxProcess = struct {
pid: pid_t,
stdout_pipe: Pipe,
stderr_pipe: Pipe,
sync_pipe: Pipe, // For parent-child synchronization
pub fn wait(self: *SandboxProcess) !u32 {
const result = posix.waitpid(self.pid, 0);
if (result.status.Exited) |code| {
return code;
}
if (result.status.Signaled) |sig| {
return 128 + @as(u32, @intFromEnum(sig));
}
return 1;
}
pub fn readStdout(self: *SandboxProcess, allocator: Allocator) ![]u8 {
return readAll(allocator, self.stdout_pipe.read_fd);
}
pub fn readStderr(self: *SandboxProcess, allocator: Allocator) ![]u8 {
return readAll(allocator, self.stderr_pipe.read_fd);
}
fn readAll(allocator: Allocator, fd: fd_t) ![]u8 {
var buffer = std.ArrayList(u8).init(allocator);
errdefer buffer.deinit();
var read_buf: [4096]u8 = undefined;
while (true) {
const n = posix.read(fd, &read_buf) catch |err| switch (err) {
error.WouldBlock => continue,
else => return err,
};
if (n == 0) break;
try buffer.appendSlice(read_buf[0..n]);
}
return buffer.toOwnedSlice();
}
pub fn kill(self: *SandboxProcess) void {
_ = posix.kill(self.pid, .KILL) catch {};
}
pub fn deinit(self: *SandboxProcess) void {
self.stdout_pipe.close();
self.stderr_pipe.close();
self.sync_pipe.close();
}
};
// ============================================================================
// Sandbox Executor
// ============================================================================
pub const Executor = struct {
allocator: Allocator,
config: SandboxConfig,
// Overlay filesystem paths
overlay_base: ?[]const u8 = null,
overlay_upper: ?[]const u8 = null,
overlay_work: ?[]const u8 = null,
overlay_merged: ?[]const u8 = null,
pub fn init(allocator: Allocator, config: SandboxConfig) Executor {
return Executor{
.allocator = allocator,
.config = config,
};
}
pub fn deinit(self: *Executor) void {
// Cleanup overlay directories
if (self.overlay_base) |base| {
// Unmount merged
if (self.overlay_merged) |merged| {
const merged_z = @as([*:0]const u8, @ptrCast(merged.ptr));
sandbox_linux.umount2(merged_z, sandbox_linux.MNT_DETACH) catch {};
}
// Remove directories
std.fs.deleteTreeAbsolute(base) catch {};
self.allocator.free(base);
}
}
/// Setup overlay filesystem for copy-on-write
pub fn setupOverlay(self: *Executor) !void {
// Generate unique base path
var rand_buf: [8]u8 = undefined;
std.crypto.random.bytes(&rand_buf);
var hex_buf: [16]u8 = undefined;
_ = std.fmt.bufPrint(&hex_buf, "{s}", .{std.fmt.fmtSliceHexLower(&rand_buf)}) catch unreachable;
const base = try std.fmt.allocPrint(self.allocator, "/tmp/bun-sandbox-{s}", .{hex_buf});
errdefer self.allocator.free(base);
// Create directories
const upper = try std.fmt.allocPrint(self.allocator, "{s}/upper", .{base});
errdefer self.allocator.free(upper);
const work = try std.fmt.allocPrint(self.allocator, "{s}/work", .{base});
errdefer self.allocator.free(work);
const merged = try std.fmt.allocPrint(self.allocator, "{s}/merged", .{base});
errdefer self.allocator.free(merged);
try std.fs.makeDirAbsolute(base);
try std.fs.makeDirAbsolute(upper);
try std.fs.makeDirAbsolute(work);
try std.fs.makeDirAbsolute(merged);
self.overlay_base = base;
self.overlay_upper = upper;
self.overlay_work = work;
self.overlay_merged = merged;
}
/// Spawn a sandboxed process
pub fn spawn(self: *Executor, argv: []const []const u8, envp: []const [2][]const u8) !SandboxProcess {
// Create pipes for stdout, stderr, and sync
var stdout_pipe = try Pipe.create();
errdefer stdout_pipe.close();
var stderr_pipe = try Pipe.create();
errdefer stderr_pipe.close();
var sync_pipe = try Pipe.create();
errdefer sync_pipe.close();
// Fork the process
const pid = try posix.fork();
if (pid == 0) {
// Child process
self.childProcess(argv, envp, &stdout_pipe, &stderr_pipe, &sync_pipe) catch {
posix.exit(127);
};
posix.exit(0);
}
// Parent process
stdout_pipe.closeWrite();
stderr_pipe.closeWrite();
sync_pipe.closeRead();
// Setup user namespace mappings (must be done from parent)
if (self.config.user_ns) {
const current_uid = linux.getuid();
const current_gid = linux.getgid();
sandbox_linux.writeUidMap(pid, self.config.uid, current_uid, 1) catch {};
sandbox_linux.writeGidMap(pid, self.config.gid, current_gid, 1) catch {};
}
// Signal child to continue
_ = posix.write(sync_pipe.write_fd, "x") catch {};
sync_pipe.closeWrite();
return SandboxProcess{
.pid = pid,
.stdout_pipe = stdout_pipe,
.stderr_pipe = stderr_pipe,
.sync_pipe = sync_pipe,
};
}
fn childProcess(
self: *Executor,
argv: []const []const u8,
envp: []const [2][]const u8,
stdout_pipe: *Pipe,
stderr_pipe: *Pipe,
sync_pipe: *Pipe,
) !void {
// Close parent ends of pipes
stdout_pipe.closeRead();
stderr_pipe.closeRead();
sync_pipe.closeWrite();
// Redirect stdout/stderr
try posix.dup2(stdout_pipe.write_fd, posix.STDOUT_FILENO);
try posix.dup2(stderr_pipe.write_fd, posix.STDERR_FILENO);
// Unshare namespaces
const flags = self.config.getCloneFlags();
if (flags != 0) {
sandbox_linux.unshare(flags) catch |err| {
std.debug.print("unshare failed: {}\n", .{err});
return err;
};
}
// Wait for parent to setup UID/GID mappings
var buf: [1]u8 = undefined;
_ = posix.read(sync_pipe.read_fd, &buf) catch {};
sync_pipe.closeRead();
// Setup mount namespace
if (self.config.mount_ns) {
try sandbox_linux.setupMountNamespace();
// Mount overlay if configured
if (self.overlay_merged) |merged| {
const overlay = sandbox_linux.OverlayPaths{
.lower_dir = self.config.rootfs,
.upper_dir = self.overlay_upper.?,
.work_dir = self.overlay_work.?,
.merged_dir = merged,
};
overlay.mountOverlay() catch {};
}
// Mount essential filesystems
sandbox_linux.mountProc("/proc") catch {};
sandbox_linux.mountTmpfs("/tmp", "size=64m,mode=1777") catch {};
sandbox_linux.mountDev("/dev") catch {};
// Bind mount readonly paths
for (self.config.readonly_binds) |path| {
const path_z = @as([*:0]const u8, @ptrCast(path.ptr));
sandbox_linux.bindMount(path_z, path_z, true) catch {};
}
// Bind mount writable paths
for (self.config.writable_binds) |path| {
const path_z = @as([*:0]const u8, @ptrCast(path.ptr));
sandbox_linux.bindMount(path_z, path_z, false) catch {};
}
}
// Setup UTS namespace (hostname)
if (self.config.uts_ns) {
sandbox_linux.sethostname(self.config.hostname) catch {};
}
// Apply seccomp filter
if (self.config.seccomp) {
if (sandbox_linux.createSeccompFilter(self.allocator)) |filter| {
defer self.allocator.free(filter);
sandbox_linux.applySeccompFilter(filter) catch {};
} else |_| {}
}
// Change to working directory
posix.chdir(self.config.workdir) catch {};
// Build environment
var env_ptrs: [256][*:0]const u8 = undefined;
var env_count: usize = 0;
for (envp) |kv| {
if (env_count >= 255) break;
// Would need to format "KEY=VALUE" here
_ = kv;
// env_ptrs[env_count] = ...
// env_count += 1;
}
env_ptrs[env_count] = null;
// Build argv
var argv_ptrs: [256][*:0]const u8 = undefined;
for (argv, 0..) |arg, i| {
if (i >= 255) break;
argv_ptrs[i] = @as([*:0]const u8, @ptrCast(arg.ptr));
}
argv_ptrs[argv.len] = null;
// Execute the command
const argv_ptr: [*:null]const ?[*:0]const u8 = @ptrCast(&argv_ptrs);
const envp_ptr: [*:null]const ?[*:0]const u8 = @ptrCast(&env_ptrs);
const err = posix.execvpeZ(argv_ptrs[0], argv_ptr, envp_ptr);
_ = err;
// If we get here, exec failed
posix.exit(127);
}
/// Run a command and wait for completion
pub fn run(self: *Executor, argv: []const []const u8, envp: []const [2][]const u8) !SandboxResult {
var proc = try self.spawn(argv, envp);
defer proc.deinit();
const exit_code = try proc.wait();
const stdout = try proc.readStdout(self.allocator);
const stderr = try proc.readStderr(self.allocator);
return SandboxResult{
.exit_code = @truncate(exit_code),
.stdout = stdout,
.stderr = stderr,
};
}
};
pub const SandboxResult = struct {
exit_code: u8,
stdout: []const u8,
stderr: []const u8,
pub fn deinit(self: *SandboxResult, allocator: Allocator) void {
allocator.free(self.stdout);
allocator.free(self.stderr);
}
};
// ============================================================================
// High-Level API
// ============================================================================
/// Run a command in a fully isolated sandbox
pub fn runIsolated(
allocator: Allocator,
argv: []const []const u8,
config: SandboxConfig,
) !SandboxResult {
var executor = Executor.init(allocator, config);
defer executor.deinit();
// Setup overlay for filesystem isolation
try executor.setupOverlay();
return executor.run(argv, config.env);
}
/// Quick sandbox run with default config
pub fn quickRun(allocator: Allocator, argv: []const []const u8) !SandboxResult {
const config = SandboxConfig{};
return runIsolated(allocator, argv, config);
}
// ============================================================================
// Tests
// ============================================================================
test "create executor" {
const allocator = std.testing.allocator;
var executor = Executor.init(allocator, .{});
defer executor.deinit();
}
test "setup overlay" {
const allocator = std.testing.allocator;
var executor = Executor.init(allocator, .{});
defer executor.deinit();
executor.setupOverlay() catch |err| {
// May fail without permissions
if (err == error.AccessDenied) return;
return err;
};
// Verify directories created
if (executor.overlay_base) |base| {
var dir = std.fs.openDirAbsolute(base, .{}) catch return;
dir.close();
}
}

View File

@@ -1,562 +0,0 @@
//! Linux Sandbox Implementation
//!
//! Provides process isolation using Linux namespaces:
//! - User namespace: Unprivileged operation with UID/GID mapping
//! - Mount namespace: Isolated filesystem with overlayfs
//! - PID namespace: Process tree isolation
//! - Network namespace: Network isolation
//! - UTS namespace: Hostname isolation
//! - IPC namespace: IPC isolation
//!
//! Also implements seccomp-bpf for syscall filtering.
const std = @import("std");
const builtin = @import("builtin");
const bun = @import("bun");
const linux = std.os.linux;
const posix = std.posix;
const Allocator = std.mem.Allocator;
// ============================================================================
// Linux Constants
// ============================================================================
// Clone flags for namespaces
pub const CLONE_NEWNS = 0x00020000; // Mount namespace
pub const CLONE_NEWUTS = 0x04000000; // UTS namespace (hostname)
pub const CLONE_NEWIPC = 0x08000000; // IPC namespace
pub const CLONE_NEWUSER = 0x10000000; // User namespace
pub const CLONE_NEWPID = 0x20000000; // PID namespace
pub const CLONE_NEWNET = 0x40000000; // Network namespace
pub const CLONE_NEWCGROUP = 0x02000000; // Cgroup namespace
// Mount flags
pub const MS_RDONLY = 1;
pub const MS_NOSUID = 2;
pub const MS_NODEV = 4;
pub const MS_NOEXEC = 8;
pub const MS_REMOUNT = 32;
pub const MS_BIND = 4096;
pub const MS_MOVE = 8192;
pub const MS_REC = 16384;
pub const MS_PRIVATE = 1 << 18;
pub const MS_SLAVE = 1 << 19;
pub const MS_SHARED = 1 << 20;
pub const MS_STRICTATIME = 1 << 24;
// Umount flags
pub const MNT_DETACH = 2;
pub const MNT_FORCE = 1;
// Seccomp constants
pub const SECCOMP_MODE_FILTER = 2;
pub const SECCOMP_FILTER_FLAG_TSYNC = 1;
// Seccomp BPF actions
pub const SECCOMP_RET_KILL_PROCESS = 0x80000000;
pub const SECCOMP_RET_KILL_THREAD = 0x00000000;
pub const SECCOMP_RET_TRAP = 0x00030000;
pub const SECCOMP_RET_ERRNO = 0x00050000;
pub const SECCOMP_RET_TRACE = 0x7ff00000;
pub const SECCOMP_RET_LOG = 0x7ffc0000;
pub const SECCOMP_RET_ALLOW = 0x7fff0000;
// prctl constants
pub const PR_SET_NO_NEW_PRIVS = 38;
pub const PR_SET_SECCOMP = 22;
pub const PR_GET_SECCOMP = 21;
// Syscall numbers (x86_64)
pub const SYS_clone = 56;
pub const SYS_clone3 = 435;
pub const SYS_unshare = 272;
pub const SYS_setns = 308;
pub const SYS_mount = 165;
pub const SYS_umount2 = 166;
pub const SYS_pivot_root = 155;
pub const SYS_seccomp = 317;
pub const SYS_prctl = 157;
pub const SYS_sethostname = 170;
pub const SYS_setdomainname = 171;
// ============================================================================
// Syscall Wrappers
// ============================================================================
pub const SyscallError = error{
PermissionDenied,
InvalidArgument,
OutOfMemory,
NoSuchProcess,
ResourceBusy,
NotSupported,
Unknown,
};
fn syscallError(err: usize) SyscallError {
const e = linux.E;
return switch (linux.getErrno(@bitCast(err))) {
e.PERM, e.ACCES => error.PermissionDenied,
e.INVAL => error.InvalidArgument,
e.NOMEM, e.NOSPC => error.OutOfMemory,
e.SRCH => error.NoSuchProcess,
e.BUSY => error.ResourceBusy,
e.NOSYS, e.OPNOTSUPP => error.NotSupported,
else => error.Unknown,
};
}
/// unshare - disassociate parts of the process execution context
pub fn unshare(flags: u32) SyscallError!void {
const rc = linux.syscall1(.unshare, flags);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// setns - reassociate thread with a namespace
pub fn setns(fd: i32, nstype: u32) SyscallError!void {
const rc = linux.syscall2(.setns, @bitCast(@as(isize, fd)), nstype);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// mount - mount filesystem
pub fn mount(
source: ?[*:0]const u8,
target: [*:0]const u8,
fstype: ?[*:0]const u8,
flags: u32,
data: ?[*]const u8,
) SyscallError!void {
const rc = linux.syscall5(
.mount,
@intFromPtr(source),
@intFromPtr(target),
@intFromPtr(fstype),
flags,
@intFromPtr(data),
);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// umount2 - unmount filesystem
pub fn umount2(target: [*:0]const u8, flags: u32) SyscallError!void {
const rc = linux.syscall2(.umount2, @intFromPtr(target), flags);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// pivot_root - change the root filesystem
pub fn pivot_root(new_root: [*:0]const u8, put_old: [*:0]const u8) SyscallError!void {
const rc = linux.syscall2(.pivot_root, @intFromPtr(new_root), @intFromPtr(put_old));
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// sethostname - set the system hostname
pub fn sethostname(name: []const u8) SyscallError!void {
const rc = linux.syscall2(.sethostname, @intFromPtr(name.ptr), name.len);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
/// prctl - operations on a process
pub fn prctl(option: u32, arg2: usize, arg3: usize, arg4: usize, arg5: usize) SyscallError!usize {
const rc = linux.syscall5(.prctl, option, arg2, arg3, arg4, arg5);
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
return rc;
}
/// seccomp - operate on Secure Computing state of the process
pub fn seccomp(operation: u32, flags: u32, args: ?*const anyopaque) SyscallError!void {
const rc = linux.syscall3(.seccomp, operation, flags, @intFromPtr(args));
if (rc > std.math.maxInt(usize) - 4096) {
return syscallError(rc);
}
}
// ============================================================================
// User Namespace
// ============================================================================
/// Write UID mapping for user namespace
pub fn writeUidMap(pid: i32, inside_uid: u32, outside_uid: u32, count: u32) !void {
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/uid_map", .{pid}) catch unreachable;
var content_buf: [64]u8 = undefined;
const content = std.fmt.bufPrint(&content_buf, "{d} {d} {d}\n", .{ inside_uid, outside_uid, count }) catch unreachable;
const file = try std.fs.openFileAbsolute(path, .{ .mode = .write_only });
defer file.close();
try file.writeAll(content);
}
/// Write GID mapping for user namespace
pub fn writeGidMap(pid: i32, inside_gid: u32, outside_gid: u32, count: u32) !void {
// Must deny setgroups first
var setgroups_path_buf: [64]u8 = undefined;
const setgroups_path = std.fmt.bufPrint(&setgroups_path_buf, "/proc/{d}/setgroups", .{pid}) catch unreachable;
const setgroups_file = try std.fs.openFileAbsolute(setgroups_path, .{ .mode = .write_only });
defer setgroups_file.close();
try setgroups_file.writeAll("deny\n");
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/gid_map", .{pid}) catch unreachable;
var content_buf: [64]u8 = undefined;
const content = std.fmt.bufPrint(&content_buf, "{d} {d} {d}\n", .{ inside_gid, outside_gid, count }) catch unreachable;
const file = try std.fs.openFileAbsolute(path, .{ .mode = .write_only });
defer file.close();
try file.writeAll(content);
}
// ============================================================================
// Mount Namespace & Overlayfs
// ============================================================================
pub const OverlayPaths = struct {
lower_dir: []const u8,
upper_dir: []const u8,
work_dir: []const u8,
merged_dir: []const u8,
pub fn mountOverlay(self: *const OverlayPaths) SyscallError!void {
var options_buf: [512]u8 = undefined;
const options = std.fmt.bufPrintZ(&options_buf, "lowerdir={s},upperdir={s},workdir={s}", .{
self.lower_dir,
self.upper_dir,
self.work_dir,
}) catch return error.InvalidArgument;
const merged_z = @as([*:0]const u8, @ptrCast(self.merged_dir.ptr));
try mount("overlay", merged_z, "overlay", 0, options.ptr);
}
};
/// Setup basic mount namespace with private mounts
pub fn setupMountNamespace() SyscallError!void {
// Make all mounts private so changes don't propagate to host
try mount(null, "/", null, MS_REC | MS_PRIVATE, null);
}
/// Mount proc filesystem
pub fn mountProc(target: [*:0]const u8) SyscallError!void {
try mount("proc", target, "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC, null);
}
/// Mount tmpfs
pub fn mountTmpfs(target: [*:0]const u8, options: ?[*:0]const u8) SyscallError!void {
try mount("tmpfs", target, "tmpfs", MS_NOSUID | MS_NODEV, options);
}
/// Mount devtmpfs for /dev
pub fn mountDev(target: [*:0]const u8) SyscallError!void {
try mount("tmpfs", target, "tmpfs", MS_NOSUID | MS_STRICTATIME, "mode=755,size=65536k");
}
/// Bind mount (read-only or read-write)
pub fn bindMount(source: [*:0]const u8, target: [*:0]const u8, readonly: bool) SyscallError!void {
try mount(source, target, null, MS_BIND | MS_REC, null);
if (readonly) {
try mount(null, target, null, MS_BIND | MS_REMOUNT | MS_RDONLY | MS_REC, null);
}
}
// ============================================================================
// Seccomp BPF
// ============================================================================
/// BPF instruction
pub const BpfInsn = extern struct {
code: u16,
jt: u8,
jf: u8,
k: u32,
};
/// Seccomp filter program
pub const SeccompProg = extern struct {
len: u16,
filter: [*]const BpfInsn,
};
// BPF instruction macros
const BPF_LD = 0x00;
const BPF_W = 0x00;
const BPF_ABS = 0x20;
const BPF_JMP = 0x05;
const BPF_JEQ = 0x10;
const BPF_K = 0x00;
const BPF_RET = 0x06;
fn BPF_STMT(code: u16, k: u32) BpfInsn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn BPF_JUMP(code: u16, k: u32, jt: u8, jf: u8) BpfInsn {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
/// seccomp_data structure offset for syscall number
const SECCOMP_DATA_NR_OFFSET = 0;
const SECCOMP_DATA_ARCH_OFFSET = 4;
/// x86_64 audit architecture
const AUDIT_ARCH_X86_64 = 0xc000003e;
/// aarch64 audit architecture
const AUDIT_ARCH_AARCH64 = 0xc00000b7;
/// Create a seccomp filter that blocks dangerous syscalls
pub fn createSeccompFilter(allocator: Allocator) ![]const BpfInsn {
// Syscalls to block (dangerous for sandboxing)
const blocked_syscalls = [_]u32{
// Kernel module operations
175, // init_module
176, // delete_module
313, // finit_module
// System administration
169, // reboot
167, // swapon
168, // swapoff
// Virtualization
312, // kcmp
310, // process_vm_readv
311, // process_vm_writev
// Keyring operations (can leak info)
248, // add_key
249, // request_key
250, // keyctl
// Mount operations outside namespace (shouldn't work but block anyway)
// 165, // mount - needed for sandbox setup
// 166, // umount2 - needed for sandbox setup
// ptrace (process tracing)
101, // ptrace
// Namespace escape attempts
// 272, // unshare - needed for sandbox
// 308, // setns - could be used to escape
};
var filter = std.ArrayList(BpfInsn).init(allocator);
errdefer filter.deinit();
// Load architecture
try filter.append(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_ARCH_OFFSET));
// Check architecture (x86_64 or aarch64)
const arch = comptime if (builtin.cpu.arch == .x86_64) AUDIT_ARCH_X86_64 else AUDIT_ARCH_AARCH64;
try filter.append(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, arch, 1, 0));
try filter.append(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS));
// Load syscall number
try filter.append(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_NR_OFFSET));
// Block each dangerous syscall
for (blocked_syscalls) |syscall_nr| {
try filter.append(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, syscall_nr, 0, 1));
try filter.append(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 1)); // EPERM
}
// Allow all other syscalls
try filter.append(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
return filter.toOwnedSlice();
}
/// Apply seccomp filter to current process
pub fn applySeccompFilter(filter: []const BpfInsn) SyscallError!void {
// Must set no_new_privs before seccomp
_ = try prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
const prog = SeccompProg{
.len = @intCast(filter.len),
.filter = filter.ptr,
};
try seccomp(SECCOMP_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &prog);
}
// ============================================================================
// Sandbox Configuration
// ============================================================================
pub const SandboxConfig = struct {
/// Root filesystem path (will be lower layer)
rootfs: []const u8 = "/",
/// Working directory inside sandbox
workdir: []const u8 = "/",
/// Hostname inside sandbox
hostname: []const u8 = "sandbox",
/// UID inside sandbox
uid: u32 = 0,
/// GID inside sandbox
gid: u32 = 0,
/// Enable user namespace
user_ns: bool = true,
/// Enable mount namespace
mount_ns: bool = true,
/// Enable PID namespace
pid_ns: bool = true,
/// Enable network namespace (isolates network)
net_ns: bool = true,
/// Enable UTS namespace (isolates hostname)
uts_ns: bool = true,
/// Enable IPC namespace
ipc_ns: bool = true,
/// Enable seccomp filtering
seccomp: bool = true,
/// Paths to bind mount read-only
readonly_binds: []const []const u8 = &.{},
/// Paths to bind mount read-write
writable_binds: []const []const u8 = &.{},
/// Environment variables
env: []const [2][]const u8 = &.{},
pub fn getCloneFlags(self: *const SandboxConfig) u32 {
var flags: u32 = 0;
if (self.user_ns) flags |= CLONE_NEWUSER;
if (self.mount_ns) flags |= CLONE_NEWNS;
if (self.pid_ns) flags |= CLONE_NEWPID;
if (self.net_ns) flags |= CLONE_NEWNET;
if (self.uts_ns) flags |= CLONE_NEWUTS;
if (self.ipc_ns) flags |= CLONE_NEWIPC;
return flags;
}
};
// ============================================================================
// Sandbox Execution
// ============================================================================
pub const SandboxResult = struct {
exit_code: u8,
stdout: []const u8,
stderr: []const u8,
};
/// Child process setup after clone
fn sandboxChildSetup(config: *const SandboxConfig) !void {
// Setup mount namespace
if (config.mount_ns) {
try setupMountNamespace();
// Mount /proc
mountProc("/proc") catch {};
// Mount /tmp as tmpfs
mountTmpfs("/tmp", "size=64m,mode=1777") catch {};
}
// Setup UTS namespace (hostname)
if (config.uts_ns) {
sethostname(config.hostname) catch {};
}
// Apply seccomp filter
if (config.seccomp) {
const allocator = std.heap.page_allocator;
if (createSeccompFilter(allocator)) |filter| {
defer allocator.free(filter);
applySeccompFilter(filter) catch {};
} else |_| {}
}
// Change to working directory
std.posix.chdir(config.workdir) catch {};
}
/// Create and run a sandboxed process
pub fn runSandboxed(
allocator: Allocator,
config: *const SandboxConfig,
argv: []const []const u8,
) !SandboxResult {
_ = allocator;
_ = config;
_ = argv;
// For the full implementation, we need to:
// 1. Create pipes for stdout/stderr
// 2. fork() or clone() with namespace flags
// 3. In child: setup namespaces, exec
// 4. In parent: write UID/GID maps, wait for child
// This is a simplified version - full implementation would use clone()
return SandboxResult{
.exit_code = 0,
.stdout = "",
.stderr = "",
};
}
// ============================================================================
// Tests
// ============================================================================
test "unshare user namespace" {
// This test requires unprivileged user namespaces to be enabled
unshare(CLONE_NEWUSER) catch |err| {
if (err == error.PermissionDenied) {
// User namespaces not available, skip test
return;
}
return err;
};
// We're now in a new user namespace where we are root
const uid = linux.getuid();
_ = uid; // Would be 65534 (nobody) until we setup uid_map
}
test "create seccomp filter" {
const allocator = std.testing.allocator;
const filter = try createSeccompFilter(allocator);
defer allocator.free(filter);
// Should have at least architecture check + syscall checks + allow
try std.testing.expect(filter.len > 5);
}
test "BPF instructions" {
const stmt = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0);
try std.testing.expectEqual(@as(u16, BPF_LD | BPF_W | BPF_ABS), stmt.code);
try std.testing.expectEqual(@as(u32, 0), stmt.k);
const jump = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 100, 1, 2);
try std.testing.expectEqual(@as(u16, BPF_JMP | BPF_JEQ | BPF_K), jump.code);
try std.testing.expectEqual(@as(u32, 100), jump.k);
try std.testing.expectEqual(@as(u8, 1), jump.jt);
try std.testing.expectEqual(@as(u8, 2), jump.jf);
}

View File

@@ -1,607 +0,0 @@
//! Sandboxfile Parser
//!
//! A declarative spec for agent sandboxes. Sandboxfile defines the environment,
//! services, outputs, network access, and secrets for ephemeral agent environments.
//!
//! Example Sandboxfile:
//! ```
//! # Sandboxfile
//!
//! FROM host
//! WORKDIR .
//!
//! RUN bun install
//!
//! DEV PORT=3000 WATCH=src/** bun run dev
//! SERVICE db PORT=5432 docker compose up postgres
//! SERVICE redis PORT=6379 redis-server
//! TEST bun test
//!
//! OUTPUT src/
//! OUTPUT tests/
//! OUTPUT package.json
//!
//! LOGS logs/*
//!
//! NET registry.npmjs.org
//! NET api.stripe.com
//!
//! SECRET STRIPE_API_KEY
//! ```
const std = @import("std");
const bun = @import("bun");
const string = bun.string;
const strings = bun.strings;
const Allocator = std.mem.Allocator;
const logger = bun.logger;
pub const Sandboxfile = struct {
/// Base environment: "host" or an image name
from: ?[]const u8 = null,
/// Project root directory
workdir: ?[]const u8 = null,
/// Setup commands (run once per agent)
run_commands: std.ArrayListUnmanaged([]const u8) = .{},
/// Primary dev server (optional, supports PORT, WATCH)
dev: ?Process = null,
/// Background processes (required name, supports PORT, WATCH)
services: std.ArrayListUnmanaged(Service) = .{},
/// Verification commands (optional name)
tests: std.ArrayListUnmanaged(Process) = .{},
/// Files extracted from agent (everything else is ephemeral)
outputs: std.ArrayListUnmanaged([]const u8) = .{},
/// Log streams agent can tail
logs: std.ArrayListUnmanaged([]const u8) = .{},
/// Allowed external hosts (default deny-all, services implicitly allowed)
net: std.ArrayListUnmanaged([]const u8) = .{},
/// Env vars agent can use but not inspect
secrets: std.ArrayListUnmanaged([]const u8) = .{},
/// If true, agent should infer the lockfile from repo
infer: ?[]const u8 = null,
pub const Process = struct {
name: ?[]const u8 = null,
command: []const u8,
port: ?u16 = null,
watch: ?[]const u8 = null,
};
pub const Service = struct {
name: []const u8,
command: []const u8,
port: ?u16 = null,
watch: ?[]const u8 = null,
};
pub fn deinit(self: *Sandboxfile, allocator: Allocator) void {
self.run_commands.deinit(allocator);
self.services.deinit(allocator);
self.tests.deinit(allocator);
self.outputs.deinit(allocator);
self.logs.deinit(allocator);
self.net.deinit(allocator);
self.secrets.deinit(allocator);
}
pub fn format(
self: *const Sandboxfile,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
if (self.from) |from| {
try writer.print("FROM {s}\n", .{from});
}
if (self.workdir) |workdir| {
try writer.print("WORKDIR {s}\n", .{workdir});
}
if (self.infer) |infer| {
try writer.print("INFER {s}\n", .{infer});
}
for (self.run_commands.items) |cmd| {
try writer.print("RUN {s}\n", .{cmd});
}
if (self.dev) |dev| {
try writer.writeAll("DEV");
if (dev.name) |name| try writer.print(" {s}", .{name});
if (dev.port) |port| try writer.print(" PORT={d}", .{port});
if (dev.watch) |watch| try writer.print(" WATCH={s}", .{watch});
try writer.print(" {s}\n", .{dev.command});
}
for (self.services.items) |service| {
try writer.print("SERVICE {s}", .{service.name});
if (service.port) |port| try writer.print(" PORT={d}", .{port});
if (service.watch) |watch| try writer.print(" WATCH={s}", .{watch});
try writer.print(" {s}\n", .{service.command});
}
for (self.tests.items) |t| {
try writer.writeAll("TEST");
if (t.name) |name| try writer.print(" {s}", .{name});
if (t.port) |port| try writer.print(" PORT={d}", .{port});
if (t.watch) |watch| try writer.print(" WATCH={s}", .{watch});
try writer.print(" {s}\n", .{t.command});
}
for (self.outputs.items) |output| {
try writer.print("OUTPUT {s}\n", .{output});
}
for (self.logs.items) |log| {
try writer.print("LOGS {s}\n", .{log});
}
for (self.net.items) |host| {
try writer.print("NET {s}\n", .{host});
}
for (self.secrets.items) |secret| {
try writer.print("SECRET {s}\n", .{secret});
}
}
};
pub const Parser = struct {
source: logger.Source,
src: []const u8,
log: logger.Log,
allocator: Allocator,
result: Sandboxfile,
line_number: u32,
pub const Error = error{
InvalidSandboxfile,
OutOfMemory,
};
pub fn init(allocator: Allocator, path: []const u8, src: []const u8) Parser {
return .{
.log = logger.Log.init(allocator),
.src = src,
.source = logger.Source.initPathString(path, src),
.allocator = allocator,
.result = .{},
.line_number = 0,
};
}
pub fn deinit(self: *Parser) void {
self.log.deinit();
self.result.deinit(self.allocator);
}
fn addError(self: *Parser, comptime text: []const u8) Error {
self.log.addErrorOpts(text, .{
.source = &self.source,
.loc = .{ .start = @intCast(self.line_number) },
}) catch {};
return error.InvalidSandboxfile;
}
fn addErrorFmt(self: *Parser, comptime text: []const u8, args: anytype) Error {
self.log.addErrorFmtOpts(self.allocator, text, args, .{
.source = &self.source,
.loc = .{ .start = @intCast(self.line_number) },
}) catch {};
return error.InvalidSandboxfile;
}
pub fn parse(self: *Parser) Error!Sandboxfile {
var iter = std.mem.splitScalar(u8, self.src, '\n');
while (iter.next()) |line_raw| {
self.line_number += 1;
const line = std.mem.trim(u8, line_raw, " \t\r");
// Skip empty lines and comments
if (line.len == 0 or line[0] == '#') continue;
try self.parseLine(line);
}
return self.result;
}
fn parseLine(self: *Parser, line: []const u8) Error!void {
// Find the directive (first word)
const directive_end = std.mem.indexOfAny(u8, line, " \t") orelse line.len;
const directive = line[0..directive_end];
const rest = if (directive_end < line.len) std.mem.trimLeft(u8, line[directive_end..], " \t") else "";
if (strings.eqlComptime(directive, "FROM")) {
try self.parseFrom(rest);
} else if (strings.eqlComptime(directive, "WORKDIR")) {
try self.parseWorkdir(rest);
} else if (strings.eqlComptime(directive, "RUN")) {
try self.parseRun(rest);
} else if (strings.eqlComptime(directive, "DEV")) {
try self.parseDev(rest);
} else if (strings.eqlComptime(directive, "SERVICE")) {
try self.parseService(rest);
} else if (strings.eqlComptime(directive, "TEST")) {
try self.parseTest(rest);
} else if (strings.eqlComptime(directive, "OUTPUT")) {
try self.parseOutput(rest);
} else if (strings.eqlComptime(directive, "LOGS")) {
try self.parseLogs(rest);
} else if (strings.eqlComptime(directive, "NET")) {
try self.parseNet(rest);
} else if (strings.eqlComptime(directive, "SECRET")) {
try self.parseSecret(rest);
} else if (strings.eqlComptime(directive, "INFER")) {
try self.parseInfer(rest);
} else {
return self.addErrorFmt("Unknown directive: {s}", .{directive});
}
}
fn parseFrom(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("FROM requires an argument (e.g., 'host' or image name)");
}
if (self.result.from != null) {
return self.addError("Duplicate FROM directive");
}
self.result.from = rest;
}
fn parseWorkdir(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("WORKDIR requires a path argument");
}
if (self.result.workdir != null) {
return self.addError("Duplicate WORKDIR directive");
}
self.result.workdir = rest;
}
fn parseRun(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("RUN requires a command argument");
}
try self.result.run_commands.append(self.allocator, rest);
}
fn parseDev(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("DEV requires a command argument");
}
if (self.result.dev != null) {
return self.addError("Duplicate DEV directive (only one dev server allowed)");
}
self.result.dev = try self.parseProcess(rest, false);
}
fn parseService(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("SERVICE requires a name and command");
}
const process = try self.parseProcess(rest, true);
const name = process.name orelse {
return self.addError("SERVICE requires a name");
};
try self.result.services.append(self.allocator, .{
.name = name,
.command = process.command,
.port = process.port,
.watch = process.watch,
});
}
fn parseTest(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("TEST requires a command argument");
}
try self.result.tests.append(self.allocator, try self.parseProcess(rest, false));
}
fn parseOutput(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("OUTPUT requires a path argument");
}
try self.result.outputs.append(self.allocator, rest);
}
fn parseLogs(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("LOGS requires a path pattern argument");
}
try self.result.logs.append(self.allocator, rest);
}
fn parseNet(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("NET requires a hostname argument");
}
try self.result.net.append(self.allocator, rest);
}
fn parseSecret(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("SECRET requires an environment variable name");
}
// Validate secret name (should be valid env var name)
for (rest) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '_') {
return self.addError("SECRET name must be a valid environment variable name (alphanumeric and underscore only)");
}
}
try self.result.secrets.append(self.allocator, rest);
}
fn parseInfer(self: *Parser, rest: []const u8) Error!void {
if (rest.len == 0) {
return self.addError("INFER requires a pattern argument (e.g., '*')");
}
if (self.result.infer != null) {
return self.addError("Duplicate INFER directive");
}
self.result.infer = rest;
}
/// Parse a process definition with optional name, PORT=, WATCH= options and command
/// Format: [name] [PORT=N] [WATCH=pattern] command args...
fn parseProcess(self: *Parser, input: []const u8, require_name: bool) Error!Sandboxfile.Process {
var process = Sandboxfile.Process{ .command = "" };
var rest = input;
var has_name = false;
// Parse tokens until we hit the command
while (rest.len > 0) {
const token_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
const token = rest[0..token_end];
if (std.mem.startsWith(u8, token, "PORT=")) {
const port_str = token[5..];
process.port = std.fmt.parseInt(u16, port_str, 10) catch {
return self.addErrorFmt("Invalid PORT value: {s}", .{port_str});
};
} else if (std.mem.startsWith(u8, token, "WATCH=")) {
process.watch = token[6..];
} else if (!has_name and !require_name) {
// For DEV/TEST, first non-option token is the command
process.command = rest;
break;
} else if (!has_name) {
// First non-option token is the name
process.name = token;
has_name = true;
} else {
// Rest is the command
process.command = rest;
break;
}
// Move to next token
if (token_end >= rest.len) {
rest = "";
} else {
rest = std.mem.trimLeft(u8, rest[token_end..], " \t");
}
}
if (process.command.len == 0) {
return self.addError("Missing command in process definition");
}
return process;
}
/// Parse a Sandboxfile from a file path
pub fn parseFile(allocator: Allocator, path: []const u8) Error!Sandboxfile {
const file = std.fs.cwd().openFile(path, .{}) catch {
var p = Parser.init(allocator, path, "");
return p.addError("Could not open Sandboxfile");
};
defer file.close();
const src = file.readToEndAlloc(allocator, 1024 * 1024) catch {
var p = Parser.init(allocator, path, "");
return p.addError("Could not read Sandboxfile");
};
var parser = Parser.init(allocator, path, src);
return parser.parse();
}
/// Parse a Sandboxfile from a string
pub fn parseString(allocator: Allocator, src: []const u8) Error!Sandboxfile {
var parser = Parser.init(allocator, "<string>", src);
return parser.parse();
}
};
/// Validate a parsed Sandboxfile
pub fn validate(sandboxfile: *const Sandboxfile) !void {
// FROM is required
if (sandboxfile.from == null and sandboxfile.infer == null) {
return error.MissingFrom;
}
// WORKDIR is required (unless INFER)
if (sandboxfile.workdir == null and sandboxfile.infer == null) {
return error.MissingWorkdir;
}
}
test "parse basic sandboxfile" {
const allocator = std.testing.allocator;
const src =
\\# Sandboxfile
\\
\\FROM host
\\WORKDIR .
\\
\\RUN bun install
\\
\\DEV PORT=3000 WATCH=src/** bun run dev
\\SERVICE db PORT=5432 docker compose up postgres
\\SERVICE redis PORT=6379 redis-server
\\TEST bun test
\\
\\OUTPUT src/
\\OUTPUT tests/
\\OUTPUT package.json
\\
\\LOGS logs/*
\\
\\NET registry.npmjs.org
\\NET api.stripe.com
\\
\\SECRET STRIPE_API_KEY
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = try parser.parse();
try std.testing.expectEqualStrings("host", result.from.?);
try std.testing.expectEqualStrings(".", result.workdir.?);
try std.testing.expectEqual(@as(usize, 1), result.run_commands.items.len);
try std.testing.expectEqualStrings("bun install", result.run_commands.items[0]);
// DEV
try std.testing.expect(result.dev != null);
try std.testing.expectEqual(@as(u16, 3000), result.dev.?.port.?);
try std.testing.expectEqualStrings("src/**", result.dev.?.watch.?);
try std.testing.expectEqualStrings("bun run dev", result.dev.?.command);
// Services
try std.testing.expectEqual(@as(usize, 2), result.services.items.len);
try std.testing.expectEqualStrings("db", result.services.items[0].name);
try std.testing.expectEqual(@as(u16, 5432), result.services.items[0].port.?);
try std.testing.expectEqualStrings("docker compose up postgres", result.services.items[0].command);
try std.testing.expectEqualStrings("redis", result.services.items[1].name);
// Tests
try std.testing.expectEqual(@as(usize, 1), result.tests.items.len);
try std.testing.expectEqualStrings("bun test", result.tests.items[0].command);
// Outputs
try std.testing.expectEqual(@as(usize, 3), result.outputs.items.len);
try std.testing.expectEqualStrings("src/", result.outputs.items[0]);
try std.testing.expectEqualStrings("tests/", result.outputs.items[1]);
try std.testing.expectEqualStrings("package.json", result.outputs.items[2]);
// Logs
try std.testing.expectEqual(@as(usize, 1), result.logs.items.len);
try std.testing.expectEqualStrings("logs/*", result.logs.items[0]);
// Net
try std.testing.expectEqual(@as(usize, 2), result.net.items.len);
try std.testing.expectEqualStrings("registry.npmjs.org", result.net.items[0]);
try std.testing.expectEqualStrings("api.stripe.com", result.net.items[1]);
// Secrets
try std.testing.expectEqual(@as(usize, 1), result.secrets.items.len);
try std.testing.expectEqualStrings("STRIPE_API_KEY", result.secrets.items[0]);
}
test "parse shorthand sandboxfile" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\WORKDIR .
\\INFER *
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = try parser.parse();
try std.testing.expectEqualStrings("host", result.from.?);
try std.testing.expectEqualStrings(".", result.workdir.?);
try std.testing.expectEqualStrings("*", result.infer.?);
}
test "error on unknown directive" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\INVALID_DIRECTIVE foo
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = parser.parse();
try std.testing.expect(result == error.InvalidSandboxfile);
}
test "error on duplicate FROM" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\FROM ubuntu:22.04
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = parser.parse();
try std.testing.expect(result == error.InvalidSandboxfile);
}
test "error on service without name" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\WORKDIR .
\\SERVICE PORT=5432 docker compose up postgres
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = parser.parse();
try std.testing.expect(result == error.InvalidSandboxfile);
}
test "error on invalid secret name" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\WORKDIR .
\\SECRET invalid-secret-name
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = parser.parse();
try std.testing.expect(result == error.InvalidSandboxfile);
}
test "multiple RUN commands" {
const allocator = std.testing.allocator;
const src =
\\FROM host
\\WORKDIR .
\\RUN apt-get update
\\RUN apt-get install -y nodejs
\\RUN npm install
;
var parser = Parser.init(allocator, "Sandboxfile", src);
defer parser.deinit();
const result = try parser.parse();
try std.testing.expectEqual(@as(usize, 3), result.run_commands.items.len);
try std.testing.expectEqualStrings("apt-get update", result.run_commands.items[0]);
try std.testing.expectEqualStrings("apt-get install -y nodejs", result.run_commands.items[1]);
try std.testing.expectEqualStrings("npm install", result.run_commands.items[2]);
}

View File

@@ -178,6 +178,10 @@ pub const ShellSubprocess = struct {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// The shell never uses PTY directly for stdin
return Writable{ .ignore = {} };
},
}
}
switch (stdio) {
@@ -217,8 +221,12 @@ pub const ShellSubprocess = struct {
return Writable{ .ignore = {} };
},
.readable_stream => {
// The shell never uses this
@panic("Unimplemented stdin readable_stream");
// The shell never uses this - fall back to ignore
return Writable{ .ignore = {} };
},
.pty => {
// The shell never uses PTY directly - fall back to ignore
return Writable{ .ignore = {} };
},
}
}
@@ -369,6 +377,7 @@ pub const ShellSubprocess = struct {
},
.capture => Readable{ .pipe = PipeReader.create(event_loop, process, result, shellio, out_type) },
.readable_stream => Readable{ .ignore = {} }, // Shell doesn't use readable_stream
.pty => Readable{ .ignore = {} }, // Shell doesn't use PTY
};
}
@@ -391,6 +400,7 @@ pub const ShellSubprocess = struct {
},
.capture => Readable{ .pipe = PipeReader.create(event_loop, process, result, shellio, out_type) },
.readable_stream => Readable{ .ignore = {} }, // Shell doesn't use readable_stream
.pty => Readable{ .ignore = {} }, // Shell doesn't use PTY
};
}

View File

@@ -221,6 +221,7 @@ pub const Tag = enum(u8) {
mmap,
munmap,
open,
openpty,
pread,
pwrite,
read,
@@ -4277,6 +4278,69 @@ pub fn dlsymWithHandle(comptime Type: type, comptime name: [:0]const u8, comptim
return Wrapper.function;
}
// =============================================================================
// PTY (Pseudo-Terminal) Support
// =============================================================================
/// Result of opening a PTY pair
pub const PtyPair = struct {
master: bun.FileDescriptor,
slave: bun.FileDescriptor,
};
/// Window size structure for terminal dimensions
pub const WinSize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16 = 0,
ws_ypixel: u16 = 0,
};
/// Opens a pseudo-terminal pair (master and slave)
/// Returns the master and slave file descriptors
pub fn openpty(winsize: ?*const WinSize) Maybe(PtyPair) {
if (comptime Environment.isWindows) {
@compileError("PTY is not supported on Windows");
}
// Use openpty() from libc which handles all the PTY setup
// On Linux it's in libutil, on macOS it's in libc
var master_fd: c_int = undefined;
var slave_fd: c_int = undefined;
// openpty is provided by libutil on Linux, libc on macOS
// Zig's std.c already links libutil on Linux when needed
const openpty_fn = @extern(*const fn (
amaster: *c_int,
aslave: *c_int,
name: ?[*:0]u8,
termp: ?*const anyopaque,
winp: ?*const WinSize,
) callconv(.c) c_int, .{ .name = "openpty" });
const rc = openpty_fn(
&master_fd,
&slave_fd,
null, // name - we don't need the slave name
null, // termios - use defaults
winsize, // window size
);
log("openpty() = {d} (master={d}, slave={d})", .{ rc, master_fd, slave_fd });
if (rc != 0) {
return .{ .err = .{
.errno = @intCast(@intFromEnum(std.posix.errno(rc))),
.syscall = .openpty,
} };
}
return .{ .result = .{
.master = bun.FileDescriptor.fromNative(master_fd),
.slave = bun.FileDescriptor.fromNative(slave_fd),
} };
}
pub const umask = switch (Environment.os) {
else => bun.c.umask,
// Using the same typedef and define for `mode_t` and `umask` as node on windows.

View File

@@ -1,321 +0,0 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { tempDir } from "harness";
const isolatedSandbox = await import("../../../../packages/bun-sandbox/src/isolated-sandbox");
const { checkIsolationSupport, runIsolated, runIsolatedBwrap, runIsolatedUnshare, IsolatedSandbox } = isolatedSandbox;
const { parseSandboxfile } = await import("../../../../packages/bun-sandbox/src/index");
describe("Isolation Support Check", () => {
test("checkIsolationSupport returns valid object", async () => {
const support = await checkIsolationSupport();
expect(typeof support.bwrap).toBe("boolean");
expect(typeof support.unshare).toBe("boolean");
expect(typeof support.fuseOverlayfs).toBe("boolean");
expect(typeof support.userNamespaces).toBe("boolean");
console.log("Isolation support:", support);
});
});
describe("Isolated Sandbox", () => {
let isolationAvailable = false;
beforeAll(async () => {
const support = await checkIsolationSupport();
isolationAvailable = support.bwrap || (support.unshare && support.userNamespaces);
if (!isolationAvailable) {
console.warn("Skipping isolation tests - no isolation method available");
}
});
test("runs command in isolated environment", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
const result = await runIsolated(["echo", "hello from sandbox"], config, {
verbose: true,
});
expect(result.stdout.trim()).toBe("hello from sandbox");
expect(result.exitCode).toBe(0);
expect(result.success).toBe(true);
});
test("isolates network when NET is empty", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
// Try to access network - should fail with network isolation
const result = await runIsolated(
["sh", "-c", "curl -s --connect-timeout 1 http://example.com || echo 'network blocked'"],
config,
{
verbose: true,
},
);
// Either curl fails or network is blocked
if (result.stdout.includes("network blocked") || result.exitCode !== 0) {
// Network was blocked - good
expect(true).toBe(true);
} else {
// Network worked - isolation not active (fallback mode)
console.warn("Network isolation not active - running in fallback mode");
}
});
test("provides isolated PID namespace", async () => {
const support = await checkIsolationSupport();
if (!support.bwrap && !support.unshare) {
console.warn("Skipping PID namespace test - no isolation available");
return;
}
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
// In a PID namespace, our process should be PID 1 (or low number)
const result = await runIsolated(["sh", "-c", "echo $$"], config, {
verbose: true,
});
const pid = parseInt(result.stdout.trim(), 10);
// In isolated PID namespace, shell should get a low PID
// In non-isolated, it will be much higher
console.log("PID in sandbox:", pid);
expect(result.success).toBe(true);
});
test("provides isolated hostname", async () => {
const support = await checkIsolationSupport();
if (!support.bwrap && !support.unshare) {
console.warn("Skipping hostname test - no isolation available");
return;
}
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
const result = await runIsolated(["hostname"], config, {
verbose: true,
});
// With UTS namespace, hostname should be "sandbox" (our default)
// Without isolation, it will be the host's hostname
console.log("Hostname in sandbox:", result.stdout.trim());
expect(result.success).toBe(true);
});
test("passes environment variables", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
const result = await runIsolated(["sh", "-c", "echo $MY_VAR"], config, {
env: { MY_VAR: "test_value_123" },
});
expect(result.stdout.trim()).toBe("test_value_123");
expect(result.success).toBe(true);
});
test("passes secrets to sandbox", async () => {
// Set up a secret in the environment
process.env.TEST_SECRET_KEY = "super_secret_value";
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
SECRET TEST_SECRET_KEY
`);
const result = await runIsolated(["sh", "-c", "echo $TEST_SECRET_KEY"], config, {
verbose: true,
});
expect(result.stdout.trim()).toBe("super_secret_value");
expect(result.success).toBe(true);
// Clean up
delete process.env.TEST_SECRET_KEY;
});
test("returns non-zero exit code on failure", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
const result = await runIsolated(["sh", "-c", "exit 42"], config, {});
expect(result.exitCode).toBe(42);
expect(result.success).toBe(false);
});
test("captures stdout and stderr", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
const result = await runIsolated(["sh", "-c", 'echo "stdout message" && echo "stderr message" >&2'], config, {});
expect(result.stdout).toContain("stdout message");
expect(result.stderr).toContain("stderr message");
expect(result.success).toBe(true);
});
test("uses working directory from config", async () => {
using dir = tempDir("sandbox-workdir", {});
const config = parseSandboxfile(`
FROM host
WORKDIR ${dir}
`);
const result = await runIsolated(["pwd"], config, {
cwd: String(dir),
});
expect(result.stdout.trim()).toBe(String(dir));
expect(result.success).toBe(true);
});
});
describe("IsolatedSandbox Class", () => {
test("runs setup commands", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
RUN echo "setup 1"
RUN echo "setup 2"
`);
const sandbox = new IsolatedSandbox(config, { verbose: true });
const success = await sandbox.runSetup();
expect(success).toBe(true);
});
test("runs tests and reports results", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
TEST sh -c "echo test1 && exit 0"
TEST sh -c "echo test2 && exit 1"
TEST sh -c "echo test3 && exit 0"
`);
const sandbox = new IsolatedSandbox(config, { verbose: true });
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("full lifecycle with run()", async () => {
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
RUN echo "setting up"
TEST sh -c "exit 0"
`);
const sandbox = new IsolatedSandbox(config, { verbose: true });
const result = await sandbox.run();
expect(result.success).toBe(true);
expect(result.testResults?.passed).toBe(true);
});
test("loads and passes secrets", async () => {
process.env.SANDBOX_TEST_SECRET = "secret123";
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
SECRET SANDBOX_TEST_SECRET
RUN sh -c "echo $SANDBOX_TEST_SECRET"
`);
const sandbox = new IsolatedSandbox(config, { verbose: true });
sandbox.loadSecrets();
const success = await sandbox.runSetup();
expect(success).toBe(true);
delete process.env.SANDBOX_TEST_SECRET;
});
});
describe("Sandbox Security Properties", () => {
test("cannot see host processes (with PID namespace)", async () => {
const support = await checkIsolationSupport();
if (!support.bwrap) {
console.warn("Skipping - bwrap not available");
return;
}
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
// Try to list processes - with PID namespace, should only see sandbox processes
const result = await runIsolated(["sh", "-c", "ps aux 2>/dev/null | wc -l || echo 0"], config, {});
const processCount = parseInt(result.stdout.trim(), 10);
console.log("Process count in sandbox:", processCount);
// With PID namespace, should see very few processes (< 10)
// Without isolation, would see all host processes (potentially hundreds)
if (processCount > 0 && processCount < 20) {
expect(true).toBe(true); // PID namespace working
} else {
console.warn("PID namespace may not be fully isolated");
}
});
test("has isolated /tmp", async () => {
const support = await checkIsolationSupport();
if (!support.bwrap) {
console.warn("Skipping - bwrap not available");
return;
}
// Create a file in host /tmp
const marker = `sandbox-test-${Date.now()}`;
await Bun.write(`/tmp/${marker}`, "host file");
const config = parseSandboxfile(`
FROM host
WORKDIR /tmp
`);
// Try to read the file from sandbox
const result = await runIsolated(["sh", "-c", `cat /tmp/${marker} 2>/dev/null || echo "not found"`], config, {});
// With tmpfs on /tmp, the file should not be visible
if (result.stdout.trim() === "not found") {
expect(true).toBe(true); // /tmp is isolated
} else {
console.warn("/tmp may not be fully isolated");
}
// Cleanup
await Bun.file(`/tmp/${marker}`).delete();
});
});

View File

@@ -1,356 +0,0 @@
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<void>) | 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();
});
});

View File

@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test";
// Test the Sandboxfile parser implementation
// These tests verify the TypeScript/JavaScript interface to the Sandboxfile parser
describe("Sandboxfile Parser", () => {
test("parses basic sandboxfile", () => {
const src = `# Sandboxfile
FROM host
WORKDIR .
RUN bun install
DEV PORT=3000 WATCH=src/** bun run dev
SERVICE db PORT=5432 docker compose up postgres
SERVICE redis PORT=6379 redis-server
TEST bun test
OUTPUT src/
OUTPUT tests/
OUTPUT package.json
LOGS logs/*
NET registry.npmjs.org
NET api.stripe.com
SECRET STRIPE_API_KEY
`;
const result = parseSandboxfile(src);
expect(result.from).toBe("host");
expect(result.workdir).toBe(".");
expect(result.runCommands).toEqual(["bun install"]);
expect(result.dev).toEqual({
command: "bun run dev",
port: 3000,
watch: "src/**",
});
expect(result.services).toHaveLength(2);
expect(result.services[0]).toEqual({
name: "db",
command: "docker compose up postgres",
port: 5432,
});
expect(result.services[1]).toEqual({
name: "redis",
command: "redis-server",
port: 6379,
});
expect(result.tests).toHaveLength(1);
expect(result.tests[0]).toEqual({
command: "bun test",
});
expect(result.outputs).toEqual(["src/", "tests/", "package.json"]);
expect(result.logs).toEqual(["logs/*"]);
expect(result.net).toEqual(["registry.npmjs.org", "api.stripe.com"]);
expect(result.secrets).toEqual(["STRIPE_API_KEY"]);
});
test("parses shorthand sandboxfile with INFER", () => {
const src = `FROM host
WORKDIR .
INFER *
`;
const result = parseSandboxfile(src);
expect(result.from).toBe("host");
expect(result.workdir).toBe(".");
expect(result.infer).toBe("*");
});
test("handles multiple RUN commands", () => {
const src = `FROM host
WORKDIR .
RUN apt-get update
RUN apt-get install -y nodejs
RUN npm install
`;
const result = parseSandboxfile(src);
expect(result.runCommands).toEqual(["apt-get update", "apt-get install -y nodejs", "npm install"]);
});
test("errors on unknown directive", () => {
const src = `FROM host
INVALID_DIRECTIVE foo
`;
expect(() => parseSandboxfile(src)).toThrow(/Unknown directive/);
});
test("errors on duplicate FROM", () => {
const src = `FROM host
FROM ubuntu:22.04
`;
expect(() => parseSandboxfile(src)).toThrow(/Duplicate FROM/);
});
test("service with PORT before name uses first word as name", () => {
// When PORT= comes before the name, the first non-option word becomes the name
const src = `FROM host
WORKDIR .
SERVICE PORT=5432 docker compose up postgres
`;
const result = parseSandboxfile(src);
expect(result.services[0]).toEqual({
name: "docker",
command: "compose up postgres",
port: 5432,
});
});
test("errors on service with only options and no name/command", () => {
const src = `FROM host
WORKDIR .
SERVICE PORT=5432
`;
expect(() => parseSandboxfile(src)).toThrow(/Missing command/);
});
test("errors on invalid secret name", () => {
const src = `FROM host
WORKDIR .
SECRET invalid-secret-name
`;
expect(() => parseSandboxfile(src)).toThrow(/valid environment variable/);
});
test("ignores comments and empty lines", () => {
const src = `# This is a comment
FROM host
# Another comment
WORKDIR .
# More comments
`;
const result = parseSandboxfile(src);
expect(result.from).toBe("host");
expect(result.workdir).toBe(".");
});
test("handles DEV without optional params", () => {
const src = `FROM host
WORKDIR .
DEV bun run dev
`;
const result = parseSandboxfile(src);
expect(result.dev).toEqual({
command: "bun run dev",
});
});
test("handles TEST with PORT option", () => {
const src = `FROM host
WORKDIR .
TEST bun test --filter unit
TEST PORT=3001 bun test --filter integration
`;
const result = parseSandboxfile(src);
expect(result.tests).toHaveLength(2);
// TEST doesn't require a name, so first non-option word starts the command
expect(result.tests[0].command).toBe("bun test --filter unit");
expect(result.tests[1].port).toBe(3001);
expect(result.tests[1].command).toBe("bun test --filter integration");
});
test("parses complex service definitions", () => {
const src = `FROM host
WORKDIR .
SERVICE api PORT=8080 WATCH=src/api/** node server.js
SERVICE worker WATCH=src/worker/** node worker.js
SERVICE db PORT=5432 docker-compose up -d postgres
`;
const result = parseSandboxfile(src);
expect(result.services).toHaveLength(3);
expect(result.services[0]).toEqual({
name: "api",
command: "node server.js",
port: 8080,
watch: "src/api/**",
});
expect(result.services[1]).toEqual({
name: "worker",
command: "node worker.js",
watch: "src/worker/**",
});
expect(result.services[2]).toEqual({
name: "db",
command: "docker-compose up -d postgres",
port: 5432,
});
});
});
// TypeScript interface definitions for Sandboxfile
interface SandboxProcess {
name?: string;
command: string;
port?: number;
watch?: string;
}
interface SandboxService {
name: string;
command: string;
port?: number;
watch?: string;
}
interface Sandboxfile {
from?: string;
workdir?: string;
runCommands: string[];
dev?: SandboxProcess;
services: SandboxService[];
tests: SandboxProcess[];
outputs: string[];
logs: string[];
net: string[];
secrets: string[];
infer?: string;
}
// Pure TypeScript implementation of the Sandboxfile parser
// This mirrors the Zig implementation for testing purposes
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("FROM requires an argument");
if (result.from !== undefined) throw new Error("Duplicate FROM directive");
result.from = rest;
break;
case "WORKDIR":
if (!rest) throw new Error("WORKDIR requires a path argument");
if (result.workdir !== undefined) throw new Error("Duplicate WORKDIR directive");
result.workdir = rest;
break;
case "RUN":
if (!rest) throw new Error("RUN requires a command argument");
result.runCommands.push(rest);
break;
case "DEV":
if (!rest) throw new Error("DEV requires a command argument");
if (result.dev !== undefined) throw new Error("Duplicate DEV directive");
result.dev = parseProcess(rest, false);
break;
case "SERVICE": {
if (!rest) throw new Error("SERVICE requires a name and command");
const proc = parseProcess(rest, true);
if (!proc.name) throw new Error("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("TEST requires a command argument");
result.tests.push(parseProcess(rest, false));
break;
case "OUTPUT":
if (!rest) throw new Error("OUTPUT requires a path argument");
result.outputs.push(rest);
break;
case "LOGS":
if (!rest) throw new Error("LOGS requires a path pattern argument");
result.logs.push(rest);
break;
case "NET":
if (!rest) throw new Error("NET requires a hostname argument");
result.net.push(rest);
break;
case "SECRET":
if (!rest) throw new Error("SECRET requires an environment variable name");
if (!/^[A-Za-z0-9_]+$/.test(rest)) {
throw new Error("SECRET name must be a valid environment variable name");
}
result.secrets.push(rest);
break;
case "INFER":
if (!rest) throw new Error("INFER requires a pattern argument");
if (result.infer !== undefined) throw new Error("Duplicate INFER directive");
result.infer = rest;
break;
default:
throw new Error(`Unknown directive: ${directive}`);
}
}
return result;
}
function parseProcess(input: string, requireName: boolean): 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(`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("Missing command in process definition");
}
// Clean up undefined properties
const cleaned: SandboxProcess = { command: result.command };
if (result.name !== undefined) cleaned.name = result.name;
if (result.port !== undefined) cleaned.port = result.port;
if (result.watch !== undefined) cleaned.watch = result.watch;
return cleaned;
}

View File

@@ -1,311 +0,0 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { bunExe, tempDir } from "harness";
/**
* Tests for the Zig-based Linux sandbox implementation.
*
* The sandbox uses:
* - User namespaces for unprivileged operation
* - Mount namespaces with overlayfs
* - PID namespaces for process isolation
* - Network namespaces for network isolation
* - UTS namespaces for hostname isolation
* - Seccomp BPF for syscall filtering
*/
describe("Zig Linux Sandbox", () => {
let isLinux = false;
beforeAll(() => {
isLinux = process.platform === "linux";
if (!isLinux) {
console.warn("Skipping Zig sandbox tests - not on Linux");
}
});
test("sandbox module compiles", async () => {
// The sandbox module should be compiled into bun
// We test this by running a simple command that would use it
using dir = tempDir("zig-sandbox-test", {
"test.ts": `
// This would import the sandbox module when available
console.log("sandbox module test");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "run", "test.ts"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("sandbox module test");
});
test("can check for user namespace support", async () => {
if (!isLinux) return;
// Check if unprivileged user namespaces are enabled
try {
const file = Bun.file("/proc/sys/kernel/unprivileged_userns_clone");
if (await file.exists()) {
const content = await file.text();
const enabled = content.trim() === "1";
console.log("Unprivileged user namespaces:", enabled ? "enabled" : "disabled");
} else {
console.log("Unprivileged user namespaces: sysctl not present (probably enabled)");
}
} catch {
console.log("Could not check user namespace support");
}
});
test("can create temp directories for overlay", async () => {
if (!isLinux) return;
using dir = tempDir("overlay-test", {});
const fs = await import("node:fs/promises");
const path = await import("node:path");
// Create overlay structure
const upperDir = path.join(String(dir), "upper");
const workDir = path.join(String(dir), "work");
const mergedDir = path.join(String(dir), "merged");
await fs.mkdir(upperDir);
await fs.mkdir(workDir);
await fs.mkdir(mergedDir);
// Verify directories exist
const upperStat = await fs.stat(upperDir);
const workStat = await fs.stat(workDir);
const mergedStat = await fs.stat(mergedDir);
expect(upperStat.isDirectory()).toBe(true);
expect(workStat.isDirectory()).toBe(true);
expect(mergedStat.isDirectory()).toBe(true);
});
test("unshare requires specific kernel config", async () => {
if (!isLinux) return;
// Try to unshare user namespace
const proc = Bun.spawn({
cmd: ["unshare", "--user", "--map-root-user", "id"],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode === 0) {
// User namespace worked
expect(stdout).toContain("uid=0");
console.log("User namespace: working");
} else {
// User namespace not available
console.log("User namespace: not available -", stderr.trim());
}
});
test("seccomp is available", async () => {
if (!isLinux) return;
// Check if seccomp is available
try {
const file = Bun.file("/proc/sys/kernel/seccomp/actions_avail");
if (await file.exists()) {
const content = await file.text();
console.log("Seccomp actions:", content.trim());
expect(content).toContain("allow");
}
} catch {
// Older kernel format
try {
const file = Bun.file("/proc/self/status");
const content = await file.text();
const seccompLine = content.split("\n").find(l => l.startsWith("Seccomp:"));
if (seccompLine) {
console.log("Seccomp status:", seccompLine);
}
} catch {
console.log("Could not check seccomp support");
}
}
});
test("mount namespace test with unshare", async () => {
if (!isLinux) return;
// Test mount namespace isolation
const proc = Bun.spawn({
cmd: ["unshare", "--user", "--map-root-user", "--mount", "sh", "-c", "mount -t tmpfs tmpfs /tmp && echo mounted"],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode === 0) {
expect(stdout.trim()).toBe("mounted");
console.log("Mount namespace: working");
} else {
console.log("Mount namespace: not available -", stderr.trim());
}
});
test("PID namespace test", async () => {
if (!isLinux) return;
// Test PID namespace isolation
const proc = Bun.spawn({
cmd: ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc", "sh", "-c", "echo $$"],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode === 0) {
const pid = parseInt(stdout.trim(), 10);
// In PID namespace, shell should get PID 1
expect(pid).toBe(1);
console.log("PID namespace: working (PID =", pid, ")");
} else {
console.log("PID namespace: not available -", stderr.trim());
}
});
test("network namespace test", async () => {
if (!isLinux) return;
// Test network namespace isolation
const proc = Bun.spawn({
cmd: [
"unshare",
"--user",
"--map-root-user",
"--net",
"sh",
"-c",
"ip link show 2>/dev/null | grep -c '^[0-9]' || echo 1",
],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode === 0) {
const linkCount = parseInt(stdout.trim(), 10);
// In network namespace, should only see loopback (1 interface)
console.log("Network namespace: working (interfaces =", linkCount, ")");
expect(linkCount).toBeLessThanOrEqual(2); // lo and maybe sit0
} else {
console.log("Network namespace: not available -", stderr.trim());
}
});
test("UTS namespace (hostname) test", async () => {
if (!isLinux) return;
// Test UTS namespace isolation
const proc = Bun.spawn({
cmd: ["unshare", "--user", "--map-root-user", "--uts", "sh", "-c", "hostname sandbox-test && hostname"],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode === 0) {
expect(stdout.trim()).toBe("sandbox-test");
console.log("UTS namespace: working");
} else {
console.log("UTS namespace: not available -", stderr.trim());
}
});
});
describe("Sandbox Isolation Properties", () => {
const isLinux = process.platform === "linux";
test("full isolation with all namespaces", async () => {
if (!isLinux) return;
// Test full isolation combining all namespaces
const proc = Bun.spawn({
cmd: [
"unshare",
"--user",
"--map-root-user",
"--mount",
"--pid",
"--fork",
"--net",
"--uts",
"--ipc",
"sh",
"-c",
`
hostname sandbox
echo "hostname: $(hostname)"
echo "pid: $$"
echo "uid: $(id -u)"
mount -t proc proc /proc 2>/dev/null || true
echo "mounts: ok"
`,
],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
console.log("Full isolation output:", stdout);
if (stderr) console.log("Full isolation stderr:", stderr);
if (exitCode === 0) {
expect(stdout).toContain("hostname: sandbox");
expect(stdout).toContain("pid: 1");
expect(stdout).toContain("uid: 0");
console.log("Full namespace isolation: working");
} else {
console.log("Full namespace isolation: not available");
}
});
});

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
describe("Bun.spawn with PTY", () => {
test("stdout: 'pty' makes process.stdout.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdout.isTTY)"],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("true");
expect(exitCode).toBe(0);
});
test("stderr: 'pty' makes process.stderr.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.error(process.stderr.isTTY)"],
stdin: "ignore",
stdout: "inherit",
stderr: "pty",
env: bunEnv,
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr.trim()).toBe("true");
expect(exitCode).toBe(0);
});
test("stdin: 'pty' only makes process.stdin.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdin.isTTY, process.stdout.isTTY)"],
stdin: "pty",
stdout: "pipe",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
// stdin is PTY (true), stdout is pipe (undefined - isTTY is undefined when not a TTY)
expect(stdout.trim()).toBe("true undefined");
expect(exitCode).toBe(0);
});
test("stdin: 'pty' and stdout: 'pty' makes both isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('isTTY:', process.stdout.isTTY, process.stdin.isTTY)"],
stdin: "pty",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("isTTY: true true");
expect(exitCode).toBe(0);
});
test("stdin: 'pty', stdout: 'pty', stderr: 'pty' all share the same PTY", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('isTTY:', process.stdout.isTTY, process.stdin.isTTY, process.stderr.isTTY)"],
stdin: "pty",
stdout: "pty",
stderr: "pty",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("isTTY: true true true");
expect(exitCode).toBe(0);
});
test("PTY object syntax with custom dimensions", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdout.columns, process.stdout.rows)"],
stdin: "ignore",
stdout: { type: "pty", width: 120, height: 40 },
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("120 40");
expect(exitCode).toBe(0);
});
test("PTY enables colored output from programs that detect TTY", async () => {
// Use a simple inline script that outputs ANSI colors when stdout is a TTY
const proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
if (process.stdout.isTTY) {
console.log("\\x1b[31mred\\x1b[0m");
} else {
console.log("no-color");
}
`,
],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
// Should contain ANSI escape codes
expect(stdout).toContain("\x1b[31m");
expect(stdout).toContain("red");
expect(exitCode).toBe(0);
});
test("multiple concurrent PTY spawns work correctly", async () => {
const procs = Array.from({ length: 5 }, (_, i) =>
Bun.spawn({
cmd: [bunExe(), "-e", `console.log("proc${i}:", process.stdout.isTTY)`],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
}),
);
const results = await Promise.all(
procs.map(async (proc, i) => {
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
return { stdout: stdout.trim(), exitCode, index: i };
}),
);
for (const result of results) {
expect(result.stdout).toBe(`proc${result.index}: true`);
expect(result.exitCode).toBe(0);
}
});
});
describe("Bun.spawnSync with PTY", () => {
test("throws error when PTY is used with spawnSync", () => {
expect(() => {
Bun.spawnSync({
cmd: ["echo", "test"],
stdout: "pty",
});
}).toThrow("PTY is not supported with spawnSync");
});
test("throws error when PTY object syntax is used with spawnSync", () => {
expect(() => {
Bun.spawnSync({
cmd: ["echo", "test"],
stdout: { type: "pty", width: 80, height: 24 },
});
}).toThrow("PTY is not supported with spawnSync");
});
});