Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/standalone-binary-arbitrary-dockerfile.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions .github/templates/pr-preview-comment.md
Original file line number Diff line number Diff line change
@@ -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
```
26 changes: 25 additions & 1 deletion .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,42 @@ 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'

- name: Comment Docker image tag
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({
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 30 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
55 changes: 55 additions & 0 deletions docs/STANDALONE_BINARY.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 46 additions & 11 deletions packages/sandbox-container/build.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,44 @@
/**
* 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';

// 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',
Expand All @@ -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!');
Loading
Loading