From 9cbff2621af3304587c9af286483b4a576edfc83 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:05:01 -0400 Subject: [PATCH 1/2] fix(codeapi): require bridge credentials only when configured (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codeapi): require bridge credentials only when configured Source: ClickHouse/ai@da81d707c6538b509be70e2401b649693a3f3dce * chore(codeapi): import 🫗 fix: Drain Cancelled BYOM Settlements Source: ClickHouse/ai@2391d77aab6ff81689cf8a834d74992399f41173 Co-authored-by: danny-avila <110412045+danny-avila@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: danny-avila <110412045+danny-avila@users.noreply.github.com> --- README.md | 6 ++++-- service/src/bridge/enabled.ts | 9 +++++++++ service/src/bridge/index.ts | 2 ++ service/src/bridge/router.test.ts | 17 +++++++++++++++++ service/src/bridge/router.ts | 2 ++ service/src/secure-startup.test.ts | 18 ++++++++++++++++++ service/src/secure-startup.ts | 4 +++- 7 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 service/src/bridge/enabled.ts 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/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', ); } } From 119875979e26ea0f3be028312e2fe4c0fbe26528 Mon Sep 17 00:00:00 2001 From: Mihidum <55163074+mihidumh@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:26:17 +1000 Subject: [PATCH 2/2] fix(api): stream uploads to the file-server with fetch; bump Bun to 1.4.2 (fixes silent upload truncation) (#174) * fix(api): stream uploads to the file-server with fetch, not axios over node:http On Bun, node:http's ClientRequest can drop the tail of a chunked request body: write() accepts every byte and end() is called after the last write, yet the peer receives 32 KiB-800 KiB less. The file-server then stores a short object and reports success, so a 20 MiB upload comes back corrupt with no error anywhere (Bun 1.3.10-1.3.14; 1 MiB is unaffected). Send the busboy file part with the global fetch and a web ReadableStream instead. Bun's native fetch and Node's undici stream the body intact. Co-Authored-By: Claude Fable 5.1 * build: bump Bun base images 1.3.14 -> 1.4.2 Bun 1.3.x's node:http client drops the tail of chunked request bodies under load (see the previous commit). Bun 1.4.2 streams them intact in the same test, so the base image bump is the second line of defence for every remaining node:http client in the services. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- service/Dockerfile | 10 ++-- service/Dockerfile.api | 4 +- service/Dockerfile.bun | 2 +- service/Dockerfile.egress-gateway | 4 +- service/Dockerfile.local | 4 +- service/Dockerfile.service | 2 +- service/Dockerfile.tool-call-server | 4 +- service/Dockerfile.worker | 4 +- service/src/service/router.ts | 73 ++++++++++++++++++++--------- 9 files changed, 68 insertions(+), 39 deletions(-) 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/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)