From 7408c0743bbad304bbfcf076b7c78a712e6ad643 Mon Sep 17 00:00:00 2001 From: Naresh Date: Fri, 5 Dec 2025 15:20:01 +0000 Subject: [PATCH 1/8] Add standalone binary for arbitrary Dockerfile support Compiles sandbox-container to a self-contained binary at /sandbox that can be copied into any Docker image. Includes backwards compatibility for existing startup scripts via legacy JS bundle. --- .../standalone-binary-arbitrary-dockerfile.md | 21 ++++ .github/templates/pr-preview-comment.md | 40 +++++++ .github/workflows/pkg-pr-new.yml | 25 ++++- .github/workflows/release.yml | 21 ++++ packages/sandbox-container/build.ts | 57 ++++++++-- packages/sandbox-container/src/index.ts | 86 -------------- packages/sandbox-container/src/legacy.ts | 39 +++++++ packages/sandbox-container/src/main.ts | 102 +++++++++++++++++ .../src/runtime/process-pool.ts | 57 +++++++++- packages/sandbox-container/src/server.ts | 105 ++++++++++++++++++ packages/sandbox/Dockerfile | 33 +++--- packages/sandbox/startup.sh | 3 - packages/shared/src/errors/codes.ts | 1 + packages/shared/src/errors/status-map.ts | 1 + 14 files changed, 468 insertions(+), 123 deletions(-) create mode 100644 .changeset/standalone-binary-arbitrary-dockerfile.md create mode 100644 .github/templates/pr-preview-comment.md delete mode 100644 packages/sandbox-container/src/index.ts create mode 100644 packages/sandbox-container/src/legacy.ts create mode 100644 packages/sandbox-container/src/main.ts create mode 100644 packages/sandbox-container/src/server.ts delete mode 100644 packages/sandbox/startup.sh diff --git a/.changeset/standalone-binary-arbitrary-dockerfile.md b/.changeset/standalone-binary-arbitrary-dockerfile.md new file mode 100644 index 00000000..e81c5e5e --- /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 /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..1831783f --- /dev/null +++ b/.github/templates/pr-preview-comment.md @@ -0,0 +1,40 @@ +### 🐳 Docker Images Published + +**Default (no Python):** + +```dockerfile +FROM {{DEFAULT_TAG}} +``` + +**With Python:** + +```dockerfile +FROM {{PYTHON_TAG}} +``` + +**Version:** `{{VERSION}}` + +Use the `-python` variant if you need Python code execution. + +--- + +### 📦 Standalone Binary + +**For arbitrary Dockerfiles:** + +```dockerfile +COPY --from={{DEFAULT_TAG}} /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 /sandbox > sandbox && chmod +x sandbox +``` diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 2b1162ce..1c80a9ac 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -109,6 +109,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 cloudflare/sandbox:$VERSION) + docker cp $CONTAINER_ID:/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' @@ -116,10 +131,18 @@ 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 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**Version:** \`${version}\`\n\nUse the \`-python\` variant if you need Python code execution.`; + + 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); // Find existing comment const { data: comments } = await github.rest.issues.listComments({ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcbbc84b..93c07c8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -204,6 +204,18 @@ jobs: build-args: | SANDBOX_VERSION=${{ needs.unit-tests.outputs.version }} + - name: Extract standalone binary from Docker image + run: | + VERSION=${{ needs.unit-tests.outputs.version }} + # Create container from the default image (has the binary at /sandbox) + CONTAINER_ID=$(docker create cloudflare/sandbox:$VERSION) + # Extract the binary + docker cp $CONTAINER_ID:/sandbox ./sandbox-linux-x64 + docker rm $CONTAINER_ID + # Verify the binary + file ./sandbox-linux-x64 + ls -la ./sandbox-linux-x64 + - id: changesets uses: changesets/action@v1 with: @@ -213,3 +225,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} NPM_PUBLISH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + + - 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 }} + # Tag format matches changesets: @cloudflare/sandbox@VERSION + gh release upload "@cloudflare/sandbox@${VERSION}" ./sandbox-linux-x64 --clobber 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..a9fbba38 --- /dev/null +++ b/packages/sandbox-container/src/legacy.ts @@ -0,0 +1,39 @@ +/** + * 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 server already started by /sandbox binary, this is a no-op +if (process.env.SANDBOX_STARTED === 'true') { + logger.info( + 'Server already running (SANDBOX_STARTED=true). Legacy entry is a no-op.' + ); + // Don't exit - just let the script end naturally so it doesn't affect the parent +} else { + // Legacy behavior: start server normally + logger.info('Starting server via legacy entry point'); + + registerShutdownHandlers(); + + startServer() + .then((server) => { + logger.info('Server started via legacy entry', { port: server.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..409cc8f2 --- /dev/null +++ b/packages/sandbox-container/src/main.ts @@ -0,0 +1,102 @@ +/** + * 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 + */ + +import { type ChildProcess, spawn } from 'node:child_process'; +import { createLogger } from '@repo/shared'; +import { registerShutdownHandlers, startServer } from './server'; + +const logger = createLogger({ component: 'container' }); + +async function main(): Promise { + // Arguments after the binary name are the user's CMD + const userCmd = process.argv.slice(2); + + logger.info('Starting sandbox entrypoint', { + userCmd: userCmd.length > 0 ? userCmd : '(none)', + version: process.env.SANDBOX_VERSION || 'unknown' + }); + + // Register shutdown handlers first + registerShutdownHandlers(); + + // Start the API server + const server = await startServer(); + logger.info('API server started', { port: server.port }); + + // If no user command, just keep server running + if (userCmd.length === 0) { + logger.info('No user command provided, running API server only'); + return; // Server keeps process alive + } + + // Mark server as started for backwards compatibility with legacy entry point + // This prevents double-startup when user scripts call `bun /container-server/dist/index.js` + process.env.SANDBOX_STARTED = 'true'; + + // Spawn user's command + logger.info('Spawning user command', { + command: userCmd[0], + args: userCmd.slice(1) + }); + + const child: ChildProcess = spawn(userCmd[0], userCmd.slice(1), { + stdio: 'inherit', + env: process.env, + shell: false + }); + + // Forward signals to child process + const forwardSignal = (signal: NodeJS.Signals) => { + logger.info('Forwarding signal to child', { signal }); + child.kill(signal); + }; + + process.on('SIGTERM', () => forwardSignal('SIGTERM')); + process.on('SIGINT', () => forwardSignal('SIGINT')); + + // Handle child process errors + child.on('error', (err) => { + logger.error('Failed to spawn user command', err, { command: userCmd[0] }); + process.exit(1); + }); + + // Handle child exit + child.on('exit', (code, signal) => { + if (signal) { + logger.info('User command killed by signal', { signal }); + // Standard Unix convention: 128 + signal number + const signalNum = + signal === 'SIGTERM' + ? 15 + : signal === 'SIGINT' + ? 2 + : signal === 'SIGKILL' + ? 9 + : 1; + process.exit(128 + signalNum); + } else if (code !== 0) { + // Non-zero exit: propagate the error + logger.info('User command failed', { exitCode: code }); + process.exit(code ?? 1); + } else { + // Exit code 0: user command completed successfully + // Keep server running so sandbox API remains available + 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..66c1fcb6 100644 --- a/packages/sandbox-container/src/runtime/process-pool.ts +++ b/packages/sandbox-container/src/runtime/process-pool.ts @@ -16,6 +16,29 @@ const PYTHON_AVAILABLE = (() => { } })(); +// Check which JavaScript runtime is available (prefer Node.js, fall back to Bun) +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 +268,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 +396,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 +612,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..b78ced8a --- /dev/null +++ b/packages/sandbox-container/src/server.ts @@ -0,0 +1,105 @@ +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' }); + +// Store container reference for cleanup +let containerInstance: Container | null = null; + +async function createApplication(): Promise<{ + fetch: (req: Request) => Promise; +}> { + // Initialize dependency injection container + const container = new Container(); + await container.initialize(); + containerInstance = container; + + // 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) + }; +} + +/** + * Start the HTTP API server on port 3000. + * Returns the Bun server instance. + */ +export async function startServer(): Promise> { + const app = await createApplication(); + + 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' + }); + + return server; +} + +// Track whether shutdown handlers are registered +let shutdownRegistered = false; + +/** + * Register graceful shutdown handlers for SIGTERM and SIGINT. + * Safe to call multiple times - handlers are only registered once. + */ +export function registerShutdownHandlers(): void { + if (shutdownRegistered) return; + shutdownRegistered = true; + + process.on('SIGTERM', async () => { + logger.info('Received SIGTERM, shutting down gracefully'); + + if (containerInstance?.isInitialized()) { + try { + // Cleanup services with proper typing + const processService = containerInstance.get('processService'); + const portService = containerInstance.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/Dockerfile b/packages/sandbox/Dockerfile index 8648a8da..a4cb936e 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -1,4 +1,4 @@ -# Sandbox container image with full development environment +# Sandbox container images (default and python variants) # Multi-stage build optimized for Turborepo monorepo # ============================================================================ @@ -8,10 +8,8 @@ FROM node:20-alpine 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) @@ -36,11 +34,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 +93,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 +119,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 to root (for easy COPY --from in arbitrary Dockerfiles) +COPY --from=builder /app/packages/sandbox-container/dist/sandbox /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 +147,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 ["/sandbox"] # ============================================================================ # Stage 5b: Python image - full, with Python + data science packages @@ -186,4 +179,4 @@ ENV PYTHON_POOL_MIN_SIZE=3 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -CMD ["/container-server/startup.sh"] +ENTRYPOINT ["/sandbox"] 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 db98c095..01d44c18 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -96,6 +96,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', // Validation Errors (400) VALIDATION_FAILED: 'VALIDATION_FAILED', diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 7e9dd8ca..0fde29cb 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -49,6 +49,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, From 3f71ec3f68dbf088bc2dd6fab97d8493ca981aa8 Mon Sep 17 00:00:00 2001 From: Naresh Date: Fri, 5 Dec 2025 15:29:31 +0000 Subject: [PATCH 2/8] Move binary to /container-server/sandbox --- .changeset/standalone-binary-arbitrary-dockerfile.md | 2 +- .github/templates/pr-preview-comment.md | 4 ++-- .github/workflows/pkg-pr-new.yml | 2 +- .github/workflows/release.yml | 5 +---- packages/sandbox/Dockerfile | 8 ++++---- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.changeset/standalone-binary-arbitrary-dockerfile.md b/.changeset/standalone-binary-arbitrary-dockerfile.md index e81c5e5e..246ab05c 100644 --- a/.changeset/standalone-binary-arbitrary-dockerfile.md +++ b/.changeset/standalone-binary-arbitrary-dockerfile.md @@ -9,7 +9,7 @@ Users can now add sandbox capabilities to any Docker image: ```dockerfile FROM your-image:tag -COPY --from=cloudflare/sandbox:VERSION /sandbox /sandbox +COPY --from=cloudflare/sandbox:VERSION /container-server/sandbox /sandbox ENTRYPOINT ["/sandbox"] # Optional: run your own startup command diff --git a/.github/templates/pr-preview-comment.md b/.github/templates/pr-preview-comment.md index 1831783f..20c98633 100644 --- a/.github/templates/pr-preview-comment.md +++ b/.github/templates/pr-preview-comment.md @@ -23,7 +23,7 @@ Use the `-python` variant if you need Python code execution. **For arbitrary Dockerfiles:** ```dockerfile -COPY --from={{DEFAULT_TAG}} /sandbox /sandbox +COPY --from={{DEFAULT_TAG}} /container-server/sandbox /sandbox ENTRYPOINT ["/sandbox"] ``` @@ -36,5 +36,5 @@ gh run download {{RUN_ID}} -n sandbox-binary **Extract from Docker:** ```bash -docker run --rm {{DEFAULT_TAG}} cat /sandbox > sandbox && chmod +x sandbox +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 1c80a9ac..bc447ed8 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -113,7 +113,7 @@ jobs: run: | VERSION=${{ steps.package-version.outputs.version }} CONTAINER_ID=$(docker create cloudflare/sandbox:$VERSION) - docker cp $CONTAINER_ID:/sandbox ./sandbox-linux-x64 + docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64 docker rm $CONTAINER_ID chmod +x ./sandbox-linux-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93c07c8d..86febd7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -207,12 +207,9 @@ jobs: - name: Extract standalone binary from Docker image run: | VERSION=${{ needs.unit-tests.outputs.version }} - # Create container from the default image (has the binary at /sandbox) CONTAINER_ID=$(docker create cloudflare/sandbox:$VERSION) - # Extract the binary - docker cp $CONTAINER_ID:/sandbox ./sandbox-linux-x64 + docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64 docker rm $CONTAINER_ID - # Verify the binary file ./sandbox-linux-x64 ls -la ./sandbox-linux-x64 diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index a4cb936e..d623f527 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -119,8 +119,8 @@ 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 -# Copy standalone binary to root (for easy COPY --from in arbitrary Dockerfiles) -COPY --from=builder /app/packages/sandbox-container/dist/sandbox /sandbox +# Copy standalone binary +COPY --from=builder /app/packages/sandbox-container/dist/sandbox /container-server/sandbox # Set up container server directory for executors WORKDIR /container-server @@ -147,7 +147,7 @@ ENV PYTHON_POOL_MIN_SIZE=0 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -ENTRYPOINT ["/sandbox"] +ENTRYPOINT ["/container-server/sandbox"] # ============================================================================ # Stage 5b: Python image - full, with Python + data science packages @@ -179,4 +179,4 @@ ENV PYTHON_POOL_MIN_SIZE=3 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -ENTRYPOINT ["/sandbox"] +ENTRYPOINT ["/container-server/sandbox"] From 4cdbdbef0d611e606424e3fbdf20daa2c84c52da Mon Sep 17 00:00:00 2001 From: Naresh Date: Fri, 5 Dec 2025 15:52:43 +0000 Subject: [PATCH 3/8] Add --platform flag to docker create commands --- .github/workflows/pkg-pr-new.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index bc447ed8..0782d178 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -112,7 +112,7 @@ jobs: - name: Extract standalone binary from Docker image run: | VERSION=${{ steps.package-version.outputs.version }} - CONTAINER_ID=$(docker create cloudflare/sandbox:$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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2d06839..b6c98960 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,7 +216,7 @@ jobs: - name: Extract standalone binary from Docker image run: | VERSION=${{ needs.unit-tests.outputs.version }} - CONTAINER_ID=$(docker create cloudflare/sandbox:$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 From 12ba956cedafb550260a11bf9323a4a5347943b5 Mon Sep 17 00:00:00 2001 From: Naresh Date: Wed, 10 Dec 2025 23:06:28 +0000 Subject: [PATCH 4/8] Fix signal handling and refactor server lifecycle Signal handlers are now registered before spawning the child process to close a race window. Exit codes use os.constants.signals for correct Unix convention mapping. Server cleanup is returned from startServer() rather than relying on module-level state, making the dependency between server startup and shutdown handlers explicit. --- packages/sandbox-container/src/legacy.ts | 10 +-- packages/sandbox-container/src/main.ts | 63 +++++++-------- .../src/runtime/process-pool.ts | 4 +- packages/sandbox-container/src/server.ts | 76 +++++++++---------- 4 files changed, 68 insertions(+), 85 deletions(-) diff --git a/packages/sandbox-container/src/legacy.ts b/packages/sandbox-container/src/legacy.ts index a9fbba38..7bec42f1 100644 --- a/packages/sandbox-container/src/legacy.ts +++ b/packages/sandbox-container/src/legacy.ts @@ -16,21 +16,17 @@ import { registerShutdownHandlers, startServer } from './server'; const logger = createLogger({ component: 'container' }); -// If server already started by /sandbox binary, this is a no-op if (process.env.SANDBOX_STARTED === 'true') { logger.info( 'Server already running (SANDBOX_STARTED=true). Legacy entry is a no-op.' ); - // Don't exit - just let the script end naturally so it doesn't affect the parent } else { - // Legacy behavior: start server normally logger.info('Starting server via legacy entry point'); - registerShutdownHandlers(); - startServer() - .then((server) => { - logger.info('Server started via legacy entry', { port: server.port }); + .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); diff --git a/packages/sandbox-container/src/main.ts b/packages/sandbox-container/src/main.ts index 409cc8f2..c3bc40a4 100644 --- a/packages/sandbox-container/src/main.ts +++ b/packages/sandbox-container/src/main.ts @@ -7,16 +7,20 @@ * 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 { - // Arguments after the binary name are the user's CMD const userCmd = process.argv.slice(2); logger.info('Starting sandbox entrypoint', { @@ -24,71 +28,58 @@ async function main(): Promise { version: process.env.SANDBOX_VERSION || 'unknown' }); - // Register shutdown handlers first - registerShutdownHandlers(); - - // Start the API server - const server = await startServer(); - logger.info('API server started', { port: server.port }); + const { cleanup } = await startServer(); - // If no user command, just keep server running if (userCmd.length === 0) { logger.info('No user command provided, running API server only'); - return; // Server keeps process alive + registerShutdownHandlers(cleanup); + return; } - // Mark server as started for backwards compatibility with legacy entry point - // This prevents double-startup when user scripts call `bun /container-server/dist/index.js` + // 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'; - // Spawn user's command + 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) }); - const child: ChildProcess = spawn(userCmd[0], userCmd.slice(1), { + child = spawn(userCmd[0], userCmd.slice(1), { stdio: 'inherit', env: process.env, shell: false }); - // Forward signals to child process - const forwardSignal = (signal: NodeJS.Signals) => { - logger.info('Forwarding signal to child', { signal }); - child.kill(signal); - }; - - process.on('SIGTERM', () => forwardSignal('SIGTERM')); - process.on('SIGINT', () => forwardSignal('SIGINT')); - - // Handle child process errors child.on('error', (err) => { logger.error('Failed to spawn user command', err, { command: userCmd[0] }); process.exit(1); }); - // Handle child exit child.on('exit', (code, signal) => { if (signal) { logger.info('User command killed by signal', { signal }); - // Standard Unix convention: 128 + signal number - const signalNum = - signal === 'SIGTERM' - ? 15 - : signal === 'SIGINT' - ? 2 - : signal === 'SIGKILL' - ? 9 - : 1; + // Unix convention: 128 + signal number + const signalNum = constants.signals[signal] ?? 15; process.exit(128 + signalNum); } else if (code !== 0) { - // Non-zero exit: propagate the error logger.info('User command failed', { exitCode: code }); process.exit(code ?? 1); } else { - // Exit code 0: user command completed successfully - // Keep server running so sandbox API remains available logger.info( 'User command completed successfully, server continues running' ); diff --git a/packages/sandbox-container/src/runtime/process-pool.ts b/packages/sandbox-container/src/runtime/process-pool.ts index 66c1fcb6..a5ec64eb 100644 --- a/packages/sandbox-container/src/runtime/process-pool.ts +++ b/packages/sandbox-container/src/runtime/process-pool.ts @@ -16,7 +16,9 @@ const PYTHON_AVAILABLE = (() => { } })(); -// Check which JavaScript runtime is available (prefer Node.js, fall back to Bun) +// 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 }); diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts index b78ced8a..77292782 100644 --- a/packages/sandbox-container/src/server.ts +++ b/packages/sandbox-container/src/server.ts @@ -4,86 +4,65 @@ 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' }); +const SERVER_PORT = 3000; -// Store container reference for cleanup -let containerInstance: Container | null = null; +export interface ServerInstance { + port: number; + cleanup: () => Promise; +} async function createApplication(): Promise<{ fetch: (req: Request) => Promise; + container: Container; }> { - // Initialize dependency injection container const container = new Container(); await container.initialize(); - containerInstance = container; - // 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) + fetch: (req: Request) => router.route(req), + container }; } /** * Start the HTTP API server on port 3000. - * Returns the Bun server instance. + * Returns server info and a cleanup function for graceful shutdown. */ -export async function startServer(): Promise> { +export async function startServer(): Promise { const app = await createApplication(); - const server = serve({ + serve({ idleTimeout: 255, fetch: app.fetch, hostname: '0.0.0.0', - port: 3000, - // Enhanced WebSocket placeholder for future streaming features + port: SERVER_PORT, websocket: { async message() { - // WebSocket functionality can be added here in the future + // WebSocket placeholder for future streaming features } } }); logger.info('Container server started', { - port: server.port, + port: SERVER_PORT, hostname: '0.0.0.0' }); - return server; -} - -// Track whether shutdown handlers are registered -let shutdownRegistered = false; - -/** - * Register graceful shutdown handlers for SIGTERM and SIGINT. - * Safe to call multiple times - handlers are only registered once. - */ -export function registerShutdownHandlers(): void { - if (shutdownRegistered) return; - shutdownRegistered = true; - - process.on('SIGTERM', async () => { - logger.info('Received SIGTERM, shutting down gracefully'); + return { + port: SERVER_PORT, + cleanup: async () => { + if (!app.container.isInitialized()) return; - if (containerInstance?.isInitialized()) { try { - // Cleanup services with proper typing - const processService = containerInstance.get('processService'); - const portService = containerInstance.get('portService'); + const processService = app.container.get('processService'); + const portService = app.container.get('portService'); - // Cleanup processes (asynchronous - kills all running processes) await processService.destroy(); - - // Cleanup ports (synchronous) portService.destroy(); logger.info('Services cleaned up successfully'); @@ -94,7 +73,22 @@ export function registerShutdownHandlers(): void { ); } } + }; +} +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); }); From a728ccecf93fc15262f09bf47632680f8fa26c27 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 11 Dec 2025 01:05:32 +0000 Subject: [PATCH 5/8] Switch base images from Alpine to glibc for binary compat The standalone binary compiled on glibc won't run on Alpine (musl). Using node:20-slim and oven/bun:1 ensures the binary works on standard Linux distributions like Debian, Ubuntu, and RHEL. --- packages/sandbox/Dockerfile | 10 ++++++---- packages/sandbox/package.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index 4fd911c2..2caaf741 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -4,7 +4,7 @@ # ============================================================================ # Stage 1: Prune monorepo to only include necessary packages # ============================================================================ -FROM node:20-alpine AS pruner +FROM node:20-slim AS pruner WORKDIR /app @@ -18,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/ . 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 \"$@\"" }, From 29f3f19af497d5a419f6723f1cccda26f831955a Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 11 Dec 2025 01:05:51 +0000 Subject: [PATCH 6/8] Add E2E tests for standalone binary pattern Tests that the sandbox binary works when copied into arbitrary Docker images. Validates command execution, file operations with MIME type detection, and CMD passthrough to user startup scripts. --- .github/workflows/pullrequest.yml | 12 ++++- .github/workflows/release.yml | 12 ++++- scripts/cleanup-test-deployment.sh | 12 ++++- tests/e2e/helpers/global-sandbox.ts | 22 ++++++++ tests/e2e/standalone-binary-workflow.test.ts | 54 +++++++++++++++++++ tests/e2e/test-worker/Dockerfile.standalone | 26 +++++++++ tests/e2e/test-worker/index.ts | 9 +++- tests/e2e/test-worker/startup-test.sh | 18 +++++++ tests/e2e/test-worker/wrangler.template.jsonc | 13 +++++ 9 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/standalone-binary-workflow.test.ts create mode 100644 tests/e2e/test-worker/Dockerfile.standalone create mode 100644 tests/e2e/test-worker/startup-test.sh 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 e33f7d64..f83a0ad7 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 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" } ], From 8a35b58202c3605d0e02915fa37ccfd431d665ad Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 11 Dec 2025 01:06:09 +0000 Subject: [PATCH 7/8] Document standalone binary pattern and dependencies Documents how to add sandbox capabilities to arbitrary Docker images by copying the /sandbox binary. Lists required dependencies (file, git) and what works without extra packages. --- docs/STANDALONE_BINARY.md | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/STANDALONE_BINARY.md diff --git a/docs/STANDALONE_BINARY.md b/docs/STANDALONE_BINARY.md new file mode 100644 index 00000000..307531c8 --- /dev/null +++ b/docs/STANDALONE_BINARY.md @@ -0,0 +1,46 @@ +# 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 +``` + +## 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. From ef9e2ace32187173e78ca3863d3e8861d97fd2de Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 11 Dec 2025 01:27:21 +0000 Subject: [PATCH 8/8] Add CMD passthrough docs and release checksums Document the supervisor lifecycle model for standalone binary users. Add SHA256 checksum generation for binary releases. --- .github/workflows/release.yml | 3 ++- docs/STANDALONE_BINARY.md | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f83a0ad7..3580aaa8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -264,5 +264,6 @@ jobs: 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 --clobber + 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 index 307531c8..d7b5d851 100644 --- a/docs/STANDALONE_BINARY.md +++ b/docs/STANDALONE_BINARY.md @@ -17,6 +17,15 @@ 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 |