diff --git a/.changeset/standalone-binary-arbitrary-dockerfile.md b/.changeset/standalone-binary-arbitrary-dockerfile.md new file mode 100644 index 00000000..246ab05c --- /dev/null +++ b/.changeset/standalone-binary-arbitrary-dockerfile.md @@ -0,0 +1,21 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add standalone binary support for arbitrary Dockerfiles + +Users can now add sandbox capabilities to any Docker image: + +```dockerfile +FROM your-image:tag + +COPY --from=cloudflare/sandbox:VERSION /container-server/sandbox /sandbox +ENTRYPOINT ["/sandbox"] + +# Optional: run your own startup command +CMD ["/your-entrypoint.sh"] +``` + +The `/sandbox` binary starts the HTTP API server, then executes any CMD as a child process with signal forwarding. + +Includes backwards compatibility for existing custom startup scripts. diff --git a/.github/templates/pr-preview-comment.md b/.github/templates/pr-preview-comment.md new file mode 100644 index 00000000..902a2fe7 --- /dev/null +++ b/.github/templates/pr-preview-comment.md @@ -0,0 +1,46 @@ +### 🐳 Docker Images Published + +**Default:** + +```dockerfile +FROM {{DEFAULT_TAG}} +``` + +**With Python:** + +```dockerfile +FROM {{PYTHON_TAG}} +``` + +**With OpenCode:** + +```dockerfile +FROM {{OPENCODE_TAG}} +``` + +**Version:** `{{VERSION}}` + +Use the `-python` variant if you need Python code execution, or `-opencode` for the variant with OpenCode AI coding agent pre-installed. + +--- + +### 📦 Standalone Binary + +**For arbitrary Dockerfiles:** + +```dockerfile +COPY --from={{DEFAULT_TAG}} /container-server/sandbox /sandbox +ENTRYPOINT ["/sandbox"] +``` + +**Download via GitHub CLI:** + +```bash +gh run download {{RUN_ID}} -n sandbox-binary +``` + +**Extract from Docker:** + +```bash +docker run --rm {{DEFAULT_TAG}} cat /container-server/sandbox > sandbox && chmod +x sandbox +``` diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 9f048ea7..abf6831a 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -125,6 +125,21 @@ jobs: build-args: | SANDBOX_VERSION=${{ steps.package-version.outputs.version }} + - name: Extract standalone binary from Docker image + run: | + VERSION=${{ steps.package-version.outputs.version }} + CONTAINER_ID=$(docker create --platform linux/amd64 cloudflare/sandbox:$VERSION) + docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64 + docker rm $CONTAINER_ID + chmod +x ./sandbox-linux-x64 + + - name: Upload standalone binary as artifact + uses: actions/upload-artifact@v4 + with: + name: sandbox-binary + path: ./sandbox-linux-x64 + retention-days: 30 + - name: Publish to pkg.pr.new run: npx pkg-pr-new publish './packages/sandbox' @@ -132,11 +147,20 @@ jobs: uses: actions/github-script@v7 with: script: | + const fs = require('fs'); const version = '${{ steps.package-version.outputs.version }}'; + const runId = '${{ github.run_id }}'; const defaultTag = `cloudflare/sandbox:${version}`; const pythonTag = `cloudflare/sandbox:${version}-python`; const opencodeTag = `cloudflare/sandbox:${version}-opencode`; - const body = `### 🐳 Docker Images Published\n\n**Default (no Python):**\n\`\`\`dockerfile\nFROM ${defaultTag}\n\`\`\`\n\n**With Python:**\n\`\`\`dockerfile\nFROM ${pythonTag}\n\`\`\`\n\n**With OpenCode:**\n\`\`\`dockerfile\nFROM ${opencodeTag}\n\`\`\`\n\n**Version:** \`${version}\`\n\nUse the \`-python\` variant for Python code execution, or \`-opencode\` for the OpenCode AI coding agent.`; + + const template = fs.readFileSync('.github/templates/pr-preview-comment.md', 'utf8'); + const body = template + .replace(/\{\{VERSION\}\}/g, version) + .replace(/\{\{RUN_ID\}\}/g, runId) + .replace(/\{\{DEFAULT_TAG\}\}/g, defaultTag) + .replace(/\{\{PYTHON_TAG\}\}/g, pythonTag) + .replace(/\{\{OPENCODE_TAG\}\}/g, opencodeTag); // Find existing comment const { data: comments } = await github.rest.issues.listComments({ diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 555cbd26..4a4555ae 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -116,7 +116,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build test worker Docker images (base + python + opencode) + - name: Build test worker Docker images (base + python + opencode + standalone) run: | VERSION=${{ needs.unit-tests.outputs.version || '0.0.0' }} # Build base image (no Python) - used by Sandbox binding @@ -128,6 +128,16 @@ jobs: # Build opencode image - used by SandboxOpencode binding docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \ --build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode . + # Build standalone image (arbitrary base with binary) - used by SandboxStandalone binding + # Use regex to replace any version number, avoiding hardcoded version mismatch + # Build from test-worker directory so COPY startup-test.sh works + cd tests/e2e/test-worker + sed -E "s|cloudflare/sandbox-test:[0-9]+\.[0-9]+\.[0-9]+|cloudflare/sandbox-test:$VERSION|g" \ + Dockerfile.standalone > Dockerfile.standalone.tmp + docker build -f Dockerfile.standalone.tmp --platform linux/amd64 \ + -t cloudflare/sandbox-test:$VERSION-standalone . + rm Dockerfile.standalone.tmp + cd ../../.. # Deploy test worker using official Cloudflare action - name: Deploy test worker diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1026b4a2..3580aaa8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build test worker Docker images (base + python + opencode) + - name: Build test worker Docker images (base + python + opencode + standalone) run: | VERSION=${{ needs.unit-tests.outputs.version }} # Build base image (no Python) - used by Sandbox binding @@ -120,6 +120,16 @@ jobs: # Build opencode image - used by SandboxOpencode binding docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \ --build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode . + # Build standalone image (arbitrary base with binary) - used by SandboxStandalone binding + # Use regex to replace any version number, avoiding hardcoded version mismatch + # Build from test-worker directory so COPY startup-test.sh works + cd tests/e2e/test-worker + sed -E "s|cloudflare/sandbox-test:[0-9]+\.[0-9]+\.[0-9]+|cloudflare/sandbox-test:$VERSION|g" \ + Dockerfile.standalone > Dockerfile.standalone.tmp + docker build -f Dockerfile.standalone.tmp --platform linux/amd64 \ + -t cloudflare/sandbox-test:$VERSION-standalone . + rm Dockerfile.standalone.tmp + cd ../../.. - name: Deploy test worker uses: cloudflare/wrangler-action@v3 @@ -230,6 +240,15 @@ jobs: build-args: | SANDBOX_VERSION=${{ needs.unit-tests.outputs.version }} + - name: Extract standalone binary from Docker image + run: | + VERSION=${{ needs.unit-tests.outputs.version }} + CONTAINER_ID=$(docker create --platform linux/amd64 cloudflare/sandbox:$VERSION) + docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64 + docker rm $CONTAINER_ID + file ./sandbox-linux-x64 + ls -la ./sandbox-linux-x64 + - id: changesets uses: changesets/action@v1 with: @@ -238,3 +257,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true + + - name: Upload standalone binary to GitHub release + if: steps.changesets.outputs.published == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=${{ needs.unit-tests.outputs.version }} + sha256sum sandbox-linux-x64 > sandbox-linux-x64.sha256 + # Tag format matches changesets: @cloudflare/sandbox@VERSION + gh release upload "@cloudflare/sandbox@${VERSION}" ./sandbox-linux-x64 ./sandbox-linux-x64.sha256 --clobber diff --git a/docs/STANDALONE_BINARY.md b/docs/STANDALONE_BINARY.md new file mode 100644 index 00000000..d7b5d851 --- /dev/null +++ b/docs/STANDALONE_BINARY.md @@ -0,0 +1,55 @@ +# Standalone Binary Pattern + +Add Cloudflare Sandbox capabilities to any Docker image by copying the `/sandbox` binary. + +## Basic Usage + +```dockerfile +FROM node:20-slim + +# Required: install 'file' for SDK file operations +RUN apt-get update && apt-get install -y --no-install-recommends file \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=cloudflare/sandbox:latest /container-server/sandbox /sandbox + +ENTRYPOINT ["/sandbox"] +CMD ["/your-startup-script.sh"] # Optional: runs after server starts +``` + +## How CMD Passthrough Works + +The `/sandbox` binary acts as a supervisor: + +1. Starts HTTP API server on port 3000 +2. Spawns your CMD as a child process +3. Forwards SIGTERM/SIGINT to the child +4. If CMD exits 0, server keeps running; non-zero exits terminate the container + +## Required Dependencies + +| Dependency | Required For | Install Command | +| ---------- | ----------------------------------------------- | ---------------------- | +| `file` | `readFile()`, `writeFile()`, any file operation | `apt-get install file` | +| `git` | `gitCheckout()`, `listBranches()` | `apt-get install git` | +| `bash` | Everything (core requirement) | Usually pre-installed | + +Most base images (node:slim, python:slim, ubuntu) include everything except `file` and `git`. + +## What Works Without Extra Dependencies + +- `exec()` - Run shell commands +- `startProcess()` - Background processes +- `exposePort()` - Expose services + +## Troubleshooting + +**"Failed to detect MIME type"** - Install `file` + +**"git: command not found"** - Install `git` (only needed for git operations) + +**Commands hang** - Ensure `bash` exists at `/bin/bash` + +## Note on Code Interpreter + +`runCode()` requires Python/Node executors not included in the standalone binary. Use the official sandbox images for code interpreter support. diff --git a/packages/sandbox-container/build.ts b/packages/sandbox-container/build.ts index ce54746c..01f2dee0 100644 --- a/packages/sandbox-container/build.ts +++ b/packages/sandbox-container/build.ts @@ -1,6 +1,9 @@ /** * Build script for sandbox-container using Bun's bundler. - * Bundles the container server and JS executor into standalone files. + * Produces: + * - dist/sandbox: Standalone binary for /sandbox entrypoint + * - dist/index.js: Legacy JS bundle for backwards compatibility + * - dist/runtime/executors/javascript/node_executor.js: JS executor */ import { mkdir } from 'node:fs/promises'; @@ -8,32 +11,34 @@ import { mkdir } from 'node:fs/promises'; // Ensure output directories exist await mkdir('dist/runtime/executors/javascript', { recursive: true }); -console.log('Building container server bundle...'); +// Build legacy JS bundle for backwards compatibility +// Users with custom startup scripts that call `bun /container-server/dist/index.js` need this +console.log('Building legacy JS bundle...'); -// Bundle the main container server -const serverResult = await Bun.build({ - entrypoints: ['src/index.ts'], +const legacyResult = await Bun.build({ + entrypoints: ['src/legacy.ts'], outdir: 'dist', target: 'bun', minify: true, - sourcemap: 'external' + sourcemap: 'external', + naming: 'index.js' }); -if (!serverResult.success) { - console.error('Server build failed:'); - for (const log of serverResult.logs) { +if (!legacyResult.success) { + console.error('Legacy bundle build failed:'); + for (const log of legacyResult.logs) { console.error(log); } process.exit(1); } console.log( - ` dist/index.js (${(serverResult.outputs[0].size / 1024).toFixed(1)} KB)` + ` dist/index.js (${(legacyResult.outputs[0].size / 1024).toFixed(1)} KB)` ); console.log('Building JavaScript executor...'); -// Bundle the JS executor (runs on Node, not Bun) +// Bundle the JS executor (runs on Node or Bun for code interpreter) const executorResult = await Bun.build({ entrypoints: ['src/runtime/executors/javascript/node_executor.ts'], outdir: 'dist/runtime/executors/javascript', @@ -54,4 +59,34 @@ console.log( ` dist/runtime/executors/javascript/node_executor.js (${(executorResult.outputs[0].size / 1024).toFixed(1)} KB)` ); +console.log('Building standalone binary...'); + +// Compile standalone binary (bundles Bun runtime) +const proc = Bun.spawn( + [ + 'bun', + 'build', + 'src/main.ts', + '--compile', + '--target=bun-linux-x64', + '--outfile=dist/sandbox', + '--minify' + ], + { + cwd: process.cwd(), + stdio: ['inherit', 'inherit', 'inherit'] + } +); + +const exitCode = await proc.exited; +if (exitCode !== 0) { + console.error('Standalone binary build failed'); + process.exit(1); +} + +// Get file size +const file = Bun.file('dist/sandbox'); +const size = file.size; +console.log(` dist/sandbox (${(size / 1024 / 1024).toFixed(1)} MB)`); + console.log('Build complete!'); diff --git a/packages/sandbox-container/src/index.ts b/packages/sandbox-container/src/index.ts deleted file mode 100644 index f5d8dd49..00000000 --- a/packages/sandbox-container/src/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createLogger } from '@repo/shared'; -import { serve } from 'bun'; -import { Container } from './core/container'; -import { Router } from './core/router'; -import { setupRoutes } from './routes/setup'; - -// Create module-level logger for server lifecycle events -const logger = createLogger({ component: 'container' }); - -async function createApplication(): Promise<{ - fetch: (req: Request) => Promise; -}> { - // Initialize dependency injection container - const container = new Container(); - await container.initialize(); - - // Create and configure router - const router = new Router(logger); - - // Add global CORS middleware - router.use(container.get('corsMiddleware')); - - // Setup all application routes - setupRoutes(router, container); - - return { - fetch: (req: Request) => router.route(req) - }; -} - -// Initialize the application -const app = await createApplication(); - -// Start the Bun server -const server = serve({ - idleTimeout: 255, - fetch: app.fetch, - hostname: '0.0.0.0', - port: 3000, - // Enhanced WebSocket placeholder for future streaming features - websocket: { - async message() { - // WebSocket functionality can be added here in the future - } - } -}); - -logger.info('Container server started', { - port: server.port, - hostname: '0.0.0.0' -}); - -// Graceful shutdown handling -process.on('SIGTERM', async () => { - logger.info('Received SIGTERM, shutting down gracefully'); - - // Get services for cleanup - const container = new Container(); - if (container.isInitialized()) { - try { - // Cleanup services with proper typing - const processService = container.get('processService'); - const portService = container.get('portService'); - - // Cleanup processes (asynchronous - kills all running processes) - await processService.destroy(); - - // Cleanup ports (synchronous) - portService.destroy(); - - logger.info('Services cleaned up successfully'); - } catch (error) { - logger.error( - 'Error during cleanup', - error instanceof Error ? error : new Error(String(error)) - ); - } - } - - process.exit(0); -}); - -process.on('SIGINT', async () => { - logger.info('Received SIGINT, shutting down gracefully'); - process.emit('SIGTERM'); -}); diff --git a/packages/sandbox-container/src/legacy.ts b/packages/sandbox-container/src/legacy.ts new file mode 100644 index 00000000..7bec42f1 --- /dev/null +++ b/packages/sandbox-container/src/legacy.ts @@ -0,0 +1,35 @@ +/** + * Legacy entry point for backwards compatibility. + * + * This file is bundled to dist/index.js for users who have custom startup + * scripts that explicitly run `bun /container-server/dist/index.js`. + * + * Behavior: + * - If SANDBOX_STARTED is set (meaning /sandbox binary already started the server), + * this is a no-op and exits cleanly. + * - Otherwise, starts the server normally (legacy behavior for users not using + * the binary entrypoint). + */ + +import { createLogger } from '@repo/shared'; +import { registerShutdownHandlers, startServer } from './server'; + +const logger = createLogger({ component: 'container' }); + +if (process.env.SANDBOX_STARTED === 'true') { + logger.info( + 'Server already running (SANDBOX_STARTED=true). Legacy entry is a no-op.' + ); +} else { + logger.info('Starting server via legacy entry point'); + + startServer() + .then(({ port, cleanup }) => { + registerShutdownHandlers(cleanup); + logger.info('Server started via legacy entry', { port }); + }) + .catch((err) => { + logger.error('Failed to start server via legacy entry', err); + process.exit(1); + }); +} diff --git a/packages/sandbox-container/src/main.ts b/packages/sandbox-container/src/main.ts new file mode 100644 index 00000000..c3bc40a4 --- /dev/null +++ b/packages/sandbox-container/src/main.ts @@ -0,0 +1,93 @@ +/** + * Standalone binary entrypoint with CMD passthrough support. + * + * This file is the entry point when compiled with `bun build --compile`. + * It starts the HTTP API server, then executes any user-provided CMD. + * + * Usage: + * ENTRYPOINT ["/sandbox"] + * CMD ["python", "app.py"] # Optional - passed to this entrypoint + * + * Modes: + * - Server-only (no CMD): Runs API server with standard shutdown handlers + * - Supervisor (with CMD): Forwards signals to child, exits when child exits + */ + +import { type ChildProcess, spawn } from 'node:child_process'; +import { constants } from 'node:os'; +import { createLogger } from '@repo/shared'; +import { registerShutdownHandlers, startServer } from './server'; + +const logger = createLogger({ component: 'container' }); + +async function main(): Promise { + const userCmd = process.argv.slice(2); + + logger.info('Starting sandbox entrypoint', { + userCmd: userCmd.length > 0 ? userCmd : '(none)', + version: process.env.SANDBOX_VERSION || 'unknown' + }); + + const { cleanup } = await startServer(); + + if (userCmd.length === 0) { + logger.info('No user command provided, running API server only'); + registerShutdownHandlers(cleanup); + return; + } + + // Supervisor mode: manage child process lifecycle + + // Backwards compatibility: prevents double-startup when user scripts call + // `bun /container-server/dist/index.js` + process.env.SANDBOX_STARTED = 'true'; + + let child: ChildProcess | null = null; + + // Register signal handlers before spawn to avoid race window + const forwardSignal = (signal: NodeJS.Signals) => { + if (child && !child.killed) { + logger.info('Forwarding signal to child', { signal }); + child.kill(signal); + } + }; + process.on('SIGTERM', () => forwardSignal('SIGTERM')); + process.on('SIGINT', () => forwardSignal('SIGINT')); + + logger.info('Spawning user command', { + command: userCmd[0], + args: userCmd.slice(1) + }); + + child = spawn(userCmd[0], userCmd.slice(1), { + stdio: 'inherit', + env: process.env, + shell: false + }); + + child.on('error', (err) => { + logger.error('Failed to spawn user command', err, { command: userCmd[0] }); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + logger.info('User command killed by signal', { signal }); + // Unix convention: 128 + signal number + const signalNum = constants.signals[signal] ?? 15; + process.exit(128 + signalNum); + } else if (code !== 0) { + logger.info('User command failed', { exitCode: code }); + process.exit(code ?? 1); + } else { + logger.info( + 'User command completed successfully, server continues running' + ); + } + }); +} + +main().catch((err) => { + logger.error('Entrypoint failed', err); + process.exit(1); +}); diff --git a/packages/sandbox-container/src/runtime/process-pool.ts b/packages/sandbox-container/src/runtime/process-pool.ts index 6b270ce7..a5ec64eb 100644 --- a/packages/sandbox-container/src/runtime/process-pool.ts +++ b/packages/sandbox-container/src/runtime/process-pool.ts @@ -16,6 +16,31 @@ const PYTHON_AVAILABLE = (() => { } })(); +// Prefer Node.js for user code execution: better npm compatibility and more +// predictable vm module behavior. Bun works as a fallback but may have subtle +// differences in edge cases. +const JS_RUNTIME: 'node' | 'bun' | null = (() => { + try { + const nodeResult = spawnSync('node', ['--version'], { timeout: 5000 }); + if (nodeResult.status === 0) { + return 'node'; + } + } catch { + // Node.js not available, try Bun + } + + try { + const bunResult = spawnSync('bun', ['--version'], { timeout: 5000 }); + if (bunResult.status === 0) { + return 'bun'; + } + } catch { + // Bun not available either + } + + return null; +})(); + export type InterpreterLanguage = 'python' | 'javascript' | 'typescript'; export interface InterpreterProcess { @@ -245,6 +270,25 @@ export class ProcessPoolManager { }; } + // Check if a JavaScript runtime is available for JavaScript/TypeScript + if ( + (language === 'javascript' || language === 'typescript') && + !JS_RUNTIME + ) { + return { + stdout: '', + stderr: `JavaScript runtime not available. JavaScript/TypeScript code execution requires Node.js or Bun to be installed in the container.`, + success: false, + executionId: randomUUID(), + outputs: [], + error: { + type: ErrorCode.JAVASCRIPT_NOT_AVAILABLE, + message: + 'JavaScript runtime (Node.js or Bun) not available in this container' + } + }; + } + if (sessionId) { // Context execution: Get dedicated executor and lock on it const contextExecutor = this.contextExecutors.get(sessionId); @@ -354,14 +398,15 @@ export class ProcessPoolManager { ]; break; case 'javascript': - command = 'node'; + // Use detected JS runtime (node or bun) + command = JS_RUNTIME!; args = [ '/container-server/dist/runtime/executors/javascript/node_executor.js' ]; break; case 'typescript': // TypeScript is transpiled in execute(), so use the same JS executor - command = 'node'; + command = JS_RUNTIME!; args = [ '/container-server/dist/runtime/executors/javascript/node_executor.js' ]; @@ -569,6 +614,16 @@ export class ProcessPoolManager { ); } + // Check if a JavaScript runtime is available before trying to create a context + if ( + (language === 'javascript' || language === 'typescript') && + !JS_RUNTIME + ) { + throw new Error( + `JavaScript runtime not available. JavaScript/TypeScript code execution requires Node.js or Bun to be installed in the container.` + ); + } + const mutex = this.poolLocks.get(language)!; await mutex.runExclusive(async () => { const available = this.availableExecutors.get(language) || []; diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts new file mode 100644 index 00000000..77292782 --- /dev/null +++ b/packages/sandbox-container/src/server.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@repo/shared'; +import { serve } from 'bun'; +import { Container } from './core/container'; +import { Router } from './core/router'; +import { setupRoutes } from './routes/setup'; + +const logger = createLogger({ component: 'container' }); +const SERVER_PORT = 3000; + +export interface ServerInstance { + port: number; + cleanup: () => Promise; +} + +async function createApplication(): Promise<{ + fetch: (req: Request) => Promise; + container: Container; +}> { + const container = new Container(); + await container.initialize(); + + const router = new Router(logger); + router.use(container.get('corsMiddleware')); + setupRoutes(router, container); + + return { + fetch: (req: Request) => router.route(req), + container + }; +} + +/** + * Start the HTTP API server on port 3000. + * Returns server info and a cleanup function for graceful shutdown. + */ +export async function startServer(): Promise { + const app = await createApplication(); + + serve({ + idleTimeout: 255, + fetch: app.fetch, + hostname: '0.0.0.0', + port: SERVER_PORT, + websocket: { + async message() { + // WebSocket placeholder for future streaming features + } + } + }); + + logger.info('Container server started', { + port: SERVER_PORT, + hostname: '0.0.0.0' + }); + + return { + port: SERVER_PORT, + cleanup: async () => { + if (!app.container.isInitialized()) return; + + try { + const processService = app.container.get('processService'); + const portService = app.container.get('portService'); + + await processService.destroy(); + portService.destroy(); + + logger.info('Services cleaned up successfully'); + } catch (error) { + logger.error( + 'Error during cleanup', + error instanceof Error ? error : new Error(String(error)) + ); + } + } + }; +} + +let shutdownRegistered = false; + +/** + * Register graceful shutdown handlers for SIGTERM and SIGINT. + * Safe to call multiple times - handlers are only registered once. + */ +export function registerShutdownHandlers(cleanup: () => Promise): void { + if (shutdownRegistered) return; + shutdownRegistered = true; + + process.on('SIGTERM', async () => { + logger.info('Received SIGTERM, shutting down gracefully'); + await cleanup(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + logger.info('Received SIGINT, shutting down gracefully'); + process.emit('SIGTERM'); + }); +} diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index 66fa2db5..2caaf741 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -1,17 +1,15 @@ -# Sandbox container image with full development environment +# Sandbox container images (default and python variants) # Multi-stage build optimized for Turborepo monorepo # ============================================================================ # Stage 1: Prune monorepo to only include necessary packages # ============================================================================ -FROM node:20-alpine AS pruner +FROM node:20-slim AS pruner WORKDIR /app -# Install Turborepo globally RUN npm install -g turbo -# Copy entire monorepo COPY . . # Prune to only @repo/sandbox-container and its dependencies (@repo/shared) @@ -20,13 +18,15 @@ RUN turbo prune @repo/sandbox-container --docker # ============================================================================ # Stage 2: Install dependencies and build packages +# Using glibc-based images (not Alpine) so the standalone binary works on +# standard Linux distributions (Debian, Ubuntu, RHEL, etc.) # ============================================================================ -FROM node:20-alpine AS builder +FROM node:20-slim AS builder WORKDIR /app -# Install Bun runtime (needed for sandbox-container build script) -COPY --from=oven/bun:1-alpine /usr/local/bin/bun /usr/local/bin/bun +# Install Bun runtime (glibc version for glibc-compatible standalone binary) +COPY --from=oven/bun:1 /usr/local/bin/bun /usr/local/bin/bun # Copy pruned lockfile and package.json files (for Docker layer caching) COPY --from=pruner /app/out/json/ . @@ -36,11 +36,10 @@ COPY --from=pruner /app/out/package-lock.json ./package-lock.json RUN --mount=type=cache,target=/root/.npm \ CI=true npm ci -# Copy pruned source code COPY --from=pruner /app/out/full/ . # Build all packages (Turborepo handles dependency order automatically) -# This builds @repo/shared first, then @repo/sandbox-container +# This builds @repo/shared first, then @repo/sandbox-container (including standalone binary) RUN npx turbo run build # ============================================================================ @@ -96,7 +95,6 @@ ARG SANDBOX_VERSION=unknown # Prevent interactive prompts during package installation ENV DEBIAN_FRONTEND=noninteractive -# Set the sandbox version as an environment variable for version checking ENV SANDBOX_VERSION=${SANDBOX_VERSION} # Install runtime packages and S3FS-FUSE for bucket mounting @@ -123,27 +121,24 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ # Install Bun runtime from official image COPY --from=oven/bun:1 /usr/local/bin/bun /usr/local/bin/bun -# Set up runtime container server directory -WORKDIR /container-server +# Copy standalone binary +COPY --from=builder /app/packages/sandbox-container/dist/sandbox /container-server/sandbox -# Copy bundled container server (all dependencies inlined, no node_modules needed) -COPY --from=builder /app/packages/sandbox-container/dist/index.js ./dist/ -COPY --from=builder /app/packages/sandbox-container/dist/index.js.map ./dist/ +# Set up container server directory for executors +WORKDIR /container-server -# Copy bundled JavaScript executor +# Copy bundled JavaScript executor (runs on Node or Bun for code interpreter) COPY --from=builder /app/packages/sandbox-container/dist/runtime/executors/javascript/node_executor.js ./dist/runtime/executors/javascript/ -COPY --from=builder /app/packages/sandbox-container/dist/runtime/executors/javascript/node_executor.js.map ./dist/runtime/executors/javascript/ -# Create clean workspace directory for user code +# Copy legacy JS bundle for backwards compatibility +# Users with custom startup scripts that call `bun /container-server/dist/index.js` need this +COPY --from=builder /app/packages/sandbox-container/dist/index.js ./dist/ + RUN mkdir -p /workspace # Expose the application port (3000 for control) EXPOSE 3000 -# Copy and make startup script executable -COPY packages/sandbox/startup.sh ./ -RUN chmod +x startup.sh - # ============================================================================ # Stage 5a: Default image - lean, no Python # ============================================================================ @@ -154,7 +149,7 @@ ENV PYTHON_POOL_MIN_SIZE=0 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -CMD ["/container-server/startup.sh"] +ENTRYPOINT ["/container-server/sandbox"] # ============================================================================ # Stage 5b: Python image - full, with Python + data science packages @@ -186,7 +181,7 @@ ENV PYTHON_POOL_MIN_SIZE=3 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -CMD ["/container-server/startup.sh"] +ENTRYPOINT ["/container-server/sandbox"] # ============================================================================ # Stage 5c: OpenCode image - with OpenCode CLI for AI coding agent @@ -210,4 +205,4 @@ ENV TYPESCRIPT_POOL_MIN_SIZE=3 # Expose OpenCode server port (in addition to 3000 from runtime-base) EXPOSE 4096 -CMD ["/container-server/startup.sh"] +ENTRYPOINT ["/container-server/sandbox"] diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 68391488..c876c77d 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -39,7 +39,7 @@ "check": "biome check && npm run typecheck", "fix": "biome check --fix && npm run typecheck", "typecheck": "tsc --noEmit", - "docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --target default --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version . && docker build -f packages/sandbox/Dockerfile --target python --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version-python . && docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version-opencode .", + "docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --target default --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version . && docker build -f packages/sandbox/Dockerfile --target python --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version-python . && docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version-opencode . && cd tests/e2e/test-worker && sed -E \"s|cloudflare/sandbox-test:[0-9]+\\.[0-9]+\\.[0-9]+|cloudflare/sandbox-test:$npm_package_version|g\" Dockerfile.standalone > Dockerfile.standalone.tmp && docker build -f Dockerfile.standalone.tmp --platform linux/amd64 -t cloudflare/sandbox-test:$npm_package_version-standalone . && rm Dockerfile.standalone.tmp", "test": "vitest run --config vitest.config.ts \"$@\"", "test:e2e": "cd ../../tests/e2e/test-worker && ./generate-config.sh && cd ../../.. && vitest run --config vitest.e2e.config.ts \"$@\"" }, diff --git a/packages/sandbox/startup.sh b/packages/sandbox/startup.sh deleted file mode 100644 index 403be057..00000000 --- a/packages/sandbox/startup.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -exec bun /container-server/dist/index.js diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts index d54ffa96..9dbc12a7 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -99,6 +99,7 @@ export const ErrorCode = { // Code Interpreter Errors (501) - Feature not available in image variant PYTHON_NOT_AVAILABLE: 'PYTHON_NOT_AVAILABLE', + JAVASCRIPT_NOT_AVAILABLE: 'JAVASCRIPT_NOT_AVAILABLE', // OpenCode Errors (503) OPENCODE_STARTUP_FAILED: 'OPENCODE_STARTUP_FAILED', diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 9d43da6f..68b4e000 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -50,6 +50,7 @@ export const ERROR_STATUS_MAP: Record = { // 501 Not Implemented (feature not available in image variant) [ErrorCode.PYTHON_NOT_AVAILABLE]: 501, + [ErrorCode.JAVASCRIPT_NOT_AVAILABLE]: 501, // 503 Service Unavailable [ErrorCode.INTERPRETER_NOT_READY]: 503, diff --git a/scripts/cleanup-test-deployment.sh b/scripts/cleanup-test-deployment.sh index 046e6df8..95fdd277 100755 --- a/scripts/cleanup-test-deployment.sh +++ b/scripts/cleanup-test-deployment.sh @@ -11,6 +11,7 @@ set -e # - : Base image container (no Python, default) # - -python: Python image container # - -opencode: OpenCode image container +# - -standalone: Standalone binary on arbitrary base image # # Environment variables required: # - CLOUDFLARE_API_TOKEN @@ -35,6 +36,7 @@ RAW_OUTPUT=$(npx wrangler containers list 2>&1) CONTAINER_ID="" CONTAINER_PYTHON_ID="" CONTAINER_OPENCODE_ID="" +CONTAINER_STANDALONE_ID="" # Check if output looks like JSON (starts with '[') if echo "$RAW_OUTPUT" | grep -q '^\['; then @@ -44,6 +46,7 @@ if echo "$RAW_OUTPUT" | grep -q '^\['; then CONTAINER_ID=$(echo "$RAW_OUTPUT" | jq -r ".[] | select(.name==\"$WORKER_NAME\") | .id" 2>/dev/null || echo "") CONTAINER_PYTHON_ID=$(echo "$RAW_OUTPUT" | jq -r ".[] | select(.name==\"$WORKER_NAME-python\") | .id" 2>/dev/null || echo "") CONTAINER_OPENCODE_ID=$(echo "$RAW_OUTPUT" | jq -r ".[] | select(.name==\"$WORKER_NAME-opencode\") | .id" 2>/dev/null || echo "") + CONTAINER_STANDALONE_ID=$(echo "$RAW_OUTPUT" | jq -r ".[] | select(.name==\"$WORKER_NAME-standalone\") | .id" 2>/dev/null || echo "") if [ -n "$CONTAINER_ID" ]; then echo "✓ Found base container: $CONTAINER_ID" @@ -63,7 +66,13 @@ if echo "$RAW_OUTPUT" | grep -q '^\['; then echo "⚠️ No opencode container found for $WORKER_NAME-opencode" fi - if [ -z "$CONTAINER_ID" ] && [ -z "$CONTAINER_PYTHON_ID" ] && [ -z "$CONTAINER_OPENCODE_ID" ]; then + if [ -n "$CONTAINER_STANDALONE_ID" ]; then + echo "✓ Found standalone container: $CONTAINER_STANDALONE_ID" + else + echo "⚠️ No standalone container found for $WORKER_NAME-standalone" + fi + + if [ -z "$CONTAINER_ID" ] && [ -z "$CONTAINER_PYTHON_ID" ] && [ -z "$CONTAINER_OPENCODE_ID" ] && [ -z "$CONTAINER_STANDALONE_ID" ]; then echo "Available containers:" echo "$RAW_OUTPUT" | jq -r '.[].name' 2>/dev/null || echo "(unable to parse container names)" fi @@ -112,6 +121,7 @@ CLEANUP_FAILED=false delete_container "$CONTAINER_ID" "base" || CLEANUP_FAILED=true delete_container "$CONTAINER_PYTHON_ID" "python" || CLEANUP_FAILED=true delete_container "$CONTAINER_OPENCODE_ID" "opencode" || CLEANUP_FAILED=true +delete_container "$CONTAINER_STANDALONE_ID" "standalone" || CLEANUP_FAILED=true if [ "$CLEANUP_FAILED" = true ]; then echo "=== Cleanup completed with errors ===" diff --git a/tests/e2e/helpers/global-sandbox.ts b/tests/e2e/helpers/global-sandbox.ts index a1061e73..efd0ce8b 100644 --- a/tests/e2e/helpers/global-sandbox.ts +++ b/tests/e2e/helpers/global-sandbox.ts @@ -50,6 +50,8 @@ export interface SharedSandbox { createPythonHeaders: (sessionId?: string) => Record; /** Create headers for OpenCode image sandbox (with OpenCode CLI) */ createOpencodeHeaders: (sessionId?: string) => Record; + /** Create headers for standalone binary sandbox (arbitrary base image) */ + createStandaloneHeaders: (sessionId?: string) => Record; /** Generate a unique file path prefix for test isolation */ uniquePath: (prefix: string) => string; } @@ -143,6 +145,16 @@ async function initializeSharedSandbox(): Promise { } return headers; }, + createStandaloneHeaders: (sessionId?: string) => { + const headers: Record = { + ...baseHeaders, + 'X-Sandbox-Type': 'standalone' + }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, uniquePath: (prefix: string) => `/workspace/test-${randomUUID().slice(0, 8)}/${prefix}` }; @@ -209,6 +221,16 @@ async function initializeSharedSandbox(): Promise { } return headers; }, + createStandaloneHeaders: (sessionId?: string) => { + const headers: Record = { + ...baseHeaders, + 'X-Sandbox-Type': 'standalone' + }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, uniquePath: (prefix: string) => `/workspace/test-${randomUUID().slice(0, 8)}/${prefix}` }; diff --git a/tests/e2e/standalone-binary-workflow.test.ts b/tests/e2e/standalone-binary-workflow.test.ts new file mode 100644 index 00000000..371c37e7 --- /dev/null +++ b/tests/e2e/standalone-binary-workflow.test.ts @@ -0,0 +1,54 @@ +/** + * Standalone Binary Workflow Test + * + * Tests the standalone binary pattern where users copy the /sandbox binary + * into an arbitrary Docker image (node:20-slim in this case). + * + * Key behaviors validated: + * - Binary works on non-Ubuntu base images + * - CMD passthrough executes user-defined startup scripts + * - Server continues running after CMD exits + */ + +import { describe, test, expect, beforeAll } from 'vitest'; +import { + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; +import type { ExecResult, ReadFileResult } from '@repo/shared'; + +describe('Standalone Binary Workflow', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createStandaloneHeaders(createUniqueSession()); + }, 120000); + + test('binary works on arbitrary base image', async () => { + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "ok"' }) + }); + + expect(response.status).toBe(200); + const result = (await response.json()) as ExecResult; + expect(result.exitCode).toBe(0); + }); + + test('CMD passthrough executes startup script', async () => { + // startup-test.sh writes a marker file; its existence proves CMD ran + const response = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ path: '/tmp/startup-marker.txt' }) + }); + + expect(response.status).toBe(200); + const result = (await response.json()) as ReadFileResult; + expect(result.content).toMatch(/^startup-\d+$/); + }); +}); diff --git a/tests/e2e/test-worker/Dockerfile.standalone b/tests/e2e/test-worker/Dockerfile.standalone new file mode 100644 index 00000000..4a157c91 --- /dev/null +++ b/tests/e2e/test-worker/Dockerfile.standalone @@ -0,0 +1,26 @@ +# Test the standalone binary pattern with an arbitrary base image +# This validates that users can add sandbox capabilities to any Docker image +FROM node:20-slim + +# Install dependencies required by the SDK +# - file: MIME type detection for file operations +# - git: git clone/checkout operations +RUN apt-get update && apt-get install -y --no-install-recommends \ + file \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy standalone binary from sandbox image +COPY --from=cloudflare/sandbox-test:0.6.4 /container-server/sandbox /sandbox + +# Copy startup script for CMD passthrough testing +COPY startup-test.sh /startup-test.sh +RUN chmod +x /startup-test.sh + +WORKDIR /workspace + +EXPOSE 3000 8080 + +# Standard Docker pattern: ENTRYPOINT is the executable, CMD is passed as arguments +ENTRYPOINT ["/sandbox"] +CMD ["/startup-test.sh"] diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index ec05fc55..8c8da351 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -4,12 +4,13 @@ * Exposes SDK methods via HTTP endpoints for E2E testing. * Supports both default sessions (implicit) and explicit sessions via X-Session-Id header. * - * Three sandbox types are available: + * Sandbox types available: * - Sandbox: Base image without Python (default, lean image) * - SandboxPython: Full image with Python (for code interpreter tests) * - SandboxOpencode: Image with OpenCode CLI (for OpenCode integration tests) + * - SandboxStandalone: Standalone binary on arbitrary base image (for binary pattern tests) * - * Use X-Sandbox-Type header to select: 'python' for SandboxPython, 'opencode' for SandboxOpencode, anything else for Sandbox + * Use X-Sandbox-Type header to select: 'python', 'opencode', 'standalone', or default */ import { Sandbox, getSandbox, proxyToSandbox } from '@cloudflare/sandbox'; import type { @@ -31,11 +32,13 @@ import type { export { Sandbox }; export { Sandbox as SandboxPython }; export { Sandbox as SandboxOpencode }; +export { Sandbox as SandboxStandalone }; interface Env { Sandbox: DurableObjectNamespace; SandboxPython: DurableObjectNamespace; SandboxOpencode: DurableObjectNamespace; + SandboxStandalone: DurableObjectNamespace; TEST_BUCKET: R2Bucket; // R2 credentials for bucket mounting tests CLOUDFLARE_ACCOUNT_ID?: string; @@ -76,6 +79,8 @@ export default { sandboxNamespace = env.SandboxPython; } else if (sandboxType === 'opencode') { sandboxNamespace = env.SandboxOpencode; + } else if (sandboxType === 'standalone') { + sandboxNamespace = env.SandboxStandalone; } else { sandboxNamespace = env.Sandbox; } diff --git a/tests/e2e/test-worker/startup-test.sh b/tests/e2e/test-worker/startup-test.sh new file mode 100644 index 00000000..9ea3641a --- /dev/null +++ b/tests/e2e/test-worker/startup-test.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Startup script for testing CMD passthrough +# This script runs as the CMD, proving the entrypoint forwards execution correctly + +set -e + +MARKER_FILE="/tmp/startup-marker.txt" +TIMESTAMP=$(date +%s) + +# Write marker file with timestamp to prove execution +echo "startup-${TIMESTAMP}" > "${MARKER_FILE}" + +# Log to stdout (can be verified in container logs) +echo "Startup script executed at ${TIMESTAMP}" +echo "Marker file written to ${MARKER_FILE}" + +# Exit 0 - the sandbox server should continue running after this +exit 0 diff --git a/tests/e2e/test-worker/wrangler.template.jsonc b/tests/e2e/test-worker/wrangler.template.jsonc index 13e44d0e..e873cf50 100644 --- a/tests/e2e/test-worker/wrangler.template.jsonc +++ b/tests/e2e/test-worker/wrangler.template.jsonc @@ -28,6 +28,11 @@ "class_name": "SandboxOpencode", "image": "./Dockerfile.opencode", "name": "{{CONTAINER_NAME}}-opencode" + }, + { + "class_name": "SandboxStandalone", + "image": "./Dockerfile.standalone", + "name": "{{CONTAINER_NAME}}-standalone" } ], @@ -44,6 +49,10 @@ { "class_name": "SandboxOpencode", "name": "SandboxOpencode" + }, + { + "class_name": "SandboxStandalone", + "name": "SandboxStandalone" } ] }, @@ -56,6 +65,10 @@ { "new_sqlite_classes": ["SandboxOpencode"], "tag": "v3" + }, + { + "new_sqlite_classes": ["SandboxStandalone"], + "tag": "v4" } ],