diff --git a/.changeset/configurable-control-port.md b/.changeset/configurable-control-port.md new file mode 100644 index 000000000..9862b55de --- /dev/null +++ b/.changeset/configurable-control-port.md @@ -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" +``` diff --git a/.changeset/fix-port-fallback-gaps.md b/.changeset/fix-port-fallback-gaps.md new file mode 100644 index 000000000..aad605b8a --- /dev/null +++ b/.changeset/fix-port-fallback-gaps.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index c1fcd4d0f..3b75ed5ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/docker-bake.hcl b/docker-bake.hcl index f34b5d68e..62458301e 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -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) @@ -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" { diff --git a/docs/STANDALONE_BINARY.md b/docs/STANDALONE_BINARY.md index 978510f80..1885fac8f 100644 --- a/docs/STANDALONE_BINARY.md +++ b/docs/STANDALONE_BINARY.md @@ -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 diff --git a/examples/claude-code/Dockerfile b/examples/claude-code/Dockerfile index bad8e94ac..f5cfae01d 100644 --- a/examples/claude-code/Dockerfile +++ b/examples/claude-code/Dockerfile @@ -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 diff --git a/examples/openai-agents/Dockerfile b/examples/openai-agents/Dockerfile index 52ccf4561..a3cd60804 100644 --- a/examples/openai-agents/Dockerfile +++ b/examples/openai-agents/Dockerfile @@ -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 diff --git a/packages/sandbox-container/src/config.ts b/packages/sandbox-container/src/config.ts index 9946c9a0e..7216f3dec 100644 --- a/packages/sandbox-container/src/config.ts +++ b/packages/sandbox-container/src/config.ts @@ -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 @@ -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, diff --git a/packages/sandbox-container/src/handlers/ws-adapter.ts b/packages/sandbox-container/src/handlers/ws-adapter.ts index f0f747734..59990ac78 100644 --- a/packages/sandbox-container/src/handlers/ws-adapter.ts +++ b/packages/sandbox-container/src/handlers/ws-adapter.ts @@ -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 */ @@ -194,7 +192,7 @@ export class WebSocketAdapter { request: WSRequest ): Promise { // 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 = { diff --git a/packages/sandbox-container/src/security/security-service.ts b/packages/sandbox-container/src/security/security-service.ts index 360fc837a..1e3656615 100644 --- a/packages/sandbox-container/src/security/security-service.ts +++ b/packages/sandbox-container/src/security/security-service.ts @@ -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) {} @@ -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!) */ @@ -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` ); diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts index 4de6b6e29..a383f9602 100644 --- a/packages/sandbox-container/src/server.ts +++ b/packages/sandbox-container/src/server.ts @@ -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'; @@ -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, @@ -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 { @@ -131,7 +131,7 @@ export async function startServer(): Promise { return new Response('Internal Server Error', { status: 500 }); }, hostname: '0.0.0.0', - port: SERVER_PORT, + port: CONFIG.SERVER_PORT, websocket: { open(ws) { try { @@ -199,12 +199,12 @@ export async function startServer(): Promise { }); 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. diff --git a/packages/sandbox-container/tests/config.test.ts b/packages/sandbox-container/tests/config.test.ts new file mode 100644 index 000000000..30ff4014b --- /dev/null +++ b/packages/sandbox-container/tests/config.test.ts @@ -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); + }); +}); diff --git a/packages/sandbox-container/tests/handlers/execute-handler.test.ts b/packages/sandbox-container/tests/handlers/execute-handler.test.ts index 2dcac552d..6d4c6821b 100644 --- a/packages/sandbox-container/tests/handlers/execute-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/execute-handler.test.ts @@ -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({ @@ -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' }) @@ -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' }) @@ -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({ @@ -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"' }) diff --git a/packages/sandbox-container/tests/handlers/file-handler.test.ts b/packages/sandbox-container/tests/handlers/file-handler.test.ts index da5b4c4c2..32f17f386 100644 --- a/packages/sandbox-container/tests/handlers/file-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/file-handler.test.ts @@ -79,7 +79,7 @@ describe('FileHandler', () => { data: fileContent }); - const request = new Request('http://localhost:3000/api/read', { + const request = new Request('http://localhost:8671/api/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readFileData) @@ -115,7 +115,7 @@ describe('FileHandler', () => { data: 'file content' }); - const request = new Request('http://localhost:3000/api/read', { + const request = new Request('http://localhost:8671/api/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readFileData) @@ -151,7 +151,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/read', { + const request = new Request('http://localhost:8671/api/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readFileData) @@ -181,7 +181,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/write', { + const request = new Request('http://localhost:8671/api/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(writeFileData) @@ -216,7 +216,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/write', { + const request = new Request('http://localhost:8671/api/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(writeFileData) @@ -248,7 +248,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/write', { + const request = new Request('http://localhost:8671/api/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(writeFileData) @@ -275,7 +275,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/delete', { + const request = new Request('http://localhost:8671/api/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(deleteFileData) @@ -305,7 +305,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/delete', { + const request = new Request('http://localhost:8671/api/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(deleteFileData) @@ -333,7 +333,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/rename', { + const request = new Request('http://localhost:8671/api/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(renameFileData) @@ -368,7 +368,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/rename', { + const request = new Request('http://localhost:8671/api/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(renameFileData) @@ -396,7 +396,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/move', { + const request = new Request('http://localhost:8671/api/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(moveFileData) @@ -431,7 +431,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/move', { + const request = new Request('http://localhost:8671/api/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(moveFileData) @@ -459,7 +459,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/mkdir', { + const request = new Request('http://localhost:8671/api/mkdir', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mkdirData) @@ -492,7 +492,7 @@ describe('FileHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/mkdir', { + const request = new Request('http://localhost:8671/api/mkdir', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mkdirData) @@ -529,7 +529,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/mkdir', { + const request = new Request('http://localhost:8671/api/mkdir', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mkdirData) @@ -558,7 +558,7 @@ describe('FileHandler', () => { data: true }); - const request = new Request('http://localhost:3000/api/exists', { + const request = new Request('http://localhost:8671/api/exists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(existsData) @@ -590,7 +590,7 @@ describe('FileHandler', () => { data: false }); - const request = new Request('http://localhost:3000/api/exists', { + const request = new Request('http://localhost:8671/api/exists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(existsData) @@ -619,7 +619,7 @@ describe('FileHandler', () => { } }); - const request = new Request('http://localhost:3000/api/exists', { + const request = new Request('http://localhost:8671/api/exists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(existsData) @@ -636,7 +636,7 @@ describe('FileHandler', () => { describe('route handling', () => { it('should return 500 for invalid endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/invalid-operation', + 'http://localhost:8671/api/invalid-operation', { method: 'POST' } @@ -653,7 +653,7 @@ describe('FileHandler', () => { }); it('should handle root path correctly', async () => { - const request = new Request('http://localhost:3000/', { + const request = new Request('http://localhost:8671/', { method: 'GET' }); @@ -677,7 +677,7 @@ describe('FileHandler', () => { data: 'file content' }); - const request = new Request('http://localhost:3000/api/read', { + const request = new Request('http://localhost:8671/api/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readFileData) @@ -695,7 +695,7 @@ describe('FileHandler', () => { }); it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/invalid', { + const request = new Request('http://localhost:8671/api/invalid', { method: 'POST' }); @@ -750,7 +750,7 @@ describe('FileHandler', () => { } const request = new Request( - `http://localhost:3000${operation.endpoint}`, + `http://localhost:8671${operation.endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/packages/sandbox-container/tests/handlers/git-handler.test.ts b/packages/sandbox-container/tests/handlers/git-handler.test.ts index 693177ada..4f7bdb396 100644 --- a/packages/sandbox-container/tests/handlers/git-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/git-handler.test.ts @@ -63,7 +63,7 @@ describe('GitHandler', () => { data: mockGitResult }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -108,7 +108,7 @@ describe('GitHandler', () => { data: mockGitResult }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -157,7 +157,7 @@ describe('GitHandler', () => { } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -191,7 +191,7 @@ describe('GitHandler', () => { } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -227,7 +227,7 @@ describe('GitHandler', () => { } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -265,7 +265,7 @@ describe('GitHandler', () => { } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -300,7 +300,7 @@ describe('GitHandler', () => { } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -320,7 +320,7 @@ describe('GitHandler', () => { describe('route handling', () => { it('should return 404 for invalid git endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/git/invalid-operation', + 'http://localhost:8671/api/git/invalid-operation', { method: 'POST' } @@ -340,7 +340,7 @@ describe('GitHandler', () => { }); it('should return 500 for root git path', async () => { - const request = new Request('http://localhost:3000/api/git/', { + const request = new Request('http://localhost:8671/api/git/', { method: 'POST' }); @@ -355,7 +355,7 @@ describe('GitHandler', () => { }); it('should return 500 for git endpoint without operation', async () => { - const request = new Request('http://localhost:3000/api/git', { + const request = new Request('http://localhost:8671/api/git', { method: 'POST' }); @@ -381,7 +381,7 @@ describe('GitHandler', () => { data: { path: '/tmp/repo', branch: 'main' } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -400,7 +400,7 @@ describe('GitHandler', () => { }); it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/git/invalid', { + const request = new Request('http://localhost:8671/api/git/invalid', { method: 'POST' }); @@ -424,7 +424,7 @@ describe('GitHandler', () => { data: { path: '/tmp/feature-work', branch: 'feature-branch' } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -464,7 +464,7 @@ describe('GitHandler', () => { data: { path: '/tmp/repo', branch: 'main' } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) @@ -488,7 +488,7 @@ describe('GitHandler', () => { data: { path: '/workspace/repo', branch: 'main' } }); - const request = new Request('http://localhost:3000/api/git/checkout', { + const request = new Request('http://localhost:8671/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gitCheckoutData) diff --git a/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts b/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts index 9a7a87d76..c4af0ad79 100644 --- a/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts @@ -81,7 +81,7 @@ describe('InterpreterHandler', () => { ); const request = new Request( - 'http://localhost:3000/api/interpreter/health', + 'http://localhost:8671/api/interpreter/health', { method: 'GET' } @@ -116,7 +116,7 @@ describe('InterpreterHandler', () => { ); const request = new Request( - 'http://localhost:3000/api/interpreter/health', + 'http://localhost:8671/api/interpreter/health', { method: 'GET' } @@ -158,7 +158,7 @@ describe('InterpreterHandler', () => { cwd: '/workspace' }; - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contextRequest) @@ -201,7 +201,7 @@ describe('InterpreterHandler', () => { cwd: '/workspace' }; - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contextRequest) @@ -239,7 +239,7 @@ describe('InterpreterHandler', () => { cwd: '/workspace' }; - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contextRequest) @@ -284,7 +284,7 @@ describe('InterpreterHandler', () => { mockContexts ); - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'GET' }); @@ -319,7 +319,7 @@ describe('InterpreterHandler', () => { mockListError ); - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'GET' }); @@ -348,7 +348,7 @@ describe('InterpreterHandler', () => { ); const request = new Request( - 'http://localhost:3000/api/contexts/ctx-123', + 'http://localhost:8671/api/contexts/ctx-123', { method: 'DELETE' } @@ -385,7 +385,7 @@ describe('InterpreterHandler', () => { ); const request = new Request( - 'http://localhost:3000/api/contexts/ctx-999', + 'http://localhost:8671/api/contexts/ctx-999', { method: 'DELETE' } @@ -440,7 +440,7 @@ describe('InterpreterHandler', () => { language: 'python' }; - const request = new Request('http://localhost:3000/api/execute/code', { + const request = new Request('http://localhost:8671/api/execute/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(executeRequest) @@ -488,7 +488,7 @@ describe('InterpreterHandler', () => { language: 'python' }; - const request = new Request('http://localhost:3000/api/execute/code', { + const request = new Request('http://localhost:8671/api/execute/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(executeRequest) @@ -510,7 +510,7 @@ describe('InterpreterHandler', () => { describe('handle - Invalid Endpoints', () => { it('should return error for invalid interpreter endpoint', async () => { const request = new Request( - 'http://localhost:3000/api/interpreter/invalid', + 'http://localhost:8671/api/interpreter/invalid', { method: 'GET' } @@ -528,7 +528,7 @@ describe('InterpreterHandler', () => { }); it('should return error for invalid HTTP method', async () => { - const request = new Request('http://localhost:3000/api/contexts', { + const request = new Request('http://localhost:8671/api/contexts', { method: 'PUT' // Invalid method }); diff --git a/packages/sandbox-container/tests/handlers/misc-handler.test.ts b/packages/sandbox-container/tests/handlers/misc-handler.test.ts index e7d923b05..89c9b2dc4 100644 --- a/packages/sandbox-container/tests/handlers/misc-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/misc-handler.test.ts @@ -41,7 +41,7 @@ describe('MiscHandler', () => { describe('handleRoot - GET /', () => { it('should return welcome message with text/plain content type', async () => { - const request = new Request('http://localhost:3000/', { + const request = new Request('http://localhost:8671/', { method: 'GET' }); @@ -55,7 +55,7 @@ describe('MiscHandler', () => { }); it('should include CORS headers in root response', async () => { - const request = new Request('http://localhost:3000/', { + const request = new Request('http://localhost:8671/', { method: 'GET' }); @@ -74,7 +74,7 @@ describe('MiscHandler', () => { const methods = ['GET', 'POST', 'PUT', 'DELETE']; for (const method of methods) { - const request = new Request('http://localhost:3000/', { + const request = new Request('http://localhost:8671/', { method }); @@ -88,7 +88,7 @@ describe('MiscHandler', () => { describe('handleHealth - GET /api/health', () => { it('should return health check response with JSON content type', async () => { - const request = new Request('http://localhost:3000/api/health', { + const request = new Request('http://localhost:8671/api/health', { method: 'GET' }); @@ -110,7 +110,7 @@ describe('MiscHandler', () => { }); it('should include CORS headers in health response', async () => { - const request = new Request('http://localhost:3000/api/health', { + const request = new Request('http://localhost:8671/api/health', { method: 'GET' }); @@ -131,7 +131,7 @@ describe('MiscHandler', () => { for (const method of methods) { vi.clearAllMocks(); // Clear mocks between iterations - const request = new Request('http://localhost:3000/api/health', { + const request = new Request('http://localhost:8671/api/health', { method }); @@ -145,10 +145,10 @@ describe('MiscHandler', () => { }); it('should return unique timestamps for multiple health requests', async () => { - const request1 = new Request('http://localhost:3000/api/health', { + const request1 = new Request('http://localhost:8671/api/health', { method: 'GET' }); - const request2 = new Request('http://localhost:3000/api/health', { + const request2 = new Request('http://localhost:8671/api/health', { method: 'GET' }); @@ -172,7 +172,7 @@ describe('MiscHandler', () => { // Set environment variable for test process.env.SANDBOX_VERSION = '1.2.3'; - const request = new Request('http://localhost:3000/api/version', { + const request = new Request('http://localhost:8671/api/version', { method: 'GET' }); @@ -194,7 +194,7 @@ describe('MiscHandler', () => { // Clear environment variable delete process.env.SANDBOX_VERSION; - const request = new Request('http://localhost:3000/api/version', { + const request = new Request('http://localhost:8671/api/version', { method: 'GET' }); @@ -208,7 +208,7 @@ describe('MiscHandler', () => { it('should include CORS headers in version response', async () => { process.env.SANDBOX_VERSION = '1.0.0'; - const request = new Request('http://localhost:3000/api/version', { + const request = new Request('http://localhost:8671/api/version', { method: 'GET' }); @@ -226,7 +226,7 @@ describe('MiscHandler', () => { describe('handleShutdown - POST /api/shutdown', () => { it('should return shutdown response with JSON content type', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { + const request = new Request('http://localhost:8671/api/shutdown', { method: 'POST' }); @@ -248,7 +248,7 @@ describe('MiscHandler', () => { }); it('should include CORS headers in shutdown response', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { + const request = new Request('http://localhost:8671/api/shutdown', { method: 'POST' }); @@ -264,7 +264,7 @@ describe('MiscHandler', () => { }); it('should handle shutdown requests with GET method', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { + const request = new Request('http://localhost:8671/api/shutdown', { method: 'GET' }); @@ -277,10 +277,10 @@ describe('MiscHandler', () => { }); it('should return unique timestamps for multiple shutdown requests', async () => { - const request1 = new Request('http://localhost:3000/api/shutdown', { + const request1 = new Request('http://localhost:8671/api/shutdown', { method: 'POST' }); - const request2 = new Request('http://localhost:3000/api/shutdown', { + const request2 = new Request('http://localhost:8671/api/shutdown', { method: 'POST' }); @@ -301,7 +301,7 @@ describe('MiscHandler', () => { describe('route handling', () => { it('should return 500 for invalid endpoints', async () => { - const request = new Request('http://localhost:3000/invalid-endpoint', { + const request = new Request('http://localhost:8671/invalid-endpoint', { method: 'GET' }); @@ -317,7 +317,7 @@ describe('MiscHandler', () => { }); it('should return 500 for non-existent API endpoints', async () => { - const request = new Request('http://localhost:3000/api/nonexistent', { + const request = new Request('http://localhost:8671/api/nonexistent', { method: 'GET' }); @@ -333,7 +333,7 @@ describe('MiscHandler', () => { }); it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/invalid', { + const request = new Request('http://localhost:8671/invalid', { method: 'GET' }); @@ -358,7 +358,7 @@ describe('MiscHandler', () => { ]; for (const endpoint of apiEndpoints) { - const request = new Request(`http://localhost:3000${endpoint.path}`, { + const request = new Request(`http://localhost:8671${endpoint.path}`, { method: 'GET' }); @@ -389,7 +389,7 @@ describe('MiscHandler', () => { ]; for (const endpoint of endpoints) { - const request = new Request(`http://localhost:3000${endpoint.path}`, { + const request = new Request(`http://localhost:8671${endpoint.path}`, { method: 'GET' }); @@ -412,7 +412,7 @@ describe('MiscHandler', () => { sessionId: 'session-alternative' }; - const request = new Request('http://localhost:3000/api/health', { + const request = new Request('http://localhost:8671/api/health', { method: 'GET' }); @@ -447,7 +447,7 @@ describe('MiscHandler', () => { const independentHandler = new MiscHandler(simpleLogger); - const request = new Request('http://localhost:3000/api/health', { + const request = new Request('http://localhost:8671/api/health', { method: 'GET' }); diff --git a/packages/sandbox-container/tests/handlers/port-handler.test.ts b/packages/sandbox-container/tests/handlers/port-handler.test.ts index 4cc2d077f..9dc205484 100644 --- a/packages/sandbox-container/tests/handlers/port-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/port-handler.test.ts @@ -99,7 +99,7 @@ describe('PortHandler', () => { data: mockPortInfo }); - const request = new Request('http://localhost:3000/api/expose-port', { + const request = new Request('http://localhost:8671/api/expose-port', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exposePortData) @@ -138,7 +138,7 @@ describe('PortHandler', () => { data: mockPortInfo }); - const request = new Request('http://localhost:3000/api/expose-port', { + const request = new Request('http://localhost:8671/api/expose-port', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exposePortData) @@ -167,7 +167,7 @@ describe('PortHandler', () => { } }); - const request = new Request('http://localhost:3000/api/expose-port', { + const request = new Request('http://localhost:8671/api/expose-port', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exposePortData) @@ -194,7 +194,7 @@ describe('PortHandler', () => { } }); - const request = new Request('http://localhost:3000/api/expose-port', { + const request = new Request('http://localhost:8671/api/expose-port', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exposePortData) @@ -218,7 +218,7 @@ describe('PortHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', + 'http://localhost:8671/api/exposed-ports/8080', { method: 'DELETE' } @@ -245,7 +245,7 @@ describe('PortHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', + 'http://localhost:8671/api/exposed-ports/8080', { method: 'DELETE' } @@ -263,7 +263,7 @@ describe('PortHandler', () => { it('should handle invalid port numbers in URL', async () => { const request = new Request( - 'http://localhost:3000/api/exposed-ports/invalid', + 'http://localhost:8671/api/exposed-ports/invalid', { method: 'DELETE' } @@ -284,7 +284,7 @@ describe('PortHandler', () => { it('should handle unsupported methods on exposed-ports endpoint', async () => { const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', + 'http://localhost:8671/api/exposed-ports/8080', { method: 'GET' // Not supported for individual ports } @@ -323,7 +323,7 @@ describe('PortHandler', () => { data: mockPorts }); - const request = new Request('http://localhost:3000/api/exposed-ports', { + const request = new Request('http://localhost:8671/api/exposed-ports', { method: 'GET' }); @@ -349,7 +349,7 @@ describe('PortHandler', () => { data: [] }); - const request = new Request('http://localhost:3000/api/exposed-ports', { + const request = new Request('http://localhost:8671/api/exposed-ports', { method: 'GET' }); @@ -371,7 +371,7 @@ describe('PortHandler', () => { } }); - const request = new Request('http://localhost:3000/api/exposed-ports', { + const request = new Request('http://localhost:8671/api/exposed-ports', { method: 'GET' }); @@ -397,7 +397,7 @@ describe('PortHandler', () => { mockProxyResponse ); - const request = new Request('http://localhost:3000/proxy/8080/api/data', { + const request = new Request('http://localhost:8671/proxy/8080/api/data', { method: 'GET', headers: { Authorization: 'Bearer token' } }); @@ -424,7 +424,7 @@ describe('PortHandler', () => { const requestBody = JSON.stringify({ data: 'test' }); const request = new Request( - 'http://localhost:3000/proxy/3000/api/create', + 'http://localhost:8671/proxy/3000/api/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -451,7 +451,7 @@ describe('PortHandler', () => { mockErrorResponse ); - const request = new Request('http://localhost:3000/proxy/9999/api/data', { + const request = new Request('http://localhost:8671/proxy/9999/api/data', { method: 'GET' }); @@ -463,7 +463,7 @@ describe('PortHandler', () => { }); it('should handle invalid proxy URL format', async () => { - const request = new Request('http://localhost:3000/proxy/', { + const request = new Request('http://localhost:8671/proxy/', { method: 'GET' }); @@ -482,7 +482,7 @@ describe('PortHandler', () => { it('should handle invalid port number in proxy URL', async () => { const request = new Request( - 'http://localhost:3000/proxy/invalid-port/api/data', + 'http://localhost:8671/proxy/invalid-port/api/data', { method: 'GET' } @@ -504,7 +504,7 @@ describe('PortHandler', () => { const proxyError = new Error('Connection refused'); (mockPortService.proxyRequest as any).mockRejectedValue(proxyError); - const request = new Request('http://localhost:3000/proxy/8080/api/data', { + const request = new Request('http://localhost:8671/proxy/8080/api/data', { method: 'GET' }); @@ -521,7 +521,7 @@ describe('PortHandler', () => { it('should handle non-Error exceptions in proxy', async () => { (mockPortService.proxyRequest as any).mockRejectedValue('String error'); - const request = new Request('http://localhost:3000/proxy/8080/api/data', { + const request = new Request('http://localhost:8671/proxy/8080/api/data', { method: 'GET' }); @@ -539,7 +539,7 @@ describe('PortHandler', () => { describe('route handling', () => { it('should return 500 for invalid endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/invalid-endpoint', + 'http://localhost:8671/api/invalid-endpoint', { method: 'GET' } @@ -556,7 +556,7 @@ describe('PortHandler', () => { }); it('should handle malformed exposed-ports URLs', async () => { - const request = new Request('http://localhost:3000/api/exposed-ports/', { + const request = new Request('http://localhost:8671/api/exposed-ports/', { method: 'DELETE' }); @@ -576,7 +576,7 @@ describe('PortHandler', () => { mockProxyResponse ); - const request = new Request('http://localhost:3000/proxy/8080/', { + const request = new Request('http://localhost:8671/proxy/8080/', { method: 'GET' }); @@ -595,7 +595,7 @@ describe('PortHandler', () => { data: [] }); - const request = new Request('http://localhost:3000/api/exposed-ports', { + const request = new Request('http://localhost:8671/api/exposed-ports', { method: 'GET' }); @@ -611,7 +611,7 @@ describe('PortHandler', () => { }); it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/invalid', { + const request = new Request('http://localhost:8671/api/invalid', { method: 'GET' }); @@ -659,7 +659,7 @@ describe('PortHandler', () => { statusCode: 200 }); - const request = new Request('http://localhost:3000/api/port-watch', { + const request = new Request('http://localhost:8671/api/port-watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 8080 }) @@ -685,7 +685,7 @@ describe('PortHandler', () => { data: { status: 'completed', exitCode: 0 } }); - const request = new Request('http://localhost:3000/api/port-watch', { + const request = new Request('http://localhost:8671/api/port-watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 8080, processId: 'proc-123' }) @@ -707,7 +707,7 @@ describe('PortHandler', () => { success: false }); - const request = new Request('http://localhost:3000/api/port-watch', { + const request = new Request('http://localhost:8671/api/port-watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 8080, processId: 'nonexistent' }) @@ -727,7 +727,7 @@ describe('PortHandler', () => { mockPortService.checkPortReady as ReturnType ).mockRejectedValue(new Error('Connection refused')); - const request = new Request('http://localhost:3000/api/port-watch', { + const request = new Request('http://localhost:8671/api/port-watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 8080 }) @@ -746,7 +746,7 @@ describe('PortHandler', () => { describe('URL parsing edge cases', () => { it('should handle ports with leading zeros', async () => { const request = new Request( - 'http://localhost:3000/api/exposed-ports/008080', + 'http://localhost:8671/api/exposed-ports/008080', { method: 'DELETE' } @@ -765,7 +765,7 @@ describe('PortHandler', () => { it('should handle very large port numbers', async () => { const request = new Request( - 'http://localhost:3000/api/exposed-ports/999999', + 'http://localhost:8671/api/exposed-ports/999999', { method: 'DELETE' } @@ -794,7 +794,7 @@ describe('PortHandler', () => { ); const request = new Request( - 'http://localhost:3000/proxy/8080/api/search?q=test&page=1', + 'http://localhost:8671/proxy/8080/api/search?q=test&page=1', { method: 'GET' } diff --git a/packages/sandbox-container/tests/handlers/process-handler.test.ts b/packages/sandbox-container/tests/handlers/process-handler.test.ts index fea5f00cb..be37e8ed2 100644 --- a/packages/sandbox-container/tests/handlers/process-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/process-handler.test.ts @@ -83,7 +83,7 @@ describe('ProcessHandler', () => { data: mockProcessInfo }); - const request = new Request('http://localhost:3000/api/process/start', { + const request = new Request('http://localhost:8671/api/process/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(startProcessData) @@ -118,7 +118,7 @@ describe('ProcessHandler', () => { } }); - const request = new Request('http://localhost:3000/api/process/start', { + const request = new Request('http://localhost:8671/api/process/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(startProcessData) @@ -172,7 +172,7 @@ describe('ProcessHandler', () => { data: mockProcesses }); - const request = new Request('http://localhost:3000/api/process/list', { + const request = new Request('http://localhost:8671/api/process/list', { method: 'GET' }); @@ -198,7 +198,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/list?sessionId=session-123&status=running', + 'http://localhost:8671/api/process/list?sessionId=session-123&status=running', { method: 'GET' } @@ -224,7 +224,7 @@ describe('ProcessHandler', () => { } }); - const request = new Request('http://localhost:3000/api/process/list', { + const request = new Request('http://localhost:8671/api/process/list', { method: 'GET' }); @@ -261,7 +261,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123', + 'http://localhost:8671/api/process/proc-123', { method: 'GET' } @@ -288,7 +288,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/nonexistent', + 'http://localhost:8671/api/process/nonexistent', { method: 'GET' } @@ -313,7 +313,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123', + 'http://localhost:8671/api/process/proc-123', { method: 'DELETE' } @@ -340,7 +340,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123', + 'http://localhost:8671/api/process/proc-123', { method: 'DELETE' } @@ -366,7 +366,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/kill-all', + 'http://localhost:8671/api/process/kill-all', { method: 'POST' } @@ -391,7 +391,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/kill-all', + 'http://localhost:8671/api/process/kill-all', { method: 'POST' } @@ -430,7 +430,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123/logs', + 'http://localhost:8671/api/process/proc-123/logs', { method: 'GET' } @@ -457,7 +457,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/nonexistent/logs', + 'http://localhost:8671/api/process/nonexistent/logs', { method: 'GET' } @@ -496,7 +496,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', + 'http://localhost:8671/api/process/proc-123/stream', { method: 'GET' } @@ -533,7 +533,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', + 'http://localhost:8671/api/process/proc-123/stream', { method: 'GET' } @@ -559,7 +559,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', + 'http://localhost:8671/api/process/proc-123/stream', { method: 'GET' } @@ -601,7 +601,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/proc-cancel/stream', + 'http://localhost:8671/api/process/proc-cancel/stream', { method: 'GET' } ); @@ -636,7 +636,7 @@ describe('ProcessHandler', () => { }); const request = new Request( - 'http://localhost:3000/api/process/invalid-endpoint', + 'http://localhost:8671/api/process/invalid-endpoint', { method: 'GET' } @@ -662,7 +662,7 @@ describe('ProcessHandler', () => { } }); - const request = new Request('http://localhost:3000/api/process/', { + const request = new Request('http://localhost:8671/api/process/', { method: 'GET' }); @@ -678,7 +678,7 @@ describe('ProcessHandler', () => { it('should handle unsupported HTTP methods for process endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/process/proc-123', + 'http://localhost:8671/api/process/proc-123', { method: 'PUT' // Unsupported method } @@ -696,7 +696,7 @@ describe('ProcessHandler', () => { it('should handle unsupported actions on process endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/process/proc-123/unsupported-action', + 'http://localhost:8671/api/process/proc-123/unsupported-action', { method: 'GET' } @@ -720,7 +720,7 @@ describe('ProcessHandler', () => { data: [] }); - const request = new Request('http://localhost:3000/api/process/list', { + const request = new Request('http://localhost:8671/api/process/list', { method: 'GET' }); @@ -745,7 +745,7 @@ describe('ProcessHandler', () => { } }); - const request = new Request('http://localhost:3000/api/process/invalid', { + const request = new Request('http://localhost:8671/api/process/invalid', { method: 'GET' }); diff --git a/packages/sandbox-container/tests/handlers/session-handler.test.ts b/packages/sandbox-container/tests/handlers/session-handler.test.ts index b4bbf052a..682952f15 100644 --- a/packages/sandbox-container/tests/handlers/session-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/session-handler.test.ts @@ -70,7 +70,7 @@ describe('SessionHandler', () => { data: mockSession }); - const request = new Request('http://localhost:3000/api/session/create', { + const request = new Request('http://localhost:8671/api/session/create', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -101,7 +101,7 @@ describe('SessionHandler', () => { } }); - const request = new Request('http://localhost:3000/api/session/create', { + const request = new Request('http://localhost:8671/api/session/create', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -127,10 +127,10 @@ describe('SessionHandler', () => { .mockResolvedValueOnce({ success: true, data: mockSession1 }) .mockResolvedValueOnce({ success: true, data: mockSession2 }); - const request1 = new Request('http://localhost:3000/api/session/create', { + const request1 = new Request('http://localhost:8671/api/session/create', { method: 'POST' }); - const request2 = new Request('http://localhost:3000/api/session/create', { + const request2 = new Request('http://localhost:8671/api/session/create', { method: 'POST' }); @@ -160,7 +160,7 @@ describe('SessionHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/session/delete', { + const request = new Request('http://localhost:8671/api/session/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'test-session-123' }) @@ -196,7 +196,7 @@ describe('SessionHandler', () => { } }); - const request = new Request('http://localhost:3000/api/session/delete', { + const request = new Request('http://localhost:8671/api/session/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'nonexistent' }) @@ -217,7 +217,7 @@ describe('SessionHandler', () => { }); it('should reject requests without sessionId', async () => { - const request = new Request('http://localhost:3000/api/session/delete', { + const request = new Request('http://localhost:8671/api/session/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) @@ -236,7 +236,7 @@ describe('SessionHandler', () => { }); it('should reject requests with invalid JSON', async () => { - const request = new Request('http://localhost:3000/api/session/delete', { + const request = new Request('http://localhost:8671/api/session/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'invalid json' @@ -259,7 +259,7 @@ describe('SessionHandler', () => { success: true }); - const request = new Request('http://localhost:3000/api/session/delete', { + const request = new Request('http://localhost:8671/api/session/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'test-session' }) @@ -288,7 +288,7 @@ describe('SessionHandler', () => { data: mockSessionIds }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -314,7 +314,7 @@ describe('SessionHandler', () => { data: [] }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -337,7 +337,7 @@ describe('SessionHandler', () => { data: mockSessionIds }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -365,7 +365,7 @@ describe('SessionHandler', () => { } }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -391,7 +391,7 @@ describe('SessionHandler', () => { data: mockSessionIds }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -408,7 +408,7 @@ describe('SessionHandler', () => { describe('route handling', () => { it('should return 500 for invalid session endpoints', async () => { const request = new Request( - 'http://localhost:3000/api/session/invalid-operation', + 'http://localhost:8671/api/session/invalid-operation', { method: 'POST' } @@ -430,7 +430,7 @@ describe('SessionHandler', () => { }); it('should return 500 for root session path', async () => { - const request = new Request('http://localhost:3000/api/session/', { + const request = new Request('http://localhost:8671/api/session/', { method: 'GET' }); @@ -445,7 +445,7 @@ describe('SessionHandler', () => { }); it('should return 500 for session endpoint without operation', async () => { - const request = new Request('http://localhost:3000/api/session', { + const request = new Request('http://localhost:8671/api/session', { method: 'GET' }); @@ -469,7 +469,7 @@ describe('SessionHandler', () => { data: mockSession }); - const request = new Request('http://localhost:3000/api/session/create', { + const request = new Request('http://localhost:8671/api/session/create', { method: 'POST' }); @@ -491,7 +491,7 @@ describe('SessionHandler', () => { data: [] }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -502,7 +502,7 @@ describe('SessionHandler', () => { }); it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/session/invalid', { + const request = new Request('http://localhost:8671/api/session/invalid', { method: 'GET' }); @@ -524,7 +524,7 @@ describe('SessionHandler', () => { }); const createRequest = new Request( - 'http://localhost:3000/api/session/create', + 'http://localhost:8671/api/session/create', { method: 'POST' } @@ -545,7 +545,7 @@ describe('SessionHandler', () => { }); const listRequest = new Request( - 'http://localhost:3000/api/session/list', + 'http://localhost:8671/api/session/list', { method: 'GET' } @@ -566,7 +566,7 @@ describe('SessionHandler', () => { data: mockSession }); - const request = new Request('http://localhost:3000/api/session/create', { + const request = new Request('http://localhost:8671/api/session/create', { method: 'POST' }); @@ -591,7 +591,7 @@ describe('SessionHandler', () => { data: mockSessionIds }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); @@ -615,7 +615,7 @@ describe('SessionHandler', () => { data: mockSessionIds }); - const request = new Request('http://localhost:3000/api/session/list', { + const request = new Request('http://localhost:8671/api/session/list', { method: 'GET' }); diff --git a/packages/sandbox-container/tests/handlers/watch-handler.test.ts b/packages/sandbox-container/tests/handlers/watch-handler.test.ts index ccf0b0623..8df2cbc70 100644 --- a/packages/sandbox-container/tests/handlers/watch-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/watch-handler.test.ts @@ -10,7 +10,7 @@ function createMockWatchService(): WatchService { } function makeRequest(body: Record): Request { - return new Request('http://localhost:3000/api/watch', { + return new Request('http://localhost:8671/api/watch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) diff --git a/packages/sandbox-container/tests/security/security-service.test.ts b/packages/sandbox-container/tests/security/security-service.test.ts index a53f2177d..be7535a1f 100644 --- a/packages/sandbox-container/tests/security/security-service.test.ts +++ b/packages/sandbox-container/tests/security/security-service.test.ts @@ -2,7 +2,7 @@ * SecurityService Tests - Phase 0 Simplified Security Model * * Philosophy: Trust container isolation, only protect SDK control plane - * - Port 3000 is protected (SDK control plane) + * - Control plane port is protected * - Format validation only (null bytes, length limits) * - No content restrictions (no path blocking, no command blocking, no URL allowlists) */ @@ -83,8 +83,8 @@ describe('SecurityService - Simplified Security Model', () => { }); describe('validatePort - Protect control plane only', () => { - test('should block port 3000 (SDK control plane) - CRITICAL!', () => { - const result = service.validatePort(3000); + test('should block control plane port - CRITICAL!', () => { + const result = service.validatePort(8671); expect(result.isValid).toBe(false); expect(result.errors[0].message).toContain('control plane'); }); diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index ce53267d4..d18755fb8 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -1,5 +1,6 @@ # Bun version — override via --build-arg BUN_VERSION=$(cat .bun-version) ARG BUN_VERSION=1 +ARG BUILD_INTERNAL_SERVER_PORT=8671 FROM oven/bun:${BUN_VERSION} AS bun-binary # Sandbox container images (default and python variants) @@ -147,8 +148,9 @@ COPY --from=builder /app/packages/sandbox-container/dist/index.js ./dist/ RUN mkdir -p /workspace -# Expose the application port (3000 for control) -EXPOSE 3000 +# Control plane port — only needed for local development with wrangler dev. +ARG BUILD_INTERNAL_SERVER_PORT +EXPOSE ${BUILD_INTERNAL_SERVER_PORT} # ============================================================================ # Stage 5a: Default image - lean, no Python @@ -214,7 +216,7 @@ ENV PYTHON_POOL_MIN_SIZE=0 ENV JAVASCRIPT_POOL_MIN_SIZE=3 ENV TYPESCRIPT_POOL_MIN_SIZE=3 -# Expose OpenCode server port (in addition to 3000 from runtime-base) +# Expose OpenCode server port (in addition to control port from runtime-base) EXPOSE 4096 ENTRYPOINT ["/container-server/sandbox"] @@ -279,6 +281,7 @@ ENTRYPOINT ["/container-server/sandbox"] FROM alpine:3.21 AS musl ARG SANDBOX_VERSION=unknown +ARG BUILD_INTERNAL_SERVER_PORT ENV SANDBOX_VERSION=${SANDBOX_VERSION} @@ -307,7 +310,7 @@ COPY --from=builder /app/packages/sandbox-container/dist/index.js ./dist/ RUN mkdir -p /workspace -EXPOSE 3000 +EXPOSE ${BUILD_INTERNAL_SERVER_PORT} ENV PYTHON_POOL_MIN_SIZE=0 ENV JAVASCRIPT_POOL_MIN_SIZE=0 diff --git a/packages/sandbox/scripts/docker-local.sh b/packages/sandbox/scripts/docker-local.sh index 0798e9c99..b0e7ba383 100755 --- a/packages/sandbox/scripts/docker-local.sh +++ b/packages/sandbox/scripts/docker-local.sh @@ -6,12 +6,14 @@ cd "$(dirname "$0")/../../.." VERSION="$npm_package_version" IMAGE="cloudflare/sandbox-test" +CONTROL_PORT="${BUILD_INTERNAL_SERVER_PORT:-8671}" docker build \ -f packages/sandbox/Dockerfile \ --target default \ --platform linux/amd64 \ --build-arg SANDBOX_VERSION="$VERSION" \ + --build-arg BUILD_INTERNAL_SERVER_PORT="$CONTROL_PORT" \ -t "$IMAGE:$VERSION" \ . @@ -20,6 +22,7 @@ docker build \ --target python \ --platform linux/amd64 \ --build-arg SANDBOX_VERSION="$VERSION" \ + --build-arg BUILD_INTERNAL_SERVER_PORT="$CONTROL_PORT" \ -t "$IMAGE:$VERSION-python" \ . @@ -28,6 +31,7 @@ docker build \ --target opencode \ --platform linux/amd64 \ --build-arg SANDBOX_VERSION="$VERSION" \ + --build-arg BUILD_INTERNAL_SERVER_PORT="$CONTROL_PORT" \ -t "$IMAGE:$VERSION-opencode" \ --secret id=wrangler_ca,src="${NODE_EXTRA_CA_CERTS:-/dev/null}" \ . @@ -37,6 +41,7 @@ docker build \ --target desktop \ --platform linux/amd64 \ --build-arg SANDBOX_VERSION="$VERSION" \ + --build-arg BUILD_INTERNAL_SERVER_PORT="$CONTROL_PORT" \ -t "$IMAGE:$VERSION-desktop" \ --secret id=wrangler_ca,src="${NODE_EXTRA_CA_CERTS:-/dev/null}" \ . @@ -56,6 +61,7 @@ docker build \ --target musl \ --platform linux/amd64 \ --build-arg SANDBOX_VERSION="$VERSION" \ + --build-arg BUILD_INTERNAL_SERVER_PORT="$CONTROL_PORT" \ -t "$IMAGE:$VERSION-musl" \ --secret id=wrangler_ca,src="${NODE_EXTRA_CA_CERTS:-/dev/null}" \ . diff --git a/packages/sandbox/src/clients/base-client.ts b/packages/sandbox/src/clients/base-client.ts index d3548dca9..967040a9f 100644 --- a/packages/sandbox/src/clients/base-client.ts +++ b/packages/sandbox/src/clients/base-client.ts @@ -1,5 +1,5 @@ import type { Logger } from '@repo/shared'; -import { createNoOpLogger } from '@repo/shared'; +import { createNoOpLogger, DEFAULT_CONTROL_PORT } from '@repo/shared'; import type { ErrorResponse as NewErrorResponse } from '../errors'; import { createErrorFromResponse, ErrorCode } from '../errors'; import { createTransport, type ITransport } from './transport'; @@ -32,7 +32,9 @@ export abstract class BaseHttpClient { const mode = options.transportMode ?? 'http'; this.transport = createTransport({ mode, - baseUrl: options.baseUrl ?? 'http://localhost:3000', + baseUrl: + options.baseUrl ?? + `http://localhost:${options.port ?? DEFAULT_CONTROL_PORT}`, wsUrl: options.wsUrl, logger: this.logger, stub: options.stub, diff --git a/packages/sandbox/src/clients/sandbox-client.ts b/packages/sandbox/src/clients/sandbox-client.ts index 18c9cf211..f8a711716 100644 --- a/packages/sandbox/src/clients/sandbox-client.ts +++ b/packages/sandbox/src/clients/sandbox-client.ts @@ -53,9 +53,7 @@ export class SandboxClient { }); } - // Ensure baseUrl is provided for all clients const clientOptions: HttpClientOptions = { - baseUrl: 'http://localhost:3000', ...options, // Share transport across all clients transport: this.transport ?? options.transport diff --git a/packages/sandbox/src/clients/transport/factory.ts b/packages/sandbox/src/clients/transport/factory.ts index 019cde9b3..0603f1818 100644 --- a/packages/sandbox/src/clients/transport/factory.ts +++ b/packages/sandbox/src/clients/transport/factory.ts @@ -21,13 +21,13 @@ export interface TransportOptions extends TransportConfig { * // HTTP transport (default) * const http = createTransport({ * mode: 'http', - * baseUrl: 'http://localhost:3000' + * baseUrl: 'http://localhost:' * }); * * // WebSocket transport * const ws = createTransport({ * mode: 'websocket', - * wsUrl: 'ws://localhost:3000/ws' + * wsUrl: 'ws://localhost:/ws' * }); * ``` */ diff --git a/packages/sandbox/src/clients/transport/http-transport.ts b/packages/sandbox/src/clients/transport/http-transport.ts index ffed4b9c8..c0c68f75b 100644 --- a/packages/sandbox/src/clients/transport/http-transport.ts +++ b/packages/sandbox/src/clients/transport/http-transport.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CONTROL_PORT } from '@repo/shared'; import { BaseTransport } from './base-transport'; import type { TransportConfig, TransportMode } from './types'; @@ -12,7 +13,9 @@ export class HttpTransport extends BaseTransport { constructor(config: TransportConfig) { super(config); - this.baseUrl = config.baseUrl ?? 'http://localhost:3000'; + this.baseUrl = + config.baseUrl ?? + `http://localhost:${config.port ?? DEFAULT_CONTROL_PORT}`; } getMode(): TransportMode { diff --git a/packages/sandbox/src/clients/transport/ws-transport.ts b/packages/sandbox/src/clients/transport/ws-transport.ts index c97d9324c..6ad11f3de 100644 --- a/packages/sandbox/src/clients/transport/ws-transport.ts +++ b/packages/sandbox/src/clients/transport/ws-transport.ts @@ -1,4 +1,5 @@ import { + DEFAULT_CONTROL_PORT, generateRequestId, isWSError, isWSResponse, @@ -252,7 +253,7 @@ export class WebSocketTransport extends BaseTransport { try { // Build the WebSocket URL for the container const wsPath = new URL(this.config.wsUrl!).pathname; - const httpUrl = `http://localhost:${this.config.port || 3000}${wsPath}`; + const httpUrl = `http://localhost:${this.config.port ?? DEFAULT_CONTROL_PORT}${wsPath}`; const response = await this.fetchUpgradeWithRetry(() => this.fetchUpgradeAttempt(httpUrl, timeoutMs) diff --git a/packages/sandbox/src/interpreter.ts b/packages/sandbox/src/interpreter.ts index e65d8b12b..0ee6c5ee5 100644 --- a/packages/sandbox/src/interpreter.ts +++ b/packages/sandbox/src/interpreter.ts @@ -13,13 +13,17 @@ import type { Sandbox } from './sandbox.js'; import { validateLanguage } from './security.js'; export class CodeInterpreter { - private interpreterClient: InterpreterClient; + private readonly sandbox: Sandbox; private contexts = new Map(); constructor(sandbox: Sandbox) { - // In init-testing architecture, client is a SandboxClient with an interpreter property - this.interpreterClient = (sandbox.client as any) - .interpreter as InterpreterClient; + this.sandbox = sandbox; + } + + // Resolved from the sandbox on each access so CodeInterpreter always + // uses the current interpreter client. + private get interpreterClient(): InterpreterClient { + return this.sandbox.client.interpreter; } /** diff --git a/packages/sandbox/src/local-mount-sync.ts b/packages/sandbox/src/local-mount-sync.ts index 1a753d3e9..0d8fdd001 100644 --- a/packages/sandbox/src/local-mount-sync.ts +++ b/packages/sandbox/src/local-mount-sync.ts @@ -18,7 +18,9 @@ interface LocalMountSyncOptions { mountPath: string; prefix: string | undefined; readOnly: boolean; - client: SandboxClient; + // Called for each sync operation so LocalMountSyncManager always uses + // the current sandbox client. + getClient: () => SandboxClient; sessionId: string; logger: Logger; pollIntervalMs?: number; @@ -36,7 +38,7 @@ export class LocalMountSyncManager { private readonly mountPath: string; private readonly prefix: string | undefined; private readonly readOnly: boolean; - private readonly client: SandboxClient; + private readonly getClient: () => SandboxClient; private readonly sessionId: string; private readonly logger: Logger; private readonly pollIntervalMs: number; @@ -57,7 +59,7 @@ export class LocalMountSyncManager { this.mountPath = options.mountPath; this.prefix = options.prefix; this.readOnly = options.readOnly; - this.client = options.client; + this.getClient = options.getClient; this.sessionId = options.sessionId; this.logger = options.logger.child({ operation: 'local-mount-sync' }); this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; @@ -72,7 +74,7 @@ export class LocalMountSyncManager { async start(): Promise { this.running = true; - await this.client.files.mkdir(this.mountPath, this.sessionId, { + await this.getClient().files.mkdir(this.mountPath, this.sessionId, { recursive: true }); @@ -216,7 +218,10 @@ export class LocalMountSyncManager { this.suppressEcho(containerPath); try { - await this.client.files.deleteFile(containerPath, this.sessionId); + await this.getClient().files.deleteFile( + containerPath, + this.sessionId + ); this.logger.debug('R2 -> Container: deleted file', { key }); } catch (error) { this.logger.error( @@ -262,9 +267,14 @@ export class LocalMountSyncManager { const arrayBuffer = await obj.arrayBuffer(); const base64 = uint8ArrayToBase64(new Uint8Array(arrayBuffer)); - await this.client.files.writeFile(containerPath, base64, this.sessionId, { - encoding: 'base64' - }); + await this.getClient().files.writeFile( + containerPath, + base64, + this.sessionId, + { + encoding: 'base64' + } + ); } private async ensureParentDir(containerPath: string): Promise { @@ -273,7 +283,7 @@ export class LocalMountSyncManager { containerPath.lastIndexOf('/') ); if (parentDir && parentDir !== this.mountPath) { - await this.client.files.mkdir(parentDir, this.sessionId, { + await this.getClient().files.mkdir(parentDir, this.sessionId, { recursive: true }); } @@ -329,7 +339,7 @@ export class LocalMountSyncManager { } private async runContainerWatchLoop(): Promise { - const stream = await this.client.watch.watch({ + const stream = await this.getClient().watch.watch({ path: this.mountPath, recursive: true, sessionId: this.sessionId @@ -397,7 +407,7 @@ export class LocalMountSyncManager { containerPath: string, r2Key: string ): Promise { - const result = await this.client.files.readFile( + const result = await this.getClient().files.readFile( containerPath, this.sessionId, { encoding: 'base64' } diff --git a/packages/sandbox/src/pty/proxy.ts b/packages/sandbox/src/pty/proxy.ts index bea06eb0d..17a81e967 100644 --- a/packages/sandbox/src/pty/proxy.ts +++ b/packages/sandbox/src/pty/proxy.ts @@ -1,4 +1,3 @@ -import { switchPort } from '@cloudflare/containers'; import type { PtyOptions } from '@repo/shared'; export async function proxyTerminal( @@ -24,5 +23,5 @@ export async function proxyTerminal( const ptyUrl = `http://localhost/ws/pty?${params}`; const ptyRequest = new Request(ptyUrl, request); - return stub.fetch(switchPort(ptyRequest, 3000)); + return stub.fetch(ptyRequest); } diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index bce5f4ee4..c6ffa37e7 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -1,5 +1,11 @@ import { switchPort } from '@cloudflare/containers'; -import { createLogger, type LogContext, TraceContext } from '@repo/shared'; +import { + createLogger, + DEFAULT_CONTROL_PORT, + getEnvString, + type LogContext, + TraceContext +} from '@repo/shared'; import { getSandbox, type Sandbox } from './sandbox'; import { sanitizeSandboxId, validatePort } from './security'; @@ -29,7 +35,13 @@ export async function proxyToSandbox< try { const url = new URL(request.url); - const routeInfo = extractSandboxRoute(url); + const envObj = env as Record; + const controlPortStr = getEnvString(envObj, 'SANDBOX_CONTROL_PORT'); + const controlPort = controlPortStr + ? parseInt(controlPortStr, 10) || DEFAULT_CONTROL_PORT + : DEFAULT_CONTROL_PORT; + + const routeInfo = extractSandboxRoute(url, controlPort); if (!routeInfo) { return null; // Not a request to an exposed container port @@ -40,8 +52,8 @@ export async function proxyToSandbox< const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true }); // Critical security check: Validate token (mandatory for all user ports) - // Skip check for control plane port 3000 - if (port !== 3000) { + // Skip check for control plane port + if (port !== controlPort) { // Validate the token matches the port const isValidToken = await sandbox.validatePortToken(port, token); if (!isValidToken) { @@ -81,13 +93,11 @@ export async function proxyToSandbox< // Build proxy request with proper headers let proxyUrl: string; - // Route based on the target port - if (port !== 3000) { - // Route directly to user's service on the specified port + // Route based on target port + if (port !== controlPort) { proxyUrl = `http://localhost:${port}${path}${url.search}`; } else { - // Port 3000 is our control plane - route normally - proxyUrl = `http://localhost:3000${path}${url.search}`; + proxyUrl = `http://localhost:${controlPort}${path}${url.search}`; } const headers: Record = { @@ -119,7 +129,7 @@ export async function proxyToSandbox< } } -function extractSandboxRoute(url: URL): RouteInfo | null { +function extractSandboxRoute(url: URL, controlPort: number): RouteInfo | null { // URL format: {port}-{sandboxId}-{token}.{domain} // Tokens are [a-z0-9_]+, so we split at the last hyphen to handle sandboxIds with hyphens (UUIDs) const dotIndex = url.hostname.indexOf('.'); @@ -142,7 +152,7 @@ function extractSandboxRoute(url: URL): RouteInfo | null { } const port = parseInt(portStr, 10); - if (!validatePort(port)) { + if (!validatePort(port, controlPort)) { return null; } diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 396f9e210..3410a286e 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -33,6 +33,7 @@ import type { } from '@repo/shared'; import { createLogger, + DEFAULT_CONTROL_PORT, filterEnvVars, getEnvString, isTerminalStatus, @@ -113,6 +114,11 @@ type ConfigurableSandboxStub = { ) => Promise; }; +const LEGACY_CONTROL_PORT = 3000; + +/** Header used by @cloudflare/containers switchPort() to target a specific container port. */ +const CONTAINER_TARGET_PORT_HEADER = 'cf-container-target-port'; + const sandboxConfigurationCache = new WeakMap< object, Map @@ -375,9 +381,9 @@ export function connect(stub: { fetch: (request: Request) => Promise; }) { return async (request: Request, port: number) => { - if (!validatePort(port)) { + if (!Number.isInteger(port) || port < 1024 || port > 65535) { throw new SecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + `Invalid port number: ${port}. Must be an integer between 1024 and 65535.` ); } const portSwitchedRequest = switchPort(request, port); @@ -405,7 +411,7 @@ function isR2Bucket(value: unknown): value is R2Bucket { } export class Sandbox extends Container implements ISandbox { - defaultPort = 3000; // Default port for the container's Bun server + defaultPort = DEFAULT_CONTROL_PORT; // Overridden in constructor if SANDBOX_CONTROL_PORT env var is set sleepAfter: string | number = '10m'; // Sleep the sandbox if no requests are made in this timeframe client: SandboxClient; @@ -540,17 +546,19 @@ export class Sandbox extends Container implements ISandbox { * Create a SandboxClient with current transport settings */ private createSandboxClient(): SandboxClient { + const port = this.defaultPort; return new SandboxClient({ logger: this.logger, - port: 3000, + port, stub: this, + baseUrl: `http://localhost:${port}`, retryTimeoutMs: this.computeRetryTimeoutMs(), defaultHeaders: { 'X-Sandbox-Id': this.ctx.id.toString() }, ...(this.transport === 'websocket' && { transportMode: 'websocket' as const, - wsUrl: 'ws://localhost:3000/ws' + wsUrl: `ws://localhost:${port}/ws` }) }); } @@ -559,6 +567,14 @@ export class Sandbox extends Container implements ISandbox { super(ctx, env); const envObj = env as Record; + + // Read control port from env (must happen before createSandboxClient) + const controlPortStr = getEnvString(envObj, 'SANDBOX_CONTROL_PORT'); + if (controlPortStr) { + this.defaultPort = parseInt(controlPortStr, 10) || DEFAULT_CONTROL_PORT; + } + this.envVars.SANDBOX_CONTROL_PORT = String(this.defaultPort); + // Set sandbox environment variables from env object const sandboxEnvKeys = ['SANDBOX_LOG_LEVEL', 'SANDBOX_LOG_FORMAT'] as const; sandboxEnvKeys.forEach((key) => { if (envObj?.[key]) { @@ -960,7 +976,7 @@ export class Sandbox extends Container implements ISandbox { mountPath, prefix: options.prefix, readOnly: options.readOnly ?? false, - client: this.client, + getClient: () => this.client, sessionId, logger: this.logger }); @@ -1449,156 +1465,267 @@ export class Sandbox extends Container implements ISandbox { portOrInit?: number | RequestInit, portParam?: number ): Promise { - // Parse arguments to extract request and port const { request, port } = this.parseContainerFetchArgs( requestOrUrl, portOrInit, portParam ); + const originalDefaultPort = this.defaultPort; + const { needsStartup, staleStateDetected } = + await this.checkContainerState(); + if (needsStartup) { + try { + await this.startContainer(port, request.signal); + } catch (e) { + return this.handleStartupError(e, staleStateDetected); + } + } + + // After a legacy fallback, the current request's port may still reference the + // originally configured control port. Remap it to the actual (fallback) port. + const effectivePort = + port === originalDefaultPort && this.defaultPort === LEGACY_CONTROL_PORT + ? LEGACY_CONTROL_PORT + : port; + + return await super.containerFetch(request, effectivePort); + } + + /** + * Check container state and determine if startup is needed. + * Returns staleStateDetected flag and whether startup is required. + * + * Separated from the actual startup call so callers can run getState() + * outside their try/catch for startup errors — a getState() failure should + * propagate directly, not be misclassified as a container startup error. + */ + private async checkContainerState(): Promise<{ + needsStartup: boolean; + staleStateDetected: boolean; + }> { const state = await this.getState(); const containerRunning = this.ctx.container?.running; - - // Start container if persisted state is not healthy OR if runtime reports container is not running. - // The runtime check catches stale persisted state (e.g., state says 'healthy' after DO recreation - // but Docker container is gone). const staleStateDetected = state.status === 'healthy' && containerRunning === false; - if (state.status !== 'healthy' || containerRunning === false) { - try { - await this.startAndWaitForPorts({ - ports: port, - cancellationOptions: { - instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS, - portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS, - waitInterval: this.containerTimeouts.waitIntervalMS, - abort: request.signal - } - }); - } catch (e) { - // 1. Provisioning: Container VM not yet available - if (this.isNoInstanceError(e)) { - const errorBody: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: - 'Container is currently provisioning. This can take several minutes on first deployment.', - context: { phase: 'provisioning' }, - httpStatus: 503, - timestamp: new Date().toISOString(), - suggestion: - 'This is expected during first deployment. The SDK will retry automatically.' - }; - return new Response(JSON.stringify(errorBody), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '10' - } - }); - } + const needsStartup = + state.status !== 'healthy' || containerRunning === false; + return { needsStartup, staleStateDetected }; + } - // 2. Permanent errors: Resource exhaustion, misconfiguration, bad image - // These will never recover on retry — fail fast so the caller gets a clear signal. - // Checked before transient to avoid broad transient patterns (e.g., "container did not - // start") masking specific permanent causes in wrapped error messages. - if (this.isPermanentStartupError(e)) { - this.logger.error( - 'Permanent container startup error, returning 500', - e instanceof Error ? e : new Error(String(e)) - ); - const errorBody: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: - 'Container failed to start due to a permanent error. Check your container configuration.', - context: { - phase: 'startup', - error: e instanceof Error ? e.message : String(e) - }, - httpStatus: 500, - timestamp: new Date().toISOString(), - suggestion: - 'This error will not resolve with retries. Check container logs, image name, and resource limits.' - }; - return new Response(JSON.stringify(errorBody), { - status: 500, - headers: { - 'Content-Type': 'application/json' - } - }); + /** + * Start the container with legacy port fallback if needed. + * Throws on startup failure — caller should use handleStartupError(). + */ + private async startContainer( + port: number, + signal?: AbortSignal + ): Promise { + this.logger.debug('Starting container with configured timeouts', { + instanceTimeout: this.containerTimeouts.instanceGetTimeoutMS, + portTimeout: this.containerTimeouts.portReadyTimeoutMS + }); + await this.startWithLegacyFallback(port, signal); + } + + /** + * Classify a container startup error and return an appropriate HTTP error response. + * Covers provisioning, permanent, transient, and unrecognized error categories. + */ + private handleStartupError( + e: unknown, + staleStateDetected: boolean + ): Response { + const error = e instanceof Error ? e : new Error(String(e)); + + // 1. Provisioning: Container VM not yet available + if (this.isNoInstanceError(e)) { + logCanonicalEvent(this.logger, { + event: 'container.startup', + outcome: 'error', + durationMs: 0, + errorMessage: 'provisioning', + error, + staleStateDetected + }); + const errorBody: ErrorResponse = { + code: ErrorCode.INTERNAL_ERROR, + message: + 'Container is currently provisioning. This can take several minutes on first deployment.', + context: { phase: 'provisioning' }, + httpStatus: 503, + timestamp: new Date().toISOString(), + suggestion: + 'This is expected during first deployment. The SDK will retry automatically.' + }; + return new Response(JSON.stringify(errorBody), { + status: 503, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '10' } + }); + } - // 3. Transient startup errors: Container starting, port not ready yet - if (this.isTransientStartupError(e)) { - // If startup failed after detecting stale state, the container runtime is likely stuck - // (e.g., workerd can't restart after an unexpected container death). Abort the DO so the - // next request gets a fresh instance with a clean container binding. This mirrors the - // recovery pattern in the base Container class for 'Network connection lost' errors. - if (staleStateDetected) { - this.logger.warn('container.startup', { - outcome: 'stale_state_abort', - staleStateDetected: true, - error: e instanceof Error ? e.message : String(e) - }); - this.ctx.abort(); - } else { - this.logger.debug('container.startup', { - outcome: 'transient_error', - staleStateDetected, - error: e instanceof Error ? e.message : String(e) - }); - } - const errorBody: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: 'Container is starting. Please retry in a moment.', - context: { - phase: 'startup', - error: e instanceof Error ? e.message : String(e) - }, - httpStatus: 503, - timestamp: new Date().toISOString(), - suggestion: - 'The container is booting. The SDK will retry automatically.' - }; - return new Response(JSON.stringify(errorBody), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '3' - } - }); + // 2. Permanent errors: Resource exhaustion, misconfiguration, bad image + // Checked before transient to avoid broad transient patterns (e.g., "container did not + // start") masking specific permanent causes in wrapped error messages. + if (this.isPermanentStartupError(e)) { + logCanonicalEvent(this.logger, { + event: 'container.startup', + outcome: 'error', + durationMs: 0, + errorMessage: 'permanent', + error, + staleStateDetected + }); + const errorBody: ErrorResponse = { + code: ErrorCode.INTERNAL_ERROR, + message: + 'Container failed to start due to a permanent error. Check your container configuration.', + context: { phase: 'startup' }, + httpStatus: 500, + timestamp: new Date().toISOString(), + suggestion: + 'This error will not resolve with retries. Check container logs, image name, and resource limits.' + }; + return new Response(JSON.stringify(errorBody), { + status: 500, + headers: { + 'Content-Type': 'application/json' } + }); + } - // 4. Unrecognized errors: Treat as transient since retries are safe - // and new platform error messages may not yet be in our pattern list. - this.logger.warn('container.startup', { - outcome: 'unrecognized_error', - staleStateDetected, - error: e instanceof Error ? e.message : String(e) + // 3. Transient startup errors: Container starting, port not ready yet + if (this.isTransientStartupError(e)) { + // If startup failed after detecting stale state, the container runtime is likely stuck. + // Abort the DO so the next request gets a fresh instance with a clean container binding. + if (staleStateDetected) { + logCanonicalEvent(this.logger, { + event: 'container.startup', + outcome: 'error', + durationMs: 0, + errorMessage: 'stale_state_abort', + error, + staleStateDetected: true }); - const errorBody: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: 'Container is starting. Please retry in a moment.', - context: { - phase: 'startup', - error: e instanceof Error ? e.message : String(e) + this.ctx.abort(); + } else { + logCanonicalEvent( + this.logger, + { + event: 'container.startup', + outcome: 'error', + durationMs: 0, + errorMessage: 'transient', + error, + staleStateDetected }, - httpStatus: 503, - timestamp: new Date().toISOString(), - suggestion: - 'The SDK will retry automatically. If this persists, the container may need redeployment.' - }; - return new Response(JSON.stringify(errorBody), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '5' + { successLevel: 'debug' } + ); + } + const errorBody: ErrorResponse = { + code: ErrorCode.INTERNAL_ERROR, + message: 'Container is starting. Please retry in a moment.', + context: { phase: 'startup' }, + httpStatus: 503, + timestamp: new Date().toISOString(), + suggestion: + 'The container is booting. The SDK will retry automatically.' + }; + return new Response(JSON.stringify(errorBody), { + status: 503, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '3' + } + }); + } + + // 4. Unrecognized errors: Treat as transient since retries are safe + // and new platform error messages may not yet be in our pattern list. + logCanonicalEvent(this.logger, { + event: 'container.startup', + outcome: 'error', + durationMs: 0, + errorMessage: 'unrecognized', + error, + staleStateDetected + }); + const errorBody: ErrorResponse = { + code: ErrorCode.INTERNAL_ERROR, + message: 'Container is starting. Please retry in a moment.', + context: { phase: 'startup' }, + httpStatus: 503, + timestamp: new Date().toISOString(), + suggestion: + 'The SDK will retry automatically. If this persists, the container may need redeployment.' + }; + return new Response(JSON.stringify(errorBody), { + status: 503, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '5' + } + }); + } + + /** + * Start the container and wait for the requested port, with backwards-compatible + * fallback to port 3000 for older container images that predate SANDBOX_CONTROL_PORT. + * Succeeds silently (with or without fallback) or throws the original error. + */ + private async startWithLegacyFallback( + port: number, + signal?: AbortSignal + ): Promise { + try { + await this.startAndWaitForPorts({ + ports: port, + cancellationOptions: { + instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS, + portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS, + waitInterval: this.containerTimeouts.waitIntervalMS, + abort: signal + } + }); + } catch (e) { + // Fallback only applies to the control port when the container is running + // but not listening on the expected port (version mismatch). + // Throw original error + if ( + port !== this.defaultPort || + port === LEGACY_CONTROL_PORT || + !this.ctx.container?.running + ) { + throw e; + } + + try { + await this.startAndWaitForPorts({ + ports: LEGACY_CONTROL_PORT, + cancellationOptions: { + portReadyTimeoutMS: 10_000, + waitInterval: this.containerTimeouts.waitIntervalMS, + abort: signal } }); + } catch { + throw e; } - } - // Delegate to parent for the actual fetch (handles TCP port access internally) - return await super.containerFetch(requestOrUrl, portOrInit, portParam); + this.logger.warn( + `Container responded on legacy port ${LEGACY_CONTROL_PORT} instead of ${port}. ` + + 'Your Docker image does not support the SANDBOX_CONTROL_PORT environment variable. ' + + 'Update your Dockerfile base image to match the SDK version. ' + + 'This fallback will be removed in a future release.' + ); + this.defaultPort = LEGACY_CONTROL_PORT; + // Rebuild the client after updating the control port. + // Long-lived helpers read it from the sandbox when they need it. + this.client = this.createSandboxClient(); + } } /** @@ -1779,14 +1906,59 @@ export class Sandbox extends Container implements ISandbox { connectionHeader?.toLowerCase().includes('upgrade'); if (isWebSocket) { - // WebSocket path: Let parent Container class handle WebSocket proxying - // This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades + // WebSocket path: Let parent Container class handle WebSocket proxying. + // This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades. + const switchedPort = this.getExplicitPort(request); + const hasRawHeader = request.headers.has(CONTAINER_TARGET_PORT_HEADER); + + // Validate only when a specific port was explicitly targeted (via switchPort). + // Without a switched port, this is a control-plane WebSocket (PTY, /ws, /api/ws) + // routed to defaultPort by Container.fetch() — no validation needed. + if ( + switchedPort !== null && + !validatePort(switchedPort, this.defaultPort) + ) { + requestLogger.warn( + 'WebSocket connection rejected: invalid target port', + { + port: switchedPort, + path: url.pathname + } + ); + return new Response( + `Invalid port for WebSocket connection: ${switchedPort}. Must be 1024-65535, excluding ${this.defaultPort} (sandbox control plane).`, + { status: 403 } + ); + } + + // If the header was present but malformed (getExplicitPort returned null), + // strip it so Container.fetch() doesn't re-parse it with its own lenient parseInt. + let wsRequest = request; + if (hasRawHeader && switchedPort === null) { + const sanitizedHeaders = new Headers(request.headers); + sanitizedHeaders.delete(CONTAINER_TARGET_PORT_HEADER); + wsRequest = new Request(request, { headers: sanitizedHeaders }); + } + + // Ensure the container is started before proxying the WebSocket upgrade. + // Without this, super.fetch() would hit an unresponsive or missing container. + const startupPort = switchedPort ?? this.defaultPort; + const { needsStartup: wsNeedsStartup, staleStateDetected: wsStaleState } = + await this.checkContainerState(); + if (wsNeedsStartup) { + try { + await this.startContainer(startupPort, request.signal); + } catch (e) { + return this.handleStartupError(e, wsStaleState); + } + } + try { requestLogger.debug('WebSocket upgrade requested', { path: url.pathname, - port: this.determinePort(url) + port: switchedPort ?? this.defaultPort }); - return await super.fetch(request); + return await super.fetch(wsRequest); } catch (error) { requestLogger.error( 'WebSocket connection failed', @@ -1798,7 +1970,7 @@ export class Sandbox extends Container implements ISandbox { } // Non-WebSocket: Use existing port determination and HTTP routing logic - const port = this.determinePort(url); + const port = this.resolvePort(url, request); // Route to the appropriate port return await this.containerFetch(request, port); @@ -1811,16 +1983,41 @@ export class Sandbox extends Container implements ISandbox { ); } - private determinePort(url: URL): number { - // Extract port from proxy requests (e.g., /proxy/8080/*) - const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/); - if (proxyMatch) { - return parseInt(proxyMatch[1], 10); + /** + * Read the port explicitly set by switchPort() from @cloudflare/containers. + * Returns null when no explicit port was set (control-plane requests). + */ + private getExplicitPort(request: Request): number | null { + const raw = request.headers.get(CONTAINER_TARGET_PORT_HEADER); + if (raw === null) return null; + + // Strict digit-only parse. Malformed values (e.g. "8080abc", "8080.5") + // did not come from switchPort() and are treated as absent. + if (!/^\d+$/.test(raw)) { + this.logger.warn( + `Ignoring malformed ${CONTAINER_TARGET_PORT_HEADER} header`, + { + value: raw + } + ); + return null; } + return parseInt(raw, 10); + } - // All other requests go to control plane on port 3000 - // This includes /api/* endpoints and any other control requests - return 3000; + /** Extract port from proxy URL path (/proxy/8080/*), null if no match. */ + private getProxyPort(url: URL): number | null { + const match = url.pathname.match(/^\/proxy\/(\d+)/); + return match ? parseInt(match[1], 10) : null; + } + + /** Resolve target port for HTTP requests: explicit port → proxy path → default. */ + private resolvePort(url: URL, request: Request): number { + return ( + this.getExplicitPort(request) ?? + this.getProxyPort(url) ?? + this.defaultPort + ); } /** @@ -2990,9 +3187,9 @@ export class Sandbox extends Container implements ISandbox { let outcome: 'success' | 'error' = 'error'; let caughtError: Error | undefined; try { - if (!validatePort(port)) { + if (!validatePort(port, this.defaultPort)) { throw new SecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + `Invalid port number: ${port}. Must be 1024-65535, excluding ${this.defaultPort} (sandbox control plane).` ); } @@ -3076,9 +3273,9 @@ export class Sandbox extends Container implements ISandbox { let outcome: 'success' | 'error' = 'error'; let caughtError: Error | undefined; try { - if (!validatePort(port)) { + if (!validatePort(port, this.defaultPort)) { throw new SecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + `Invalid port number: ${port}. Must be 1024-65535, excluding ${this.defaultPort} (sandbox control plane).` ); } const sessionId = await this.ensureDefaultSession(); @@ -3233,9 +3430,9 @@ export class Sandbox extends Container implements ISandbox { hostname: string, token: string ): string { - if (!validatePort(port)) { + if (!validatePort(port, this.defaultPort)) { throw new SecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + `Invalid port number: ${port}. Must be 1024-65535, excluding ${this.defaultPort} (sandbox control plane).` ); } diff --git a/packages/sandbox/src/security.ts b/packages/sandbox/src/security.ts index f597466c7..e9124234b 100644 --- a/packages/sandbox/src/security.ts +++ b/packages/sandbox/src/security.ts @@ -24,9 +24,9 @@ export class SecurityError extends Error { * * Rules: * - Range: 1024-65535 (privileged ports require root, which containers don't have) - * - Reserved: 3000 (sandbox control plane) + * - Reserved: Control plane port (default 8671) is reserved and cannot be used for user services */ -export function validatePort(port: number): boolean { +export function validatePort(port: number, controlPort: number): boolean { // Must be a valid integer if (!Number.isInteger(port)) { return false; @@ -37,9 +37,7 @@ export function validatePort(port: number): boolean { return false; } - const reservedPorts = [3000]; - - if (reservedPorts.includes(port)) { + if (port === controlPort) { return false; } diff --git a/packages/sandbox/tests/base-client.test.ts b/packages/sandbox/tests/base-client.test.ts index 9deac00cf..c8adfb945 100644 --- a/packages/sandbox/tests/base-client.test.ts +++ b/packages/sandbox/tests/base-client.test.ts @@ -87,7 +87,7 @@ class TestHttpClient extends BaseHttpClient { constructor(options: HttpClientOptions = {}) { super({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, ...options }); } @@ -137,7 +137,7 @@ describe('BaseHttpClient', () => { client = new TestHttpClient({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, onError }); }); @@ -476,7 +476,7 @@ describe('BaseHttpClient', () => { const stub = { containerFetch: stubFetch, fetch: vi.fn() }; const stubClient = new TestHttpClient({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, stub }); @@ -486,9 +486,9 @@ describe('BaseHttpClient', () => { expect(result.success).toBe(true); expect(result.source).toBe('stub'); expect(stubFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/stub-test', + 'http://localhost:8671/api/stub-test', { method: 'GET' }, - 3000 + 8671 ); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -500,7 +500,7 @@ describe('BaseHttpClient', () => { const stub = { containerFetch: stubFetch, fetch: vi.fn() }; const stubClient = new TestHttpClient({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, stub }); diff --git a/packages/sandbox/tests/command-client.test.ts b/packages/sandbox/tests/command-client.test.ts index 62097baa8..4d901be7d 100644 --- a/packages/sandbox/tests/command-client.test.ts +++ b/packages/sandbox/tests/command-client.test.ts @@ -24,7 +24,7 @@ describe('CommandClient', () => { client = new CommandClient({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, onCommandComplete, onError }); @@ -340,7 +340,7 @@ describe('CommandClient', () => { it('should work without any callbacks', async () => { const clientWithoutCallbacks = new CommandClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); const mockResponse: ExecuteResponse = { @@ -368,7 +368,7 @@ describe('CommandClient', () => { it('should handle errors gracefully without callbacks', async () => { const clientWithoutCallbacks = new CommandClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); mockFetch.mockRejectedValue(new Error('Network failed')); diff --git a/packages/sandbox/tests/desktop-client.test.ts b/packages/sandbox/tests/desktop-client.test.ts index 60dc25740..4d7364200 100644 --- a/packages/sandbox/tests/desktop-client.test.ts +++ b/packages/sandbox/tests/desktop-client.test.ts @@ -25,7 +25,7 @@ describe('DesktopClient', () => { client = new DesktopClient({ baseUrl: 'http://test.com', - port: 3000, + port: 8671, onError }); }); @@ -808,7 +808,7 @@ describe('DesktopClient', () => { it('should work without onError callback', async () => { const clientWithoutCallbacks = new DesktopClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); mockFetch.mockRejectedValue(new Error('Connection refused')); diff --git a/packages/sandbox/tests/file-client.test.ts b/packages/sandbox/tests/file-client.test.ts index ff266cab4..c74849341 100644 --- a/packages/sandbox/tests/file-client.test.ts +++ b/packages/sandbox/tests/file-client.test.ts @@ -30,7 +30,7 @@ describe('FileClient', () => { client = new FileClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); }); diff --git a/packages/sandbox/tests/git-client.test.ts b/packages/sandbox/tests/git-client.test.ts index 7a21d64a2..2127935d4 100644 --- a/packages/sandbox/tests/git-client.test.ts +++ b/packages/sandbox/tests/git-client.test.ts @@ -25,7 +25,7 @@ describe('GitClient', () => { client = new GitClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); }); diff --git a/packages/sandbox/tests/local-mount-sync.test.ts b/packages/sandbox/tests/local-mount-sync.test.ts index bcc3f4105..d18554d83 100644 --- a/packages/sandbox/tests/local-mount-sync.test.ts +++ b/packages/sandbox/tests/local-mount-sync.test.ts @@ -191,7 +191,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger }); @@ -248,7 +248,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger }); @@ -272,7 +272,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger }); @@ -303,7 +303,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 1000 @@ -347,7 +347,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 1000 @@ -386,7 +386,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 1000 @@ -426,7 +426,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 1000 @@ -464,7 +464,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: 'data/', readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger }); @@ -501,7 +501,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: 'uploads', readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger }); @@ -540,7 +540,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -595,7 +595,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -643,7 +643,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -686,7 +686,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -741,7 +741,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -782,7 +782,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -823,7 +823,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: 'uploads/', readOnly: false, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 60_000 @@ -864,7 +864,7 @@ describe('LocalMountSyncManager', () => { mountPath: '/mnt/data', prefix: undefined, readOnly: true, - client, + getClient: () => client, sessionId: 'test-session', logger, pollIntervalMs: 1000 diff --git a/packages/sandbox/tests/port-client.test.ts b/packages/sandbox/tests/port-client.test.ts index e01a02bc7..c23622e56 100644 --- a/packages/sandbox/tests/port-client.test.ts +++ b/packages/sandbox/tests/port-client.test.ts @@ -27,7 +27,7 @@ describe('PortClient', () => { client = new PortClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); }); diff --git a/packages/sandbox/tests/process-client.test.ts b/packages/sandbox/tests/process-client.test.ts index 1cb344da5..31c711284 100644 --- a/packages/sandbox/tests/process-client.test.ts +++ b/packages/sandbox/tests/process-client.test.ts @@ -27,7 +27,7 @@ describe('ProcessClient', () => { client = new ProcessClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); }); diff --git a/packages/sandbox/tests/request-handler.test.ts b/packages/sandbox/tests/request-handler.test.ts index 7c349fd19..83e7bf511 100644 --- a/packages/sandbox/tests/request-handler.test.ts +++ b/packages/sandbox/tests/request-handler.test.ts @@ -197,18 +197,14 @@ describe('proxyToSandbox - WebSocket Support', () => { }); }); - it('should reject reserved port 3000', async () => { - // Port 3000 is reserved as control plane port and rejected by validatePort() + it('should reject the default control port', async () => { const request = new Request( - 'https://3000-sandbox-anytoken12345678.example.com/status', - { - method: 'GET' - } + 'https://8671-sandbox-anytoken12345678.example.com/status', + { method: 'GET' } ); const response = await proxyToSandbox(request, mockEnv); - // Port 3000 is reserved and should be rejected (extractSandboxRoute returns null) expect(response).toBeNull(); expect(mockSandbox.validatePortToken).not.toHaveBeenCalled(); expect(mockSandbox.containerFetch).not.toHaveBeenCalled(); @@ -289,4 +285,42 @@ describe('proxyToSandbox - WebSocket Support', () => { expect(response?.status).toBe(500); }); }); + + describe('Custom control port via SANDBOX_CONTROL_PORT', () => { + it('should reject the custom control port in preview URLs', async () => { + const customEnv = { + ...mockEnv, + SANDBOX_CONTROL_PORT: '9500' + }; + + const request = new Request( + 'https://9500-sandbox-anytoken12345678.example.com/status', + { method: 'GET' } + ); + + const response = await proxyToSandbox(request, customEnv); + + expect(response).toBeNull(); + expect(mockSandbox.validatePortToken).not.toHaveBeenCalled(); + }); + + it('should allow port 3000 when custom control port is set', async () => { + const customEnv = { + ...mockEnv, + SANDBOX_CONTROL_PORT: '9500' + }; + + const request = new Request( + 'https://3000-sandbox-token12345678901.example.com/api', + { method: 'GET' } + ); + + await proxyToSandbox(request, customEnv); + + expect(mockSandbox.validatePortToken).toHaveBeenCalledWith( + 3000, + 'token12345678901' + ); + }); + }); }); diff --git a/packages/sandbox/tests/sandbox-control-port.test.ts b/packages/sandbox/tests/sandbox-control-port.test.ts new file mode 100644 index 000000000..37c406b56 --- /dev/null +++ b/packages/sandbox/tests/sandbox-control-port.test.ts @@ -0,0 +1,568 @@ +import { DEFAULT_CONTROL_PORT } from '@repo/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@cloudflare/containers', () => { + const MockContainer = class Container { + ctx: any; + env: any; + defaultPort?: number; + envVars: Record = {}; + sleepAfter: string | number = '10m'; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + async fetch() { + return new Response('mock'); + } + async containerFetch() { + return new Response('mock'); + } + async getState() { + return { status: 'healthy' }; + } + async startAndWaitForPorts() {} + renewActivityTimeout() {} + }; + + return { + Container: MockContainer, + getContainer: vi.fn(), + switchPort: vi.fn((req: Request) => req) + }; +}); + +import { connect, Sandbox } from '../src/sandbox'; +import { SecurityError } from '../src/security'; + +function createMockCtx() { + return { + storage: { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(new Map()) + }, + blockConcurrencyWhile: vi + .fn() + .mockImplementation((cb: () => Promise): Promise => cb()), + waitUntil: vi.fn(), + id: { + toString: () => 'test-id', + equals: vi.fn(), + name: 'test' + } + } as unknown as ConstructorParameters[0]; +} + +describe('Sandbox control port configuration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('defaults to DEFAULT_CONTROL_PORT when env var is not set', () => { + const sandbox = new Sandbox(createMockCtx(), {}); + expect(sandbox.defaultPort).toBe(DEFAULT_CONTROL_PORT); + }); + + it('reads SANDBOX_CONTROL_PORT from env', () => { + const sandbox = new Sandbox(createMockCtx(), { + SANDBOX_CONTROL_PORT: '9500' + }); + expect(sandbox.defaultPort).toBe(9500); + }); + + it('falls back to default for non-numeric SANDBOX_CONTROL_PORT', () => { + const sandbox = new Sandbox(createMockCtx(), { + SANDBOX_CONTROL_PORT: 'abc' + }); + expect(sandbox.defaultPort).toBe(DEFAULT_CONTROL_PORT); + }); + + it('propagates port to container via envVars', () => { + const sandbox = new Sandbox(createMockCtx(), { + SANDBOX_CONTROL_PORT: '9500' + }); + expect(sandbox.envVars.SANDBOX_CONTROL_PORT).toBe('9500'); + }); + + it('propagates default port to container via envVars', () => { + const sandbox = new Sandbox(createMockCtx(), {}); + expect(sandbox.envVars.SANDBOX_CONTROL_PORT).toBe( + String(DEFAULT_CONTROL_PORT) + ); + }); +}); + +describe('connect() with range-only validation', () => { + let mockStub: { fetch: ReturnType }; + let wsConnect: ReturnType; + let request: Request; + + beforeEach(() => { + mockStub = { fetch: vi.fn().mockResolvedValue(new Response('ok')) }; + wsConnect = connect(mockStub); + request = new Request('http://localhost/test'); + }); + + it('allows any port in valid range including DEFAULT_CONTROL_PORT', async () => { + await wsConnect(request, 8080); + expect(mockStub.fetch).toHaveBeenCalled(); + + mockStub.fetch.mockClear(); + // Control port passes at proxy level — DO validates authoritatively + await wsConnect(request, DEFAULT_CONTROL_PORT); + expect(mockStub.fetch).toHaveBeenCalled(); + }); + + it('rejects privileged ports (< 1024)', async () => { + await expect(wsConnect(request, 80)).rejects.toThrow(SecurityError); + expect(mockStub.fetch).not.toHaveBeenCalled(); + }); + + it('rejects out-of-range ports', async () => { + await expect(wsConnect(request, 65536)).rejects.toThrow(SecurityError); + expect(mockStub.fetch).not.toHaveBeenCalled(); + }); + + it('rejects non-integer ports', async () => { + await expect(wsConnect(request, 3.5)).rejects.toThrow(SecurityError); + expect(mockStub.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('WebSocket port validation in fetch() (header-based)', () => { + let sandbox: Sandbox; + let mockCtx: any; + let parentFetch: ReturnType; + + function wsRequest(path: string, targetPort?: string): Request { + const headers: Record = { + Upgrade: 'websocket', + Connection: 'upgrade' + }; + if (targetPort !== undefined) { + headers['cf-container-target-port'] = targetPort; + } + return new Request(`http://localhost${path}`, { headers }); + } + + beforeEach(async () => { + vi.clearAllMocks(); + mockCtx = createMockCtx(); + sandbox = new Sandbox(mockCtx, {}); + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + vi.spyOn(sandbox as any, 'getState').mockResolvedValue({ + status: 'healthy' + }); + (sandbox as any).ctx.container = { running: true }; + parentFetch = vi + .spyOn(Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), 'fetch') + .mockResolvedValue(new Response('ok')); + }); + + afterEach(() => { + parentFetch?.mockRestore(); + vi.restoreAllMocks(); + }); + + it('rejects header-switched WebSocket targeting the control port', async () => { + const response = await sandbox.fetch( + wsRequest('/ws', String(DEFAULT_CONTROL_PORT)) + ); + expect(response.status).toBe(403); + expect(parentFetch).not.toHaveBeenCalled(); + }); + + it('rejects header-switched WebSocket targeting a custom configured control port', async () => { + const customCtx = createMockCtx(); + const customSandbox = new Sandbox(customCtx, { + SANDBOX_CONTROL_PORT: '9500' + }); + await vi.waitFor(() => { + expect(customCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + vi.spyOn(customSandbox as any, 'getState').mockResolvedValue({ + status: 'healthy' + }); + (customSandbox as any).ctx.container = { running: true }; + + const response = await customSandbox.fetch(wsRequest('/ws', '9500')); + expect(response.status).toBe(403); + }); + + it('rejects header-switched WebSocket to out-of-range port', async () => { + const response = await sandbox.fetch(wsRequest('/ws', '80')); + expect(response.status).toBe(403); + expect(parentFetch).not.toHaveBeenCalled(); + }); + + it('allows header-switched WebSocket to a valid user port', async () => { + const response = await sandbox.fetch(wsRequest('/ws', '8080')); + expect(response.status).toBe(200); + expect(parentFetch).toHaveBeenCalled(); + }); + + it('allows control-plane WebSocket without target-port header', async () => { + const response = await sandbox.fetch(wsRequest('/ws/pty?sessionId=test')); + expect(response.status).toBe(200); + expect(parentFetch).toHaveBeenCalled(); + }); + + it('strips malformed target-port header before forwarding to Container.fetch()', async () => { + const response = await sandbox.fetch(wsRequest('/ws', '8080abc')); + expect(response.status).toBe(200); + expect(parentFetch).toHaveBeenCalled(); + + // Malformed header must be stripped so Container.fetch() can't re-parse it + const forwarded = parentFetch.mock.calls[0][0] as Request; + expect(forwarded.headers.has('cf-container-target-port')).toBe(false); + }); +}); + +describe('Legacy port fallback (startWithLegacyFallback)', () => { + let sandbox: Sandbox; + let mockCtx: any; + let startAndWaitSpy: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockCtx = createMockCtx(); + sandbox = new Sandbox(mockCtx, {}); + + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + startAndWaitSpy = vi.spyOn(sandbox as any, 'startAndWaitForPorts'); + + vi.spyOn(sandbox as any, 'getState').mockResolvedValue({ + status: 'unhealthy' + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('succeeds on configured port without fallback', async () => { + startAndWaitSpy.mockResolvedValueOnce(undefined); + + const parentFetch = vi + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), + 'containerFetch' + ) + .mockResolvedValueOnce(new Response('ok')); + + const response = await sandbox.containerFetch( + new Request('http://localhost/test'), + {}, + DEFAULT_CONTROL_PORT + ); + + expect(startAndWaitSpy).toHaveBeenCalledTimes(1); + expect(startAndWaitSpy).toHaveBeenCalledWith( + expect.objectContaining({ ports: DEFAULT_CONTROL_PORT }) + ); + expect(response.status).toBe(200); + parentFetch.mockRestore(); + }); + + it('falls back to port 3000 when control port fails and container is running', async () => { + startAndWaitSpy + .mockRejectedValueOnce(new Error('failed to verify port')) + .mockResolvedValueOnce(undefined); + + (sandbox as any).ctx.container = { running: true }; + + const parentFetch = vi + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), + 'containerFetch' + ) + .mockResolvedValueOnce(new Response('ok')); + + const response = await sandbox.containerFetch( + new Request('http://localhost/test'), + {}, + DEFAULT_CONTROL_PORT + ); + + expect(startAndWaitSpy).toHaveBeenCalledTimes(2); + expect(startAndWaitSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ ports: 3000 }) + ); + expect(sandbox.defaultPort).toBe(3000); + expect(response.status).toBe(200); + parentFetch.mockRestore(); + }); + + it('throws original error when both ports fail', async () => { + startAndWaitSpy + .mockRejectedValueOnce(new Error('failed to verify port')) + .mockRejectedValueOnce(new Error('legacy also failed')); + + (sandbox as any).ctx.container = { running: true }; + + const response = await sandbox.containerFetch( + new Request('http://localhost/test'), + {}, + DEFAULT_CONTROL_PORT + ); + + expect(startAndWaitSpy).toHaveBeenCalledTimes(2); + expect(response.status).toBe(503); + const body = (await response.json()) as { context: { phase: string } }; + expect(body.context.phase).toBe('startup'); + }); + + it('skips fallback when container is not running', async () => { + startAndWaitSpy.mockRejectedValueOnce(new Error('container did not start')); + + (sandbox as any).ctx.container = { running: false }; + + const response = await sandbox.containerFetch( + new Request('http://localhost/test'), + {}, + DEFAULT_CONTROL_PORT + ); + + expect(startAndWaitSpy).toHaveBeenCalledTimes(1); + expect(response.status).toBe(503); + }); + + it('skips fallback for non-control ports (preview URLs)', async () => { + startAndWaitSpy.mockRejectedValueOnce(new Error('failed to verify port')); + + (sandbox as any).ctx.container = { running: true }; + + const response = await sandbox.containerFetch( + new Request('http://localhost/test'), + 8080 + ); + + expect(startAndWaitSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ ports: 8080 }) + ); + expect(response.status).toBe(503); + expect(sandbox.defaultPort).toBe(DEFAULT_CONTROL_PORT); + }); + + it('skips fallback when configured port is already 3000', async () => { + const ctx3000 = createMockCtx(); + const sandbox3000 = new Sandbox(ctx3000, { + SANDBOX_CONTROL_PORT: '3000' + }); + await vi.waitFor(() => { + expect(ctx3000.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + const spy = vi + .spyOn(sandbox3000 as any, 'startAndWaitForPorts') + .mockRejectedValueOnce(new Error('failed to verify port')); + + vi.spyOn(sandbox3000 as any, 'getState').mockResolvedValue({ + status: 'unhealthy' + }); + (sandbox3000 as any).ctx.container = { running: true }; + + const response = await sandbox3000.containerFetch( + new Request('http://localhost/test'), + {}, + 3000 + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(response.status).toBe(503); + }); + + it('remaps port for current request after fallback', async () => { + startAndWaitSpy + .mockRejectedValueOnce(new Error('failed to verify port')) + .mockResolvedValueOnce(undefined); + + (sandbox as any).ctx.container = { running: true }; + + const parentFetch = vi + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), + 'containerFetch' + ) + .mockResolvedValueOnce(new Response('ok')); + + await sandbox.containerFetch( + new Request('http://localhost/test'), + {}, + DEFAULT_CONTROL_PORT + ); + + expect(parentFetch).toHaveBeenCalledWith(expect.any(Request), 3000); + parentFetch.mockRestore(); + }); + + it('remaps custom configured port after fallback', async () => { + const customCtx = createMockCtx(); + const customSandbox = new Sandbox(customCtx, { + SANDBOX_CONTROL_PORT: '9500' + }); + await vi.waitFor(() => { + expect(customCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + const customStartSpy = vi.spyOn( + customSandbox as any, + 'startAndWaitForPorts' + ); + customStartSpy + .mockRejectedValueOnce(new Error('failed to verify port')) + .mockResolvedValueOnce(undefined); + + vi.spyOn(customSandbox as any, 'getState').mockResolvedValue({ + status: 'unhealthy' + }); + (customSandbox as any).ctx.container = { running: true }; + + const parentFetch = vi + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(customSandbox)), + 'containerFetch' + ) + .mockResolvedValueOnce(new Response('ok')); + + await customSandbox.containerFetch( + new Request('http://localhost/test'), + {}, + 9500 + ); + + expect(parentFetch).toHaveBeenCalledWith(expect.any(Request), 3000); + parentFetch.mockRestore(); + }); +}); + +describe('CodeInterpreter client freshness after fallback', () => { + it('uses the current interpreter client, not a stale captured reference', async () => { + const mockCtx = createMockCtx(); + const sandbox = new Sandbox(mockCtx, {}); + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + // Capture the initial interpreter client + const initialClient = (sandbox as any).client.interpreter; + + // Simulate what startWithLegacyFallback does: recreate the client + (sandbox as any).defaultPort = 3000; + (sandbox as any).client = (sandbox as any).createSandboxClient(); + + // The new client's interpreter should be different + const newClient = (sandbox as any).client.interpreter; + expect(newClient).not.toBe(initialClient); + + // CodeInterpreter should resolve to the NEW client, not the stale one + const interpreterClient = (sandbox as any).codeInterpreter + .interpreterClient; + expect(interpreterClient).toBe(newClient); + }); +}); + +describe('WebSocket upgrade startup handling', () => { + let sandbox: Sandbox; + let mockCtx: any; + let parentFetch: ReturnType; + let startWithLegacyFallback: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCtx = createMockCtx(); + sandbox = new Sandbox(mockCtx, {}); + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + parentFetch = vi + .spyOn(Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), 'fetch') + .mockResolvedValue(new Response('ok')); + }); + + afterEach(() => { + parentFetch?.mockRestore(); + vi.restoreAllMocks(); + }); + + it('calls startWithLegacyFallback before super.fetch for control-plane WebSocket when container is not running', async () => { + vi.spyOn(sandbox as any, 'getState').mockResolvedValue({ + status: 'starting' + }); + (sandbox as any).ctx.container = { running: false }; + + startWithLegacyFallback = vi + .spyOn(sandbox as any, 'startWithLegacyFallback') + .mockResolvedValue(undefined); + + const request = new Request('http://localhost/ws', { + headers: { Upgrade: 'websocket', Connection: 'upgrade' } + }); + + await sandbox.fetch(request); + + expect(startWithLegacyFallback).toHaveBeenCalledWith( + sandbox.defaultPort, + expect.anything() + ); + expect(parentFetch).toHaveBeenCalled(); + }); + + it('uses explicit target port for switched-port WebSocket startup', async () => { + vi.spyOn(sandbox as any, 'getState').mockResolvedValue({ + status: 'starting' + }); + (sandbox as any).ctx.container = { running: false }; + + startWithLegacyFallback = vi + .spyOn(sandbox as any, 'startWithLegacyFallback') + .mockResolvedValue(undefined); + + const request = new Request('http://localhost/ws', { + headers: { + Upgrade: 'websocket', + Connection: 'upgrade', + 'cf-container-target-port': '8080' + } + }); + + await sandbox.fetch(request); + + expect(startWithLegacyFallback).toHaveBeenCalledWith( + 8080, + expect.anything() + ); + expect(parentFetch).toHaveBeenCalled(); + }); + + it('skips startup when container is already healthy and running', async () => { + vi.spyOn(sandbox as any, 'getState').mockResolvedValue({ + status: 'healthy' + }); + (sandbox as any).ctx.container = { running: true }; + + startWithLegacyFallback = vi + .spyOn(sandbox as any, 'startWithLegacyFallback') + .mockResolvedValue(undefined); + + const request = new Request('http://localhost/ws', { + headers: { Upgrade: 'websocket', Connection: 'upgrade' } + }); + + await sandbox.fetch(request); + + expect(startWithLegacyFallback).not.toHaveBeenCalled(); + expect(parentFetch).toHaveBeenCalled(); + }); +}); diff --git a/packages/sandbox/tests/sandbox-error-handling.test.ts b/packages/sandbox/tests/sandbox-error-handling.test.ts index 62f2d9379..752555e12 100644 --- a/packages/sandbox/tests/sandbox-error-handling.test.ts +++ b/packages/sandbox/tests/sandbox-error-handling.test.ts @@ -167,7 +167,7 @@ describe('Sandbox.containerFetch() error classification', () => { return sandbox.containerFetch( new Request('http://localhost/test'), {}, - 3000 + 8671 ); } @@ -193,7 +193,7 @@ describe('Sandbox.containerFetch() error classification', () => { it('returns 503 for "the container is not listening" (@cloudflare/containers)', async () => { const response = await triggerContainerFetchWithError( - 'the container is not listening on port 3000' + 'the container is not listening on port 8671' ); expect(response.status).toBe(503); @@ -211,7 +211,7 @@ describe('Sandbox.containerFetch() error classification', () => { it('returns 503 for "failed to verify port" (@cloudflare/containers)', async () => { const response = await triggerContainerFetchWithError( - 'Failed to verify port 3000 is available after 20000ms' + 'Failed to verify port 8671 is available after 20000ms' ); expect(response.status).toBe(503); @@ -329,8 +329,8 @@ describe('Sandbox.containerFetch() error classification', () => { expect(response.status).toBe(500); expect(response.headers.get('Retry-After')).toBeNull(); - const body = (await response.json()) as { context: { error: string } }; - expect(body.context.error).toContain('No such image'); + const body = (await response.json()) as { context: { phase: string } }; + expect(body.context.phase).toBe('startup'); }); }); describe('unrecognized errors → 503 (safe to retry)', () => { @@ -464,7 +464,7 @@ describe('Sandbox.containerFetch() error classification', () => { const response = await sandbox.containerFetch( new Request('http://localhost/test'), {}, - 3000 + 8671 ); // startAndWaitForPorts should NOT be called when healthy @@ -493,7 +493,7 @@ describe('Sandbox.containerFetch() error classification', () => { const response = await sandbox.containerFetch( new Request('http://localhost/test'), {}, - 3000 + 8671 ); expect(startAndWaitSpy).toHaveBeenCalledWith( @@ -522,7 +522,7 @@ describe('Sandbox.containerFetch() error classification', () => { const response = await sandbox.containerFetch( new Request('http://localhost/test'), {}, - 3000 + 8671 ); expect(abortSpy).toHaveBeenCalled(); @@ -543,7 +543,7 @@ describe('Sandbox.containerFetch() error classification', () => { const response = await sandbox.containerFetch( new Request('http://localhost/test'), {}, - 3000 + 8671 ); expect(abortSpy).not.toHaveBeenCalled(); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index a6c893f91..07589945d 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -310,7 +310,7 @@ describe('Sandbox - Automatic Session Management', () => { expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({ id: 'custom-session-123', - env: { NODE_ENV: 'test' }, + env: expect.objectContaining({ NODE_ENV: 'test' }), cwd: '/test' }); @@ -530,7 +530,10 @@ describe('Sandbox - Automatic Session Management', () => { expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({ id: expect.any(String), - env: { NODE_ENV: 'production', DEBUG: 'true' }, + env: expect.objectContaining({ + NODE_ENV: 'production', + DEBUG: 'true' + }), cwd: '/workspace' }); }); diff --git a/packages/sandbox/tests/security.test.ts b/packages/sandbox/tests/security.test.ts index 7d6f41270..afeeb9ac4 100644 --- a/packages/sandbox/tests/security.test.ts +++ b/packages/sandbox/tests/security.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CONTROL_PORT } from '@repo/shared'; import { describe, expect, it } from 'vitest'; import { SecurityError, @@ -5,33 +6,51 @@ import { validatePort } from '../src/security'; +const CONTROL_PORT = DEFAULT_CONTROL_PORT; + describe('validatePort', () => { - it('accepts valid user ports (1024-65535 except 3000)', () => { - expect(validatePort(1024)).toBe(true); // first non-privileged - expect(validatePort(8080)).toBe(true); // common - expect(validatePort(8787)).toBe(true); // was incorrectly blocked - this is the bug fix - expect(validatePort(65535)).toBe(true); // max + it('accepts valid user ports (1024-65535 except control port)', () => { + expect(validatePort(1024, CONTROL_PORT)).toBe(true); + expect(validatePort(3000, CONTROL_PORT)).toBe(true); + expect(validatePort(8080, CONTROL_PORT)).toBe(true); + expect(validatePort(8787, CONTROL_PORT)).toBe(true); + expect(validatePort(65535, CONTROL_PORT)).toBe(true); }); - it('rejects port 3000 (sandbox control plane)', () => { - expect(validatePort(3000)).toBe(false); + it('rejects the control port', () => { + expect(validatePort(CONTROL_PORT, CONTROL_PORT)).toBe(false); }); it('rejects privileged ports (< 1024)', () => { - expect(validatePort(0)).toBe(false); - expect(validatePort(80)).toBe(false); - expect(validatePort(1023)).toBe(false); // boundary + expect(validatePort(0, CONTROL_PORT)).toBe(false); + expect(validatePort(80, CONTROL_PORT)).toBe(false); + expect(validatePort(1023, CONTROL_PORT)).toBe(false); }); it('rejects out-of-range ports', () => { - expect(validatePort(-1)).toBe(false); - expect(validatePort(65536)).toBe(false); + expect(validatePort(-1, CONTROL_PORT)).toBe(false); + expect(validatePort(65536, CONTROL_PORT)).toBe(false); }); it('rejects non-integers', () => { - expect(validatePort(3000.5)).toBe(false); - expect(validatePort(NaN)).toBe(false); - expect(validatePort(Infinity)).toBe(false); + expect(validatePort(8080.5, CONTROL_PORT)).toBe(false); + expect(validatePort(NaN, CONTROL_PORT)).toBe(false); + expect(validatePort(Infinity, CONTROL_PORT)).toBe(false); + }); + + describe('with custom control port', () => { + it('rejects the custom control port', () => { + expect(validatePort(9500, 9500)).toBe(false); + }); + + it('accepts the default port when a different control port is specified', () => { + expect(validatePort(DEFAULT_CONTROL_PORT, 9500)).toBe(true); + }); + + it('still rejects privileged and out-of-range ports', () => { + expect(validatePort(80, 9500)).toBe(false); + expect(validatePort(65536, 9500)).toBe(false); + }); }); }); diff --git a/packages/sandbox/tests/transport.test.ts b/packages/sandbox/tests/transport.test.ts index b21c6e045..1d38d9980 100644 --- a/packages/sandbox/tests/transport.test.ts +++ b/packages/sandbox/tests/transport.test.ts @@ -22,7 +22,7 @@ describe('Transport', () => { it('should create transport in HTTP mode by default', () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); expect(transport.getMode()).toBe('http'); @@ -31,7 +31,7 @@ describe('Transport', () => { it('should make HTTP GET request', async () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); mockFetch.mockResolvedValue( @@ -44,7 +44,7 @@ describe('Transport', () => { const body = await response.json(); expect(body).toEqual({ data: 'test' }); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/test', + 'http://localhost:8671/api/test', expect.objectContaining({ method: 'GET' }) ); }); @@ -52,7 +52,7 @@ describe('Transport', () => { it('should make HTTP POST request with body', async () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); mockFetch.mockResolvedValue( @@ -67,7 +67,7 @@ describe('Transport', () => { expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/execute', + 'http://localhost:8671/api/execute', expect.objectContaining({ method: 'POST', body: JSON.stringify({ command: 'echo hello' }) @@ -78,7 +78,7 @@ describe('Transport', () => { it('should handle HTTP errors', async () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); mockFetch.mockResolvedValue( @@ -93,7 +93,7 @@ describe('Transport', () => { it('should stream HTTP responses', async () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); const mockStream = new ReadableStream({ @@ -124,17 +124,17 @@ describe('Transport', () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000', + baseUrl: 'http://localhost:8671', stub: { containerFetch: mockContainerFetch, fetch: vi.fn() }, - port: 3000 + port: 8671 }); await transport.fetch('/api/test', { method: 'GET' }); expect(mockContainerFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/test', + 'http://localhost:8671/api/test', expect.any(Object), - 3000 + 8671 ); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -147,7 +147,7 @@ describe('Transport', () => { it('should create transport in WebSocket mode', () => { const transport = createTransport({ mode: 'websocket', - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); expect(transport.getMode()).toBe('websocket'); @@ -156,7 +156,7 @@ describe('Transport', () => { it('should report WebSocket connection state', () => { const transport = createTransport({ mode: 'websocket', - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); // Initially not connected @@ -178,7 +178,7 @@ describe('Transport', () => { it('should create HTTP transport with minimal options', () => { const transport = createTransport({ mode: 'http', - baseUrl: 'http://localhost:3000' + baseUrl: 'http://localhost:8671' }); expect(transport).toBeInstanceOf(HttpTransport); @@ -188,7 +188,7 @@ describe('Transport', () => { it('should create WebSocket transport with URL', () => { const transport = createTransport({ mode: 'websocket', - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); expect(transport).toBeInstanceOf(WebSocketTransport); diff --git a/packages/sandbox/tests/utility-client.test.ts b/packages/sandbox/tests/utility-client.test.ts index 20f6196f1..3065b7903 100644 --- a/packages/sandbox/tests/utility-client.test.ts +++ b/packages/sandbox/tests/utility-client.test.ts @@ -51,7 +51,7 @@ describe('UtilityClient', () => { client = new UtilityClient({ baseUrl: 'http://test.com', - port: 3000 + port: 8671 }); }); diff --git a/packages/sandbox/tests/ws-transport.test.ts b/packages/sandbox/tests/ws-transport.test.ts index c41894252..0c90d6eea 100644 --- a/packages/sandbox/tests/ws-transport.test.ts +++ b/packages/sandbox/tests/ws-transport.test.ts @@ -303,7 +303,7 @@ describe('WebSocket Protocol Types', () => { vi.useFakeTimers(); try { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', requestTimeoutMs: 1000 }); @@ -355,7 +355,7 @@ describe('WebSocket Protocol Types', () => { vi.useFakeTimers(); try { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', requestTimeoutMs: 1000 }); @@ -419,7 +419,7 @@ describe('WebSocket Protocol Types', () => { vi.useFakeTimers(); try { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', streamIdleTimeoutMs: 1000 }); @@ -459,7 +459,7 @@ describe('WebSocket Protocol Types', () => { vi.useFakeTimers(); try { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', streamIdleTimeoutMs: 1000 }); @@ -513,7 +513,7 @@ describe('WebSocket Protocol Types', () => { vi.useFakeTimers(); try { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', streamIdleTimeoutMs: 100 }); @@ -590,14 +590,14 @@ describe('WebSocketTransport', () => { describe('initial state', () => { it('should not be connected after construction', () => { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); expect(transport.isConnected()).toBe(false); }); it('should accept custom options', () => { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', + wsUrl: 'ws://localhost:8671/ws', connectTimeoutMs: 5000, requestTimeoutMs: 60000 }); @@ -614,7 +614,7 @@ describe('WebSocketTransport', () => { describe('disconnect', () => { it('should be safe to call disconnect when not connected', () => { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); // Should not throw transport.disconnect(); @@ -623,7 +623,7 @@ describe('WebSocketTransport', () => { it('should be safe to call disconnect multiple times', () => { const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' + wsUrl: 'ws://localhost:8671/ws' }); transport.disconnect(); transport.disconnect(); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfda3a290..e9919bcdc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -169,7 +169,6 @@ export type { RemoteMountBucketOptions, RenameFileResult, RestoreBackupResult, - // Sandbox configuration options SandboxOptions, // Session management result types SessionCreateResult, @@ -187,6 +186,7 @@ export type { WriteFileResult } from './types.js'; export { + DEFAULT_CONTROL_PORT, isExecResult, isProcess, isProcessStatus, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a3bc97404..abfe782ea 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -439,6 +439,8 @@ export interface SessionOptions { } // Sandbox configuration options +export const DEFAULT_CONTROL_PORT = 8671; + export interface SandboxOptions { /** * Duration after which the sandbox instance will sleep if no requests are received diff --git a/tests/e2e/control-port-workflow.test.ts b/tests/e2e/control-port-workflow.test.ts new file mode 100644 index 000000000..dbe655c4e --- /dev/null +++ b/tests/e2e/control-port-workflow.test.ts @@ -0,0 +1,112 @@ +import { DEFAULT_CONTROL_PORT } from '@repo/shared'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { + cleanupTestSandbox, + createTestSandbox, + createUniqueSession, + type TestSandbox +} from './helpers/global-sandbox'; + +const skipPortExposureTests = + process.env.TEST_WORKER_URL?.endsWith('.workers.dev') ?? false; + +describe('Control Port Configuration', () => { + let sandbox: TestSandbox | null = null; + let workerUrl: string; + let headers: Record; + let portHeaders: Record; + + beforeAll(async () => { + sandbox = await createTestSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.headers(createUniqueSession()); + portHeaders = { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + afterAll(async () => { + await cleanupTestSandbox(sandbox); + sandbox = null; + }, 120000); + + test('container should bind to the default control port', async () => { + // Convert port to hex for /proc/net/tcp lookup (always available, no ss/netstat needed) + const portHex = DEFAULT_CONTROL_PORT.toString(16) + .toUpperCase() + .padStart(4, '0'); + const result = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `grep ':${portHex}' /proc/net/tcp` + }) + }); + + expect(result.status).toBe(200); + const data = (await result.json()) as { stdout: string; exitCode: number }; + expect(data.exitCode).toBe(0); + expect(data.stdout).toContain(portHex); + }, 90000); + + test('port 3000 should not be reserved by the control plane', async () => { + const result = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + "node -e \"const s = require('net').createServer(); s.listen(3000, () => { console.log('bound'); s.close(); })\"" + }) + }); + + expect(result.status).toBe(200); + const data = (await result.json()) as { + stdout: string; + stderr: string; + exitCode: number; + }; + expect(data.exitCode).toBe(0); + expect(data.stdout).toContain('bound'); + }, 90000); + + test.skipIf(skipPortExposureTests)( + 'should reject exposing the control port', + async () => { + const response = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ + port: DEFAULT_CONTROL_PORT, + name: 'control-plane' + }) + }); + + expect(response.status).toBeGreaterThanOrEqual(400); + const data = (await response.json()) as { error: string }; + expect(data.error).toMatch( + /reserved|not allowed|forbidden|invalid port/i + ); + }, + 90000 + ); + + test.skipIf(skipPortExposureTests)( + 'should allow exposing port 3000 as a user service', + async () => { + const response = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ + port: 3000, + name: 'user-app' + }) + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { port: number }; + expect(data.port).toBe(3000); + }, + 90000 + ); +}); diff --git a/tests/e2e/test-worker/Dockerfile.musl b/tests/e2e/test-worker/Dockerfile.musl index 4001d6ad2..69a7db300 100644 --- a/tests/e2e/test-worker/Dockerfile.musl +++ b/tests/e2e/test-worker/Dockerfile.musl @@ -1,7 +1,10 @@ +ARG BUILD_INTERNAL_SERVER_PORT=8671 FROM docker.io/cloudflare/sandbox-test:0.8.0-musl +ARG BUILD_INTERNAL_SERVER_PORT + # Install snapshot tools (Alpine uses apk) # squashfs-tools: create archives, squashfuse: mount, fuse-overlayfs: COW layer RUN apk add --no-cache squashfs-tools squashfuse fuse-overlayfs -EXPOSE 3000 8080 +EXPOSE ${BUILD_INTERNAL_SERVER_PORT} 8080 diff --git a/tests/e2e/test-worker/Dockerfile.standalone b/tests/e2e/test-worker/Dockerfile.standalone index 3debec268..f2a32b3f9 100644 --- a/tests/e2e/test-worker/Dockerfile.standalone +++ b/tests/e2e/test-worker/Dockerfile.standalone @@ -2,10 +2,13 @@ # This validates that users can add sandbox capabilities to any Docker image ARG BASE_IMAGE=cloudflare/sandbox-test:0.8.0 +ARG BUILD_INTERNAL_SERVER_PORT=8671 FROM ${BASE_IMAGE} AS sandbox-source FROM node:20-slim +ARG BUILD_INTERNAL_SERVER_PORT + # Install dependencies required by the SDK # - file: MIME type detection for file operations # - git: git clone/checkout operations @@ -27,7 +30,7 @@ RUN chmod +x /startup-test.sh WORKDIR /workspace -EXPOSE 3000 8080 +EXPOSE ${BUILD_INTERNAL_SERVER_PORT} 8080 # Standard Docker pattern: ENTRYPOINT is the executable, CMD is passed as arguments ENTRYPOINT ["/sandbox"]