Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .changeset/configurable-control-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@cloudflare/sandbox': patch
---

Change the container control plane port from 3000 to 8671, freeing port 3000 for user services like Express and Next.js.

The port is configurable via the `SANDBOX_CONTROL_PORT` environment variable:

```toml
[vars]
SANDBOX_CONTROL_PORT = "9500"
```
5 changes: 5 additions & 0 deletions .changeset/fix-port-fallback-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': patch
---

Improve container startup resilience for WebSocket transport. The SDK now ensures the container is ready before attempting WebSocket upgrades, matching the existing behavior for HTTP transport.
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The Cloudflare Sandbox SDK enables secure, isolated code execution in containers

### Key Flow

Worker → Sandbox DO → Container HTTP API (port 3000) → Bun runtime → Shell commands/File system
Worker → Sandbox DO → Container HTTP API (port 8671) → Bun runtime → Shell commands/File system

## Development Commands

Expand Down Expand Up @@ -190,7 +190,9 @@ The container runtime (`packages/sandbox-container/src/`) uses:
- **Services**: Business logic in `services/` (CommandService, FileService, ProcessService, etc.)
- **Managers**: Stateful managers in `managers/` (ProcessManager, PortManager)

Entry point: `packages/sandbox-container/src/index.ts` starts Bun HTTP server on port 3000.
Entry point: `packages/sandbox-container/src/index.ts` starts Bun HTTP server on the configured control port (default: 8671, override via `SANDBOX_CONTROL_PORT` env var).

**Legacy port fallback**: When the SDK connects to a container that doesn't support `SANDBOX_CONTROL_PORT` (older images still on port 3000), `startWithLegacyFallback()` in `sandbox.ts` catches the port failure and retries on port 3000. If successful, it switches `defaultPort` and recreates the client. This fallback only applies to control port requests, not user service ports (preview URLs). It will be removed in a future release.

## Monorepo Structure

Expand Down
3 changes: 2 additions & 1 deletion docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
variable "TAG" { default = "dev" }
variable "SANDBOX_VERSION" { default = "dev" }
variable "BUN_VERSION" { default = "1.3" }
variable "BUILD_INTERNAL_SERVER_PORT" { default = "8671" }
variable "CACHE_REPO" { default = "" }

// main: all variants needed for E2E testing (CF registry)
Expand All @@ -25,7 +26,7 @@ target "_common" {
context = "."
dockerfile = "packages/sandbox/Dockerfile"
platforms = ["linux/amd64"]
args = { SANDBOX_VERSION = SANDBOX_VERSION, BUN_VERSION = BUN_VERSION }
args = { SANDBOX_VERSION = SANDBOX_VERSION, BUN_VERSION = BUN_VERSION, BUILD_INTERNAL_SERVER_PORT = BUILD_INTERNAL_SERVER_PORT }
}

target "default" {
Expand Down
2 changes: 1 addition & 1 deletion docs/STANDALONE_BINARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The musl image is a lightweight, functional sandbox. It supports all core SDK me

The `/sandbox` binary acts as a supervisor:

1. Starts HTTP API server on port 3000
1. Starts HTTP API server on the control port (default: 8671)
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
Expand Down
2 changes: 1 addition & 1 deletion examples/claude-code/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/cloudflare/sandbox:0.8.0
RUN npm install -g @anthropic-ai/claude-code
ENV COMMAND_TIMEOUT_MS=300000
EXPOSE 3000
EXPOSE 8671
2 changes: 1 addition & 1 deletion examples/openai-agents/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ FROM docker.io/cloudflare/sandbox:0.8.0

# Required during local development to access exposed ports
EXPOSE 8080
EXPOSE 3000
EXPOSE 8671
14 changes: 14 additions & 0 deletions packages/sandbox-container/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import { DEFAULT_CONTROL_PORT } from '@repo/shared';

/**
* Port the container HTTP server binds to (SDK control plane).
* The SDK always sets SANDBOX_CONTROL_PORT in envVars before starting
* the container, so the fallback only guards against standalone/test usage.
*
* Default: 8671
* Environment variable: SANDBOX_CONTROL_PORT
*/
const SERVER_PORT =
Number(process.env.SANDBOX_CONTROL_PORT) || DEFAULT_CONTROL_PORT;

/**
* How long to wait for an interpreter process to spawn and become ready.
* If an interpreter doesn't start within this time, something is fundamentally
Expand Down Expand Up @@ -53,6 +66,7 @@ const STREAM_CHUNK_DELAY_MS = 100;
const DEFAULT_CWD = '/workspace';

export const CONFIG = {
SERVER_PORT,
INTERPRETER_SPAWN_TIMEOUT_MS,
INTERPRETER_EXECUTION_TIMEOUT_MS,
COMMAND_TIMEOUT_MS,
Expand Down
6 changes: 2 additions & 4 deletions packages/sandbox-container/src/handlers/ws-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@ import {
type WSStreamChunk
} from '@repo/shared';
import type { ServerWebSocket } from 'bun';
import { CONFIG } from '../config';
import type { Router } from '../core/router';

/** Container server port - must match SERVER_PORT in server.ts */
const SERVER_PORT = 3000;

/**
* WebSocket data attached to each connection
*/
Expand Down Expand Up @@ -194,7 +192,7 @@ export class WebSocketAdapter {
request: WSRequest
): Promise<void> {
// Build URL for the request
const url = `http://localhost:${SERVER_PORT}${request.path}`;
const url = `http://localhost:${CONFIG.SERVER_PORT}${request.path}`;

// Build headers
const headers: Record<string, string> = {
Expand Down
18 changes: 9 additions & 9 deletions packages/sandbox-container/src/security/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
// Philosophy:
// - Container isolation handles system-level security
// - Users have full control over their sandbox (it's the value proposition!)
// - Only protect port 3000 (SDK control plane) from interference
// - Only protect the SDK control plane port from interference
// - Format validation only (null bytes, length limits)
// - No content restrictions (no path blocking, no command blocking, no URL allowlists)
import { type Logger, redactCommand } from '@repo/shared';
import type { Logger } from '@repo/shared';
import { redactCommand } from '@repo/shared';
import { CONFIG } from '../config';
import type { ValidationResult } from '../core/types';

export class SecurityService {
// Only port 3000 is truly reserved (SDK control plane)
// This is REAL security - prevents control plane interference
private static readonly RESERVED_PORTS = [
3000 // Container control plane (API endpoints) - MUST be protected
];
// Currently only the control plane port is reserved. Kept as an array
// in case additional ports need protection in the future.
private readonly reservedPorts = [CONFIG.SERVER_PORT];

constructor(private logger: Logger) {}

Expand Down Expand Up @@ -81,7 +81,7 @@ export class SecurityService {

/**
* Validate port number
* - Protects port 3000 (SDK control plane) - CRITICAL!
* - Protects the SDK control plane port - CRITICAL!
* - Range validation (1-65535)
* - No arbitrary port restrictions (users control their sandbox!)
*/
Expand All @@ -98,7 +98,7 @@ export class SecurityService {
}

// CRITICAL: Protect SDK control plane
if (SecurityService.RESERVED_PORTS.includes(port)) {
if (this.reservedPorts.includes(port)) {
errors.push(
`Port ${port} is reserved for the sandbox API control plane`
);
Expand Down
10 changes: 5 additions & 5 deletions packages/sandbox-container/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@repo/shared';
import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { CONFIG } from './config';
import { Container } from './core/container';
import { Router } from './core/router';
import type { PtyWSData } from './handlers/pty-ws-handler';
Expand All @@ -15,7 +16,6 @@ export type WSData = (ControlWSData & { type: 'control' }) | PtyWSData;
import { setupRoutes } from './routes/setup';

const logger = createLogger({ component: 'container' });
const SERVER_PORT = 3000;

// Global error handlers to prevent fragmented stack traces in logs
// Bun's default handler writes stack traces line-by-line to stderr,
Expand Down Expand Up @@ -114,7 +114,7 @@ async function createApplication(): Promise<{
}

/**
* Start the HTTP API server on port 3000.
* Start the HTTP API server on the configured control port.
* Returns server info and a cleanup function for graceful shutdown.
*/
export async function startServer(): Promise<ServerInstance> {
Expand All @@ -131,7 +131,7 @@ export async function startServer(): Promise<ServerInstance> {
return new Response('Internal Server Error', { status: 500 });
},
hostname: '0.0.0.0',
port: SERVER_PORT,
port: CONFIG.SERVER_PORT,
websocket: {
open(ws) {
try {
Expand Down Expand Up @@ -199,12 +199,12 @@ export async function startServer(): Promise<ServerInstance> {
});

logger.info('Container server started', {
port: SERVER_PORT,
port: CONFIG.SERVER_PORT,
hostname: '0.0.0.0'
});

return {
port: SERVER_PORT,
port: CONFIG.SERVER_PORT,
// Cleanup handles application-level resources (processes, ports).
// WebSocket connections are closed automatically when the process exits -
// Bun's serve() handles transport cleanup on shutdown.
Expand Down
9 changes: 9 additions & 0 deletions packages/sandbox-container/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { DEFAULT_CONTROL_PORT } from '@repo/shared';
import { CONFIG } from '@sandbox-container/config';

describe('CONFIG.SERVER_PORT', () => {
test('uses DEFAULT_CONTROL_PORT when SANDBOX_CONTROL_PORT is not set', () => {
expect(CONFIG.SERVER_PORT).toBe(DEFAULT_CONTROL_PORT);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('ExecuteHandler', () => {
mockCommandResult
);

const request = new Request('http://localhost:3000/api/execute', {
const request = new Request('http://localhost:8671/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('ExecuteHandler', () => {
mockCommandResult
);

const request = new Request('http://localhost:3000/api/execute', {
const request = new Request('http://localhost:8671/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'nonexistent-command' })
Expand Down Expand Up @@ -163,7 +163,7 @@ describe('ExecuteHandler', () => {
mockServiceError
);

const request = new Request('http://localhost:3000/api/execute', {
const request = new Request('http://localhost:8671/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'ls' })
Expand Down Expand Up @@ -208,7 +208,7 @@ describe('ExecuteHandler', () => {
mockProcessResult
);

const request = new Request('http://localhost:3000/api/execute', {
const request = new Request('http://localhost:8671/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -278,7 +278,7 @@ describe('ExecuteHandler', () => {
mockStreamProcessResult
);

const request = new Request('http://localhost:3000/api/execute/stream', {
const request = new Request('http://localhost:8671/api/execute/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'echo "streaming test"' })
Expand Down
Loading
Loading