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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions service/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -27,15 +27,15 @@ 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
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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions service/Dockerfile.api
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion service/Dockerfile.bun
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions service/Dockerfile.egress-gateway
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/*
Expand Down
4 changes: 2 additions & 2 deletions service/Dockerfile.local
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion service/Dockerfile.service
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions service/Dockerfile.tool-call-server
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions service/Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions service/src/bridge/enabled.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions service/src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions service/src/bridge/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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();
Expand Down
2 changes: 2 additions & 0 deletions service/src/bridge/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const PRINCIPAL_TYPES = new Set<BridgePrincipalType>([
export type BridgeAuthMode = 'static' | 'paired';

export interface BridgeRouterOptions {
enabled?: boolean;
store: RedisBridgeStore;
pairings: RedisBridgePairingStore;
authMode: BridgeAuthMode;
Expand Down Expand Up @@ -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 ||
Expand Down
18 changes: 18 additions & 0 deletions service/src/secure-startup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { env } from './config';
import { isBridgeEnabled } from './bridge/enabled';
import {
validateApiBridgePolicy,
validateApiHardenedConfig,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion service/src/secure-startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
);
}
}
Expand Down
73 changes: 51 additions & 22 deletions service/src/service/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string>,
signal: AbortSignal,
): Promise<t.UploadResult> {
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
Expand Down Expand Up @@ -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<t.UploadResult>(
`${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);
Expand Down Expand Up @@ -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<void> => axios.put<t.UploadResult>(
const forwardFile = (): Promise<void> => 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)
Expand Down