diff --git a/README.md b/README.md index 7bff76fb..bad5b66e 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,10 @@ cut. Copy `.env.example` to `.env` and set `CODEAPI_BRIDGE_TOKEN` to a private value of at least 32 bytes (generate one with `openssl rand -hex 32`). The API exposes -bridge routes even with the default HTTP sandbox backend, so hardened mode -requires this enrollment credential. Compose defaults to +bridge routes when configured through the remote-bridge backend, paired auth, +dynamic workers, or a bridge token. Hardened deployments with none of these +configured leave bridge routes disabled and do not require a bridge token. +Enabled bridges still require this enrollment credential. Compose defaults to `CODEAPI_BRIDGE_AUTH_MODE=paired` and `CODEAPI_BRIDGE_DYNAMIC_WORKERS=true`. To restrict pairing to a fixed worker, set `CODEAPI_BRIDGE_DYNAMIC_WORKERS=false` and `CODEAPI_BRIDGE_WORKER_ID` to its ID. Keep the token outside workspaces and diff --git a/service/Dockerfile b/service/Dockerfile index 00680111..d7bf9036 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -1,5 +1,5 @@ # File Server Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/worker-server.ts --minify --outdir .build-worker --target bu RUN bun build ./src/egress-gateway.ts --minify --outdir .build-egress-gateway --target bun --external '@opentelemetry/*' # File server production -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -35,7 +35,7 @@ COPY --from=builder /app/.build ./.build CMD ["bun", "run", ".build/file-server.js"] # API server (HTTP on port 3112) -FROM oven/bun:1.3.14 AS api +FROM oven/bun:1.4.2 AS api ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -45,7 +45,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-api/api-server.js"] # Worker server (job processor, health on port 3113) -FROM oven/bun:1.3.14 AS worker +FROM oven/bun:1.4.2 AS worker ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -54,7 +54,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-worker/worker-server.js"] # Egress gateway (sandbox outbound delegation) -FROM oven/bun:1.3.14 AS egress-gateway +FROM oven/bun:1.4.2 AS egress-gateway ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.api b/service/Dockerfile.api index 2921401e..1da68c46 100644 --- a/service/Dockerfile.api +++ b/service/Dockerfile.api @@ -1,7 +1,7 @@ # API-Only Server Dockerfile # This builds the HTTP API server without workers # Scale this based on HTTP traffic -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/api-server.ts --minify --outdir .build --target bun --extern RUN bun build ./scripts/rehydrate-session-cache.ts --minify --outdir .build-migrations --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck (not included in bun base image) diff --git a/service/Dockerfile.bun b/service/Dockerfile.bun index b416f14e..d545f32c 100644 --- a/service/Dockerfile.bun +++ b/service/Dockerfile.bun @@ -1,5 +1,5 @@ # Base stage -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.egress-gateway b/service/Dockerfile.egress-gateway index ca9e9a9d..bd1d2b64 100644 --- a/service/Dockerfile.egress-gateway +++ b/service/Dockerfile.egress-gateway @@ -1,5 +1,5 @@ # Egress Gateway Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app FROM base AS install @@ -11,7 +11,7 @@ RUN mkdir -p /temp/prod COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* diff --git a/service/Dockerfile.local b/service/Dockerfile.local index cbb7af13..4ed93ee4 100644 --- a/service/Dockerfile.local +++ b/service/Dockerfile.local @@ -1,5 +1,5 @@ # Local development Dockerfile - no authentication required -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -22,7 +22,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/local-api.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.service b/service/Dockerfile.service index 3a89dc15..d5d4e6d9 100644 --- a/service/Dockerfile.service +++ b/service/Dockerfile.service @@ -1,5 +1,5 @@ # Service API Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.tool-call-server b/service/Dockerfile.tool-call-server index 355e8ec1..76afb837 100644 --- a/service/Dockerfile.tool-call-server +++ b/service/Dockerfile.tool-call-server @@ -1,5 +1,5 @@ # Tool Call Server Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -13,7 +13,7 @@ COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.worker b/service/Dockerfile.worker index e99c16c4..bb7b6945 100644 --- a/service/Dockerfile.worker +++ b/service/Dockerfile.worker @@ -2,7 +2,7 @@ # This builds the job processing worker without HTTP server # Deploy alongside a sandbox sidecar for execution # Scale this based on queue depth -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -25,7 +25,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/worker-server.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck diff --git a/service/src/bridge/enabled.ts b/service/src/bridge/enabled.ts new file mode 100644 index 00000000..540a844a --- /dev/null +++ b/service/src/bridge/enabled.ts @@ -0,0 +1,9 @@ +import { env } from '../config'; + +/** API-only deployments can serve bridges without selecting that worker backend. */ +export function isBridgeEnabled(): boolean { + return env.SANDBOX_BACKEND === 'remote-bridge' + || env.BRIDGE_AUTH_MODE === 'paired' + || env.BRIDGE_DYNAMIC_WORKERS + || env.BRIDGE_TOKEN.length > 0; +} diff --git a/service/src/bridge/index.ts b/service/src/bridge/index.ts index fc409ad2..a1ec5d78 100644 --- a/service/src/bridge/index.ts +++ b/service/src/bridge/index.ts @@ -3,6 +3,7 @@ import { env } from '../config'; import { RedisBridgePairingStore } from './pairing'; import { createBridgeRouter } from './router'; import { RedisBridgeStore } from './store'; +import { isBridgeEnabled } from './enabled'; export const bridgeStore = new RedisBridgeStore( connection, @@ -13,6 +14,7 @@ export const bridgeStore = new RedisBridgeStore( export const bridgePairings = new RedisBridgePairingStore(connection); export default createBridgeRouter({ + enabled: isBridgeEnabled(), store: bridgeStore, pairings: bridgePairings, authMode: env.BRIDGE_AUTH_MODE, diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 764613a5..af0e765c 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -25,6 +25,23 @@ afterEach(async () => { }); describe('paired bridge HTTP API', () => { + test('disabled bridges expose no HTTP routes', async () => { + const app = express(); + app.use('/v1/bridge', createBridgeRouter({ + enabled: false, + store: new RedisBridgeStore(redis), + pairings: new RedisBridgePairingStore(redis), + authMode: 'static', + adminToken: '', + })); + server = createServer(app); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Expected TCP listener'); + const response = await fetch(`http://127.0.0.1:${address.port}/v1/bridge/workers/test/status`); + expect(response.status).toBe(404); + }); + test('reports authenticated worker readiness without exposing identity or binding data', async () => { const store = new RedisBridgeStore(redis); const app = express(); diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 72e51b4d..25b0bdaf 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -29,6 +29,7 @@ const PRINCIPAL_TYPES = new Set([ export type BridgeAuthMode = 'static' | 'paired'; export interface BridgeRouterOptions { + enabled?: boolean; store: RedisBridgeStore; pairings: RedisBridgePairingStore; authMode: BridgeAuthMode; @@ -130,6 +131,7 @@ function isSettlement(value: unknown): value is CodeBridgeSettlement { export function createBridgeRouter(options: BridgeRouterOptions): Router { const router = Router(); + if (options.enabled === false) return router; const configuredWorker = (workerId: string): boolean => options.allowDynamicWorkers === true || diff --git a/service/src/secure-startup.test.ts b/service/src/secure-startup.test.ts index 6820010a..79d602df 100644 --- a/service/src/secure-startup.test.ts +++ b/service/src/secure-startup.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { env } from './config'; +import { isBridgeEnabled } from './bridge/enabled'; import { validateApiBridgePolicy, validateApiHardenedConfig, @@ -394,6 +395,23 @@ describe('sandbox backend policy', () => { expect(() => validateSandboxBackendPolicy()).not.toThrow(); }); + test('hardened HTTP and Lambda APIs start without an unused bridge credential', () => { + env.HARDENED_SANDBOX_MODE = true; + env.BRIDGE_AUTH_MODE = 'static'; + env.BRIDGE_DYNAMIC_WORKERS = false; + env.BRIDGE_TOKEN = ''; + env.BRIDGE_WORKER_ID = ''; + for (const backend of ['http', 'lambda-microvm'] as const) { + env.SANDBOX_BACKEND = backend; + expect(isBridgeEnabled()).toBe(false); + expect(() => validateApiBridgePolicy()).not.toThrow(); + } + env.BRIDGE_AUTH_MODE = 'paired'; + env.BRIDGE_DYNAMIC_WORKERS = true; + expect(isBridgeEnabled()).toBe(true); + expect(() => validateApiBridgePolicy()).toThrow('CODEAPI_BRIDGE_TOKEN'); + }); + test('API-only hardened bridge validation rejects static worker auth', () => { env.SANDBOX_BACKEND = 'http'; env.HARDENED_SANDBOX_MODE = true; diff --git a/service/src/secure-startup.ts b/service/src/secure-startup.ts index 79f69c42..26d378e3 100644 --- a/service/src/secure-startup.ts +++ b/service/src/secure-startup.ts @@ -5,6 +5,7 @@ import { } from './config'; import { INTERNAL_SERVICE_TOKEN_ENV } from './internal-service-auth'; import { isValidBridgeWorkerId } from '../../packages/code/src/protocol'; +import { isBridgeEnabled } from './bridge/enabled'; export class SecureStartupConfigError extends Error { constructor(message: string) { @@ -56,6 +57,7 @@ export function validateApiHardenedConfig(): void { /** Validate bridge credentials in every process that exposes bridge routes. */ export function validateApiBridgePolicy(): void { + if (!isBridgeEnabled()) return; if (env.BRIDGE_TOKEN !== env.BRIDGE_TOKEN.trim()) { throw new SecureStartupConfigError( 'CODEAPI_BRIDGE_TOKEN must not contain surrounding whitespace', @@ -87,7 +89,7 @@ export function validateApiBridgePolicy(): void { requireStrongSecret('CODEAPI_BRIDGE_TOKEN', env.BRIDGE_TOKEN); if (env.BRIDGE_AUTH_MODE !== 'paired') { throw new SecureStartupConfigError( - 'Hardened API deployments require CODEAPI_BRIDGE_AUTH_MODE=paired because bridge routes are always exposed', + 'Hardened API deployments with bridge routes enabled require CODEAPI_BRIDGE_AUTH_MODE=paired', ); } } diff --git a/service/src/service/router.ts b/service/src/service/router.ts index f89bbb17..2c42f60c 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -3,7 +3,7 @@ import busboy from 'busboy'; import { nanoid } from 'nanoid'; import { Router } from 'express'; import type { Response } from 'express'; -import type { Readable } from 'stream'; +import { Readable } from 'stream'; import type * as t from '../types'; import { checkServiceStartUp, checkServiceShutDown } from '../lifecycle'; import { sessionAuth } from '../middleware/auth'; @@ -42,6 +42,43 @@ const JOB_COMPLETION_WAIT_TIMEOUT_MS = jobCompletionWaitTimeoutMs( ); const UPLOAD_TIMEOUT_MS = 30_000; + +/** + * Streams one busboy file part to the file-server. + * + * Uses the global `fetch` rather than axios on purpose. axios routes a + * stream body through `node:http`'s `ClientRequest`, and on Bun (the + * runtime in `Dockerfile.api`) that client can drop the tail of a + * chunked request body: every byte is accepted by `write()`, `end()` + * is called after the last write, yet the peer receives 32 KiB-800 KiB + * less and the file-server stores a short object while reporting + * success (reproduced on Bun 1.3.10-1.3.14 with a 20 MiB upload; a + * 1 MiB upload is unaffected). Bun's native `fetch` and Node's undici + * stream the same body intact. busboy's `limits.fileSize` already caps + * the part, so no separate body-length guard is needed here. + */ +async function putFileToFileServer( + url: string, + file: Readable, + headers: Record, + signal: AbortSignal, +): Promise { + const response = await fetch(url, { + method: 'PUT', + headers, + body: Readable.toWeb(file) as unknown as ReadableStream, + signal, + /* Required by the WHATWG fetch spec for streamed request bodies. */ + duplex: 'half', + } as RequestInit); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error( + `file-server responded ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, + ); + } + return (await response.json()) as t.UploadResult; +} /* Batch cap sized for skill-priming uploads: a single skill (e.g. pptx) * can carry 60+ resource files including .xsd schemas, helper scripts, * docs, and Python __init__.py markers. The previous cap of 20 silently @@ -500,20 +537,16 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R recordSessionOwnership(connection, session_id, sessionKey) .then(() => { logger.info(`[${INSTANCE_ID}] Upload: Session ID: ${session_id} | User ID: ${userId} | Session key: ${sessionKey}`); - return axios.put( - `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, - file, - { - headers: internalServiceHeaders(putHeaders), - maxBodyLength: planFileSize, - maxContentLength: planFileSize, - signal: abortController.signal, - }, - ); + return putFileToFileServer( + `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, + file, + internalServiceHeaders(putHeaders), + abortController.signal, + ); }) - .then(response => { + .then(result => { clearTimeout(uploadTimeout); - resolve(response.data); + resolve(result); }) .catch(error => { clearTimeout(uploadTimeout); @@ -742,18 +775,14 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, logger.error(`[${INSTANCE_ID}] Batch upload file failed: ${filename} | Session: ${session_id}`, { error: message }); resolve({ status: 'error', filename, error: message }); }; - const forwardFile = (): Promise => axios.put( + const forwardFile = (): Promise => putFileToFileServer( `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, file, - { - headers: internalServiceHeaders(putHeaders), - maxBodyLength: planFileSize, - maxContentLength: planFileSize, - signal: abortController.signal, - }, - ).then(response => { + internalServiceHeaders(putHeaders), + abortController.signal, + ).then(result => { clearTimeout(uploadTimeout); - resolve({ status: 'success', filename: response.data.filename, fileId: response.data.fileId }); + resolve({ status: 'success', filename: result.filename, fileId: result.fileId }); }, resolveUploadFailure); void ensureSessionRegistered(sessionKey)