From 7c5f4af6b37aef818f15ad87d6f7f73305f7121a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 7 Aug 2026 21:35:06 -0400 Subject: [PATCH 1/2] feat: add stateless npm declaration indexing --- README.md | 11 + api/package.json | 2 +- api/src/api/npm-unit.ts | 263 ++++++++++ api/src/api/v2.ts | 3 + api/src/config.ts | 11 + api/src/execution-manifest-request.ts | 3 + api/src/execution-manifest.ts | 4 + api/src/metrics.ts | 1 + api/src/npm-unit-worker-lib.ts | 489 ++++++++++++++++++ api/src/npm-unit-worker.test.ts | 192 +++++++ api/src/npm-unit-worker.ts | 17 + build-packages.sh | 14 +- docker-compose.yaml | 23 + docker/Dockerfile.worker-sandbox | 3 +- docker/package-init.sh | 23 +- helm/codeapi/README.md | 28 + helm/codeapi/templates/api-deployment.yaml | 12 +- .../templates/egress-gateway-deployment.yaml | 10 + helm/codeapi/templates/network-policy.yaml | 33 ++ .../templates/worker-sandbox-deployment.yaml | 46 ++ helm/codeapi/values.yaml | 24 + service/openapi.yml | 183 +++++++ service/src/api-server.ts | 3 + service/src/config.ts | 16 + service/src/egress-gateway-client.ts | 22 + service/src/egress-gateway.test.ts | 113 +++- service/src/egress-gateway.ts | 179 +++++++ service/src/egress-grant.test.ts | 31 ++ service/src/egress-grant.ts | 63 +++ service/src/execution-manifest.ts | 4 + service/src/local-api.ts | 2 + service/src/npm-unit-contract.test.ts | 61 +++ service/src/npm-unit-contract.ts | 222 ++++++++ service/src/npm-unit-dispatch.test.ts | 45 ++ service/src/npm-unit-dispatch.ts | 265 ++++++++++ service/src/secure-startup.test.ts | 17 + service/src/secure-startup.ts | 3 + service/src/service-api.ts | 2 + service/src/service/npm-unit-router.ts | 101 ++++ service/src/worker-server.ts | 97 +++- 40 files changed, 2627 insertions(+), 14 deletions(-) create mode 100644 api/src/api/npm-unit.ts create mode 100644 api/src/npm-unit-worker-lib.ts create mode 100644 api/src/npm-unit-worker.test.ts create mode 100644 api/src/npm-unit-worker.ts create mode 100644 service/src/npm-unit-contract.test.ts create mode 100644 service/src/npm-unit-contract.ts create mode 100644 service/src/npm-unit-dispatch.test.ts create mode 100644 service/src/npm-unit-dispatch.ts create mode 100644 service/src/service/npm-unit-router.ts diff --git a/README.md b/README.md index 718db6a..350a420 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,14 @@ session-management routes stay unauthenticated for backwards compatibility. - Worker: `GET /health` and `GET /ready` - File Server: `GET /health` and `GET /ready` - Tool Call Server: `GET /health` + +## Stateless npm declaration indexing + +An opt-in `POST /v1/sandbox/npm-unit` route indexes the `.d.ts` surface of one +exact registry package without executing package code. It verifies the +lockfile-provided SHA-512 digest before decompression, retains only declaration +files plus the root `package.json`, and returns deterministic symbols/imports +with rejection and resource-usage telemetry. Enable it with +`npmUnit.enabled=true` in Helm or `CODEAPI_NPM_UNIT_ENABLED=true` in the main +Compose stack; it is disabled by default. This route uses direct synchronous +HTTP dispatch and does not create a Redis/BullMQ job or persist package state. diff --git a/api/package.json b/api/package.json index b36ff5f..3825b7f 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "scripts": { "dev": "bun run --watch src/index.ts", - "build": "bun build ./src/index.ts --minify --outdir .build --target bun --external '@opentelemetry/*' && bun build ./src/tool-call-socket-proxy.ts --target=node --format=cjs --outfile=.build/tool-call-socket-proxy.cjs", + "build": "bun build ./src/index.ts --minify --outdir .build --target bun --external '@opentelemetry/*' && bun build ./src/tool-call-socket-proxy.ts --target=node --format=cjs --outfile=.build/tool-call-socket-proxy.cjs && bun build ./src/npm-unit-worker.ts --target=node --format=cjs --outfile=.build/npm-unit-worker.cjs --external web-tree-sitter", "start": "bun run .build/index.js", "test": "bun test" }, diff --git a/api/src/api/npm-unit.ts b/api/src/api/npm-unit.ts new file mode 100644 index 0000000..90e457a --- /dev/null +++ b/api/src/api/npm-unit.ts @@ -0,0 +1,263 @@ +import fs from 'fs'; +import path from 'path'; +import express, { type Request, type Response } from 'express'; +import { config } from '../config'; +import { Job } from '../job'; +import { getLatestRuntimeMatchingLanguageVersion } from '../runtime'; +import { + EXECUTION_MANIFEST_HEADER, + ExecutionManifestError, + executionManifestBodySha256, + verifyExecutionManifestWithKey, +} from '../execution-manifest'; +import { logger } from '../logger'; + +const router = express.Router(); +const NPM_FETCH_TOKEN_HEADER = 'X-CodeAPI-Npm-Fetch-Token'; +const KEEP = ['**/*.d.ts', 'package.json'] as const; + +type NpmUnitSandboxBody = { + execution_id: string; + name: string; + version: string; + integrity: string; + resolved: string; + keep: string[]; + fetch_token: string; + execution_manifest?: string; +}; + +function failure( + res: Response, + status: number, + error: string, + message: string, + retryable: boolean, +): Response { + return res.status(status).json({ error, message, retryable }); +} + +function validateBody(raw: unknown): NpmUnitSandboxBody { + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Request body must be an object'); + } + const body = raw as Record; + for (const field of ['execution_id', 'name', 'version', 'integrity', 'resolved', 'fetch_token'] as const) { + if (typeof body[field] !== 'string' || body[field].length === 0) { + throw new Error(`${field} is required as a non-empty string`); + } + } + if ((body.execution_id as string).length > 256 || (body.fetch_token as string).length > 16_384) { + throw new Error('Execution id or fetch token is too long'); + } + if ( + !Array.isArray(body.keep) || + body.keep.length !== KEEP.length || + KEEP.some(value => !(body.keep as unknown[]).includes(value)) + ) { + throw new Error(`keep must be exactly ${JSON.stringify(KEEP)}`); + } + return body as NpmUnitSandboxBody; +} + +function verifyManifest(body: NpmUnitSandboxBody): void { + if (!config.require_execution_manifest) return; + const token = body.execution_manifest; + if (!token) throw new ExecutionManifestError('missing_header', `${EXECUTION_MANIFEST_HEADER} is required`); + const claims = verifyExecutionManifestWithKey(token, { + publicKey: config.execution_manifest_public_key, + secret: config.execution_manifest_secret, + }); + if (claims.operation !== 'npm-unit' || claims.exec_id !== body.execution_id) { + throw new ExecutionManifestError('scope_mismatch', 'Execution manifest does not authorize this npm unit'); + } + if (claims.execute_body_sha256 !== executionManifestBodySha256(body)) { + throw new ExecutionManifestError('scope_mismatch', 'Execution manifest body hash does not match request'); + } +} + +async function readBoundedTarball(response: globalThis.Response): Promise { + if (!response.body) throw new Error('Registry gateway response had no body'); + const chunks: Buffer[] = []; + let total = 0; + const reader = response.body.getReader(); + try { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > config.npm_tarball_max_bytes) { + throw new Error('npm_tarball_too_large'); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + // A chunked upstream that crosses the gateway limit is terminated after + // exactly maxBytes have reached this reader. Preserve the structured + // too_large outcome instead of flattening that enforced cutoff into a + // generic transport failure. + if (total >= config.npm_tarball_max_bytes) throw new Error('npm_tarball_too_large'); + throw error; + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, total); +} + +async function gatewayError(response: globalThis.Response): Promise<{ error: string; message: string; retryable: boolean }> { + try { + const raw = await response.text(); + if (Buffer.byteLength(raw) > 8192) throw new Error('oversize'); + const parsed = JSON.parse(raw) as Record; + return { + error: typeof parsed.error === 'string' ? parsed.error : 'registry_unavailable', + message: typeof parsed.message === 'string' ? parsed.message : 'Registry request failed', + retryable: parsed.retryable === true, + }; + } catch { + return { error: 'registry_unavailable', message: 'Registry request failed', retryable: true }; + } +} + +function publicStatus(error: string, retryable: boolean): number { + if (error === 'not_found') return 404; + if (error === 'too_large' || error === 'decompression_limit') return 413; + if (error === 'integrity_mismatch' || error === 'unsafe_entry' || error === 'parse_failed') return 422; + if (error === 'timeout') return 504; + return retryable ? 503 : 502; +} + +router.post('/npm-unit', express.json({ limit: '64kb' }), async (req: Request, res: Response) => { + const started = performance.now(); + let job: Job | undefined; + try { + if (!config.npm_unit_enabled) { + return failure(res, 503, 'disabled', 'npm unit indexing is disabled', false); + } + let body: NpmUnitSandboxBody; + try { + body = validateBody(req.body); + verifyManifest(body); + } catch (error) { + if (error instanceof ExecutionManifestError) { + const status = error.reason === 'missing_header' ? 401 : 403; + return failure(res, status, 'invalid_request', error.message, false); + } + return failure(res, 400, 'invalid_request', (error as Error).message, false); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.npm_unit_fetch_timeout); + let gatewayResponse: globalThis.Response; + try { + gatewayResponse = await fetch(`${config.egress_gateway_url.replace(/\/+$/, '')}/npm/tarball`, { + method: 'GET', + headers: { [NPM_FETCH_TOKEN_HEADER]: body.fetch_token }, + signal: controller.signal, + }); + } catch (error) { + const timedOut = controller.signal.aborted || (error as Error)?.name === 'AbortError'; + return failure( + res, + 503, + 'registry_unavailable', + timedOut ? 'Registry request timed out' : 'Registry gateway was unavailable', + true, + ); + } finally { + clearTimeout(timeout); + } + + if (!gatewayResponse.ok) { + const detail = await gatewayError(gatewayResponse); + return failure(res, publicStatus(detail.error, detail.retryable), detail.error, detail.message, detail.retryable); + } + + let tarball: Buffer; + try { + tarball = await readBoundedTarball(gatewayResponse); + } catch (error) { + const tooLarge = (error as Error).message === 'npm_tarball_too_large'; + return failure( + res, + tooLarge ? 413 : 503, + tooLarge ? 'too_large' : 'registry_unavailable', + tooLarge ? 'Package tarball exceeded the configured byte limit' : 'Registry response ended unexpectedly', + !tooLarge, + ); + } + + const runtime = getLatestRuntimeMatchingLanguageVersion('node', '*'); + if (!runtime) return failure(res, 500, 'sandbox_unavailable', 'Pinned Node runtime is unavailable', true); + const workerPath = path.join(__dirname, 'npm-unit-worker.cjs'); + let workerSource: string; + try { + workerSource = fs.readFileSync(workerPath, 'utf8'); + } catch { + return failure(res, 500, 'sandbox_unavailable', 'npm unit worker asset is unavailable', true); + } + + const workerRequest = { + name: body.name, + version: body.version, + integrity: body.integrity, + keep: [...KEEP], + limits: { + maxUnpackedBytes: config.npm_unit_max_unpacked_bytes, + maxKeptBytes: config.npm_unit_max_kept_bytes, + maxFileBytes: config.npm_unit_max_file_bytes, + maxEntries: config.npm_unit_max_entries, + }, + }; + job = new Job({ + session_id: body.execution_id, + output_session_id: body.execution_id, + runtime: { ...runtime, output_max_size: config.npm_unit_max_response_bytes }, + files: [ + { name: 'npm-unit-worker.cjs', content: workerSource, encoding: 'utf8' }, + { name: 'request.json', content: JSON.stringify(workerRequest), encoding: 'utf8' }, + { name: 'package.tgz', content: tarball.toString('base64'), encoding: 'base64' }, + ], + args: [], + stdin: '', + timeouts: { compile: 0, run: config.npm_unit_run_timeout }, + cpu_times: { compile: 0, run: config.npm_unit_cpu_time }, + memory_limits: { compile: config.npm_unit_memory_limit, run: config.npm_unit_memory_limit }, + }); + await job.prime(); + const result = await job.execute(); + const run = result.run ?? result.compile; + if (run?.status === 'TO') return failure(res, 504, 'timeout', 'Package parsing exceeded the wall-clock limit', false); + if (run?.message === 'Out of memory' || run?.signal === 'SIGKILL') { + return failure(res, 413, 'too_large', 'Package parsing exceeded the sandbox resource limit', false); + } + if (!run || run.code !== 0) { + logger.warn({ executionId: body.execution_id, status: run?.status, code: run?.code }, 'npm unit worker failed'); + return failure(res, 422, 'parse_failed', 'Package surface could not be parsed', false); + } + let response: Record; + try { + response = JSON.parse(run.stdout) as Record; + } catch { + return failure(res, 422, 'parse_failed', 'Package parser returned an invalid response', false); + } + const usage = response.usage; + if (usage && typeof usage === 'object') { + (usage as Record).wallMs = Math.round(performance.now() - started); + } + if (typeof response.error === 'string') { + const retryable = response.retryable === true; + return res.status(publicStatus(response.error, retryable)).json(response); + } + return res.status(200).json(response); + } catch (error) { + logger.error({ err: error }, 'npm unit route failed'); + return failure(res, 500, 'sandbox_unavailable', 'npm unit sandbox failed', true); + } finally { + await job?.cleanup().catch(error => logger.error({ err: error }, 'npm unit workspace cleanup failed')); + } +}); + +export default router; diff --git a/api/src/api/v2.ts b/api/src/api/v2.ts index 895fec6..90ddc57 100644 --- a/api/src/api/v2.ts +++ b/api/src/api/v2.ts @@ -31,10 +31,13 @@ import { pruneInputCache, storeCachedInputs, } from '../session-inputs'; +import npmUnitRouter from './npm-unit'; const router = express.Router(); const SYNTHETIC_PRINCIPAL_SOURCE = 'synthetic_test'; +router.use(npmUnitRouter); + function existingDestinationConflictMessage(existing: string, destination: string): string { return existing === destination ? `files contains duplicate destination "${destination}"` diff --git a/api/src/config.ts b/api/src/config.ts index 7724ff0..9bf52d5 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -103,6 +103,17 @@ export const config = { max_input_files: safeInt(process.env.SANDBOX_MAX_INPUT_FILES, 256), prime_concurrency: safeInt(process.env.SANDBOX_PRIME_CONCURRENCY, 8), egress_gateway_url: egressGatewayUrl, + npm_unit_enabled: process.env.CODEAPI_NPM_UNIT_ENABLED === 'true', + npm_tarball_max_bytes: safeInt(process.env.CODEAPI_NPM_TARBALL_MAX_BYTES, 32 * 1024 * 1024), + npm_unit_max_unpacked_bytes: safeInt(process.env.SANDBOX_NPM_UNIT_MAX_UNPACKED_BYTES, 64 * 1024 * 1024), + npm_unit_max_kept_bytes: safeInt(process.env.SANDBOX_NPM_UNIT_MAX_KEPT_BYTES, 32 * 1024 * 1024), + npm_unit_max_file_bytes: safeInt(process.env.SANDBOX_NPM_UNIT_MAX_FILE_BYTES, 8 * 1024 * 1024), + npm_unit_max_entries: safeInt(process.env.SANDBOX_NPM_UNIT_MAX_ENTRIES, 50_000), + npm_unit_max_response_bytes: safeInt(process.env.SANDBOX_NPM_UNIT_MAX_RESPONSE_BYTES, 32 * 1024 * 1024), + npm_unit_run_timeout: safeInt(process.env.SANDBOX_NPM_UNIT_RUN_TIMEOUT, 15_000), + npm_unit_cpu_time: safeInt(process.env.SANDBOX_NPM_UNIT_CPU_TIME, 15_000), + npm_unit_memory_limit: safeInt(process.env.SANDBOX_NPM_UNIT_MEMORY_LIMIT, 384 * 1024 * 1024), + npm_unit_fetch_timeout: safeInt(process.env.SANDBOX_NPM_UNIT_FETCH_TIMEOUT, 20_000), file_server_url: process.env.FILE_SERVER_URL ?? '', max_nesting_depth: safeInt(process.env.SANDBOX_MAX_NESTING_DEPTH, 10), max_path_length: safeInt(process.env.SANDBOX_MAX_PATH_LENGTH, 256), diff --git a/api/src/execution-manifest-request.ts b/api/src/execution-manifest-request.ts index 08b65b0..ce4b84d 100644 --- a/api/src/execution-manifest-request.ts +++ b/api/src/execution-manifest-request.ts @@ -162,6 +162,9 @@ export function verifyExecuteRequestManifest(args: { }, { nowSeconds: args.nowSeconds, }); + if (manifest.operation !== undefined && manifest.operation !== 'execute') { + throw new ExecutionManifestError('scope_mismatch', 'Execution manifest operation does not authorize execute'); + } assertManifestMatchesExecuteRequest(manifest, args.body, { nowSeconds: args.nowSeconds, bodyHashRequiredAfterSeconds: args.bodyHashRequiredAfterSeconds, diff --git a/api/src/execution-manifest.ts b/api/src/execution-manifest.ts index 35513aa..b6782e9 100644 --- a/api/src/execution-manifest.ts +++ b/api/src/execution-manifest.ts @@ -73,6 +73,7 @@ export interface ExecutionManifestClaims { exp: number; execute_body_sha256?: string; tool_call_socket?: boolean; + operation?: 'execute' | 'npm-unit'; external_user_id?: string; org_id?: string; service_id?: string; @@ -218,6 +219,9 @@ function validateClaimsShape(value: unknown): asserts value is ExecutionManifest if (claims.tool_call_socket !== undefined && typeof claims.tool_call_socket !== 'boolean') { throw new ExecutionManifestError('malformed', 'Execution manifest tool_call_socket is invalid'); } + if (claims.operation !== undefined && claims.operation !== 'execute' && claims.operation !== 'npm-unit') { + throw new ExecutionManifestError('malformed', 'Execution manifest operation is invalid'); + } for (const file of claims.input_files) { if ( file == null || diff --git a/api/src/metrics.ts b/api/src/metrics.ts index 2da33b2..ef16417 100644 --- a/api/src/metrics.ts +++ b/api/src/metrics.ts @@ -47,6 +47,7 @@ function routeLabel(req: Request): string { if (req.path === '/') return '/'; if (req.path === '/metrics') return '/metrics'; if (req.path === '/api/v2/execute') return '/api/v2/execute'; + if (req.path === '/api/v2/npm-unit') return '/api/v2/npm-unit'; if (req.path === '/api/v2/health') return '/api/v2/health'; if (req.path === '/api/v2/runtimes') return '/api/v2/runtimes'; return 'unmatched'; diff --git a/api/src/npm-unit-worker-lib.ts b/api/src/npm-unit-worker-lib.ts new file mode 100644 index 0000000..29f8efc --- /dev/null +++ b/api/src/npm-unit-worker-lib.ts @@ -0,0 +1,489 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +export interface NpmUnitWorkerLimits { + maxUnpackedBytes: number; + maxKeptBytes: number; + maxFileBytes: number; + maxEntries: number; +} + +export interface NpmUnitWorkerRequest { + name: string; + version: string; + integrity: string; + keep: ['**/*.d.ts', 'package.json']; + limits: NpmUnitWorkerLimits; +} + +export interface RejectedCounts { + link: number; + device: number; + unsafePath: number; + oversize: number; + other: number; +} + +export interface KeptTarFile { + path: string; + content: Buffer; + bytes: number; + sha1: string; +} + +export class NpmUnitWorkerError extends Error { + constructor( + public readonly code: + | 'integrity_mismatch' + | 'too_large' + | 'decompression_limit' + | 'unsafe_entry' + | 'parse_failed', + message: string, + public readonly rejected?: RejectedCounts, + public readonly unpackedBytes = 0, + ) { + super(message); + this.name = 'NpmUnitWorkerError'; + } +} + +function emptyRejected(): RejectedCounts { + return { link: 0, device: 0, unsafePath: 0, oversize: 0, other: 0 }; +} + +function gitBlobSha1(content: Buffer): string { + return crypto + .createHash('sha1') + .update(`blob ${content.length}\0`, 'utf8') + .update(content) + .digest('hex'); +} + +function readTarString(block: Buffer, start: number, length: number): string { + const end = block.indexOf(0, start); + const sliceEnd = end >= start && end < start + length ? end : start + length; + const value = block.subarray(start, sliceEnd).toString('utf8'); + if (value.includes('\uFFFD')) throw new NpmUnitWorkerError('unsafe_entry', 'Tar header contains invalid UTF-8'); + return value; +} + +function readTarNumber(block: Buffer, start: number, length: number): number { + const bytes = block.subarray(start, start + length); + if ((bytes[0] & 0x80) !== 0) { + const copy = Buffer.from(bytes); + copy[0] &= 0x7f; + let value = 0; + for (const byte of copy) { + value = value * 256 + byte; + if (!Number.isSafeInteger(value)) throw new NpmUnitWorkerError('too_large', 'Tar numeric field is too large'); + } + return value; + } + const raw = bytes.toString('ascii').replace(/\0.*$/, '').trim(); + if (raw === '') return 0; + if (!/^[0-7]+$/.test(raw)) throw new NpmUnitWorkerError('unsafe_entry', 'Tar numeric field is invalid'); + const value = Number.parseInt(raw, 8); + if (!Number.isSafeInteger(value) || value < 0) throw new NpmUnitWorkerError('too_large', 'Tar numeric field is too large'); + return value; +} + +function tarChecksumValid(header: Buffer): boolean { + const expected = readTarNumber(header, 148, 8); + let actual = 0; + for (let index = 0; index < 512; index++) { + actual += index >= 148 && index < 156 ? 32 : header[index]; + } + return actual === expected; +} + +function normalizeTarPath(raw: string): string | undefined { + if (!raw || raw.includes('\0') || raw.includes('\\') || path.posix.isAbsolute(raw)) return undefined; + const withoutPackage = raw.startsWith('package/') ? raw.slice('package/'.length) : raw; + if (!withoutPackage || withoutPackage.length > 1024 || withoutPackage.endsWith('/')) return undefined; + const parts = withoutPackage.split('/'); + if (parts.some(part => part === '' || part === '.' || part === '..')) return undefined; + if (path.posix.normalize(withoutPackage) !== withoutPackage) return undefined; + return withoutPackage; +} + +function parsePaxPath(content: Buffer): string | undefined { + let offset = 0; + let found: string | undefined; + while (offset < content.length) { + const space = content.indexOf(32, offset); + if (space < 0) return undefined; + const length = Number(content.subarray(offset, space).toString('ascii')); + if (!Number.isSafeInteger(length) || length <= 0 || offset + length > content.length) return undefined; + const record = content.subarray(space + 1, offset + length - 1).toString('utf8'); + const eq = record.indexOf('='); + if (eq > 0 && record.slice(0, eq) === 'path') found = record.slice(eq + 1); + offset += length; + } + return found; +} + +function shouldKeep(filePath: string): boolean { + return filePath === 'package.json' || filePath.endsWith('.d.ts'); +} + +export function verifyIntegrity(tarball: Buffer, integrity: string): void { + const match = integrity.match(/^sha512-([A-Za-z0-9+/]+={0,2})$/); + if (!match) throw new NpmUnitWorkerError('integrity_mismatch', 'Integrity is not a sha512 SRI digest'); + const expected = Buffer.from(match[1], 'base64'); + const actual = crypto.createHash('sha512').update(tarball).digest(); + if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { + throw new NpmUnitWorkerError('integrity_mismatch', 'Package tarball integrity did not match'); + } +} + +export function extractDeclarationFiles( + tarball: Buffer, + limits: NpmUnitWorkerLimits, +): { files: KeptTarFile[]; rejected: RejectedCounts; unpackedBytes: number } { + let archive: Buffer; + try { + archive = zlib.gunzipSync(tarball, { maxOutputLength: limits.maxUnpackedBytes + 1 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ERR_BUFFER_TOO_LARGE' || /larger than/i.test((error as Error).message)) { + throw new NpmUnitWorkerError( + 'decompression_limit', + 'Package exceeded the decompressed byte limit', + emptyRejected(), + limits.maxUnpackedBytes, + ); + } + throw new NpmUnitWorkerError('parse_failed', 'Package gzip stream is invalid'); + } + if (archive.length > limits.maxUnpackedBytes) { + throw new NpmUnitWorkerError( + 'decompression_limit', + 'Package exceeded the decompressed byte limit', + emptyRejected(), + archive.length, + ); + } + + const rejected = emptyRejected(); + const files: KeptTarFile[] = []; + const seenPaths = new Set(); + let offset = 0; + let entries = 0; + let keptBytes = 0; + let nextPath: string | undefined; + let sawEndMarker = false; + + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) { + sawEndMarker = true; + offset += 512; + break; + } + entries += 1; + if (entries > limits.maxEntries) { + rejected.oversize += 1; + throw new NpmUnitWorkerError('too_large', 'Package exceeded the tar entry limit', rejected, archive.length); + } + if (!tarChecksumValid(header)) { + throw new NpmUnitWorkerError('unsafe_entry', 'Tar header checksum is invalid', rejected, archive.length); + } + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const headerPath = prefix ? `${prefix}/${name}` : name; + const size = readTarNumber(header, 124, 12); + const type = String.fromCharCode(header[156] || 48); + const dataStart = offset + 512; + const paddedSize = Math.ceil(size / 512) * 512; + const nextOffset = dataStart + paddedSize; + if (!Number.isSafeInteger(nextOffset) || nextOffset > archive.length || dataStart + size > archive.length) { + throw new NpmUnitWorkerError('unsafe_entry', 'Tar entry extends past the archive', rejected, archive.length); + } + const content = archive.subarray(dataStart, dataStart + size); + offset = nextOffset; + + if (type === 'x' || type === 'g') { + rejected.other += 1; + nextPath = parsePaxPath(content) ?? nextPath; + continue; + } + if (type === 'L') { + rejected.other += 1; + nextPath = readTarString(content, 0, content.length); + continue; + } + + const rawPath = nextPath ?? headerPath; + nextPath = undefined; + if (type === '5') { + const directoryPath = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath; + if (!normalizeTarPath(directoryPath)) rejected.unsafePath += 1; + continue; + } + const normalized = normalizeTarPath(rawPath); + if (!normalized) { + rejected.unsafePath += 1; + continue; + } + if (type === '1' || type === '2') { + rejected.link += 1; + continue; + } + if (type === '3' || type === '4' || type === '6') { + rejected.device += 1; + continue; + } + if (type !== '0' && type !== '\0') { + rejected.other += 1; + continue; + } + if (!shouldKeep(normalized)) continue; + if (size > limits.maxFileBytes || keptBytes + size > limits.maxKeptBytes) { + rejected.oversize += 1; + continue; + } + if (seenPaths.has(normalized)) { + rejected.other += 1; + continue; + } + seenPaths.add(normalized); + keptBytes += size; + files.push({ + path: normalized, + content, + bytes: size, + sha1: gitBlobSha1(content), + }); + } + + if (!sawEndMarker || archive.subarray(offset).some(byte => byte !== 0)) { + throw new NpmUnitWorkerError('unsafe_entry', 'Tar archive has a missing or invalid end marker', rejected, archive.length); + } + + return { files, rejected, unpackedBytes: archive.length }; +} + +type Point = { row: number; column: number }; +type SyntaxNode = { + type: string; + text: string; + startPosition: Point; + endPosition: Point; + namedChildren: SyntaxNode[]; + parent?: SyntaxNode | null; + hasError?: boolean; + childForFieldName(name: string): SyntaxNode | null; +}; + +type Tree = { rootNode: SyntaxNode; delete?: () => void }; +type ParserLike = { setLanguage(language: unknown): void; parse(source: string): Tree; delete?: () => void }; + +interface ParserBundle { + parser: ParserLike; + dispose(): void; +} + +async function loadTypeScriptParser(): Promise { + // Dynamic require keeps the sandbox API itself independent of the parser. + // The module and the three pinned grammar files live only in the read-only + // Node runtime package mounted into NsJail. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const imported = require('web-tree-sitter') as Record; + const Parser = (imported.Parser ?? imported.default ?? imported) as { + new(): ParserLike; + init(): Promise; + Language: { load(path: string): Promise }; + }; + await Parser.init(); + const grammarPath = '/mnt/data/node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm'; + if (!fs.existsSync(grammarPath)) throw new Error('Pinned TypeScript grammar is missing'); + const language = await Parser.Language.load(grammarPath); + const parser = new Parser(); + parser.setLanguage(language); + return { + parser, + dispose: () => parser.delete?.(), + }; +} + +const SYMBOL_LABELS: Record = { + interface_declaration: 'Interface', + type_alias_declaration: 'TypeAlias', + class_declaration: 'Class', + abstract_class_declaration: 'Class', + function_declaration: 'Function', + generator_function_declaration: 'Function', + enum_declaration: 'Enum', + internal_module: 'Namespace', + module: 'Module', + method_signature: 'Method', + abstract_method_signature: 'Method', + method_definition: 'Method', + property_signature: 'Property', + public_field_definition: 'Property', + call_signature: 'CallSignature', + construct_signature: 'ConstructSignature', +}; + +function declarationName(node: SyntaxNode, label: string): string | undefined { + const named = node.childForFieldName('name'); + if (named?.text) return named.text; + if (label === 'CallSignature') return ''; + if (label === 'ConstructSignature') return ''; + return undefined; +} + +function compactSignature(node: SyntaxNode): string { + let text = node.text.replace(/\s+/g, ' ').trim(); + if (['Interface', 'Class', 'Namespace', 'Module', 'Enum'].includes(SYMBOL_LABELS[node.type] ?? '')) { + const body = text.indexOf('{'); + if (body >= 0) text = `${text.slice(0, body).trim()} { ... }`; + } + return text.length <= 8192 ? text : `${text.slice(0, 8189)}...`; +} + +function symbolTokens(name: string, label: string): string { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[^A-Za-z0-9]+/g, ' ') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + words.push(['Function', 'Method', 'CallSignature', 'ConstructSignature'].includes(label) ? 'functions' : 'types'); + return Array.from(new Set(words)).join(' '); +} + +function isNodeExported(node: SyntaxNode, inherited: boolean): boolean { + if (inherited) return true; + let cursor: SyntaxNode | null | undefined = node; + while (cursor) { + if (cursor.type === 'export_statement') return true; + cursor = cursor.parent; + } + return /^(?:export\s+)?declare\s+global\b/.test(node.text.trim()) || /^export\b/.test(node.text.trim()); +} + +function importSpec(node: SyntaxNode): string | undefined { + const source = node.childForFieldName('source'); + const raw = source?.text; + if (!raw || raw.length < 2) return undefined; + const quote = raw[0]; + if ((quote !== '"' && quote !== "'") || raw[raw.length - 1] !== quote) return undefined; + return raw.slice(1, -1); +} + +export function parseDeclarationSource(parser: ParserLike, file: string, source: string): { + symbols: Array>; + imports: Array<{ file: string; spec: string }>; + hasError: boolean; +} { + const tree = parser.parse(source); + const symbols: Array> = []; + const imports: Array<{ file: string; spec: string }> = []; + let ordinal = 0; + const visit = (node: SyntaxNode, inheritedExported = false): void => { + const exported = isNodeExported(node, inheritedExported); + const label = SYMBOL_LABELS[node.type]; + if (label) { + const name = declarationName(node, label); + if (name) { + symbols.push({ + file, + ordinal: ordinal++, + label, + name, + startLine: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, + isExported: exported, + signature: compactSignature(node), + tokens: symbolTokens(name, label), + }); + } + } + if (node.type === 'import_statement' || node.type === 'export_statement') { + const spec = importSpec(node); + if (spec) imports.push({ file, spec }); + } + for (const child of node.namedChildren) visit(child, exported); + }; + visit(tree.rootNode); + const hasError = tree.rootNode.hasError === true; + tree.delete?.(); + return { symbols, imports, hasError }; +} + +export async function buildNpmUnitResult( + request: NpmUnitWorkerRequest, + tarball: Buffer, + parserLoader: () => Promise = loadTypeScriptParser, +): Promise> { + const started = performance.now(); + verifyIntegrity(tarball, request.integrity); + const extracted = extractDeclarationFiles(tarball, request.limits); + const files = extracted.files.map(file => ({ path: file.path, sha1: file.sha1, bytes: file.bytes })); + const symbols: Array> = []; + const imports: Array<{ file: string; spec: string }> = []; + const errors: Array<{ file?: string; code: 'parse_failed'; message: string }> = []; + const declarationFiles = extracted.files.filter(file => file.path.endsWith('.d.ts')); + + let parserBundle: ParserBundle | undefined; + try { + parserBundle = await parserLoader(); + for (const file of declarationFiles) { + try { + const parsed = parseDeclarationSource(parserBundle.parser, file.path, file.content.toString('utf8')); + symbols.push(...parsed.symbols); + imports.push(...parsed.imports); + if (parsed.hasError) { + errors.push({ file: file.path, code: 'parse_failed', message: 'Tree-sitter reported syntax errors' }); + } + } catch { + errors.push({ file: file.path, code: 'parse_failed', message: 'Declaration file could not be parsed' }); + } + } + } catch { + throw new NpmUnitWorkerError('parse_failed', 'TypeScript parser could not be initialized', extracted.rejected, extracted.unpackedBytes); + } finally { + parserBundle?.dispose(); + } + + const rejectedTotal = Object.values(extracted.rejected).reduce((sum, value) => sum + value, 0); + return { + status: errors.length > 0 || rejectedTotal > 0 ? 'partial' : 'complete', + name: request.name, + version: request.version, + integrityVerified: true, + files, + symbols, + imports, + rejected: extracted.rejected, + usage: { + tarballBytes: tarball.length, + unpackedBytes: extracted.unpackedBytes, + peakRssBytes: process.resourceUsage().maxRSS * 1024, + wallMs: Math.round(performance.now() - started), + }, + ...(errors.length > 0 ? { errors } : {}), + }; +} + +export function npmWorkerFailure(error: unknown, tarballBytes: number, started: number): Record { + const typed = error instanceof NpmUnitWorkerError + ? error + : new NpmUnitWorkerError('parse_failed', 'Package surface could not be parsed'); + return { + error: typed.code, + message: typed.message, + retryable: false, + rejected: typed.rejected ?? emptyRejected(), + usage: { + tarballBytes, + unpackedBytes: typed.unpackedBytes, + peakRssBytes: process.resourceUsage().maxRSS * 1024, + wallMs: Math.round(performance.now() - started), + }, + }; +} diff --git a/api/src/npm-unit-worker.test.ts b/api/src/npm-unit-worker.test.ts new file mode 100644 index 0000000..4283c01 --- /dev/null +++ b/api/src/npm-unit-worker.test.ts @@ -0,0 +1,192 @@ +import crypto from 'crypto'; +import zlib from 'zlib'; +import { describe, expect, test } from 'bun:test'; +import { + NpmUnitWorkerError, + extractDeclarationFiles, + parseDeclarationSource, + verifyIntegrity, + type NpmUnitWorkerLimits, +} from './npm-unit-worker-lib'; + +const LIMITS: NpmUnitWorkerLimits = { + maxUnpackedBytes: 1024 * 1024, + maxKeptBytes: 256 * 1024, + maxFileBytes: 128 * 1024, + maxEntries: 100, +}; + +type Entry = { name: string; body?: Buffer | string; type?: string; link?: string }; + +function octal(value: number, width: number): Buffer { + return Buffer.from(`${value.toString(8).padStart(width - 1, '0')}\0`, 'ascii'); +} + +function tar(entries: Entry[]): Buffer { + const chunks: Buffer[] = []; + for (const entry of entries) { + const body = Buffer.isBuffer(entry.body) ? entry.body : Buffer.from(entry.body ?? ''); + const header = Buffer.alloc(512); + header.write(entry.name, 0, 100, 'utf8'); + octal(0o644, 8).copy(header, 100); + octal(0, 8).copy(header, 108); + octal(0, 8).copy(header, 116); + octal(body.length, 12).copy(header, 124); + octal(0, 12).copy(header, 136); + header.fill(32, 148, 156); + header.write(entry.type ?? '0', 156, 1, 'ascii'); + if (entry.link) header.write(entry.link, 157, 100, 'utf8'); + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + let sum = 0; + for (const byte of header) sum += byte; + Buffer.from(`${sum.toString(8).padStart(6, '0')}\0 `, 'ascii').copy(header, 148); + chunks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); + } + chunks.push(Buffer.alloc(1024)); + return zlib.gzipSync(Buffer.concat(chunks)); +} + +function sri(value: Buffer): string { + return `sha512-${crypto.createHash('sha512').update(value).digest('base64')}`; +} + +describe('npm unit worker archive boundary', () => { + test('keeps only declarations and root package metadata with git blob hashes', () => { + const archive = tar([ + { name: 'package/', type: '5' }, + { name: 'package/index.js', body: 'module.exports = 1' }, + { name: 'package/index.d.ts', body: 'export interface Query { id: string }' }, + { name: 'package/package.json', body: '{"name":"demo"}' }, + ]); + const result = extractDeclarationFiles(archive, LIMITS); + + expect(result.files.map(file => file.path)).toEqual(['index.d.ts', 'package.json']); + expect(result.files[0].sha1).toBe( + crypto.createHash('sha1') + .update(`blob ${result.files[0].bytes}\0`) + .update(result.files[0].content) + .digest('hex'), + ); + expect(result.rejected).toEqual({ link: 0, device: 0, unsafePath: 0, oversize: 0, other: 0 }); + }); + + test('counts traversal, absolute paths, links, devices, duplicates, and oversized declarations', () => { + const archive = tar([ + { name: 'package/../../etc/x', body: 'x' }, + { name: '/etc/y', body: 'y' }, + { name: 'package/link.d.ts', type: '2', link: '../../etc/passwd' }, + { name: 'package/device.d.ts', type: '3' }, + { name: 'package/huge.d.ts', body: Buffer.alloc(32) }, + { name: 'package/ok.d.ts', body: 'type X = 1' }, + { name: 'package/ok.d.ts', body: 'type X = 2' }, + ]); + const result = extractDeclarationFiles(archive, { ...LIMITS, maxFileBytes: 16 }); + + expect(result.files.map(file => file.path)).toEqual(['ok.d.ts']); + expect(result.rejected).toEqual({ link: 1, device: 1, unsafePath: 2, oversize: 1, other: 1 }); + }); + + test('rejects integrity mismatches before decompression', () => { + const archive = tar([{ name: 'package/index.d.ts', body: 'type X = 1' }]); + expect(() => verifyIntegrity(archive, sri(Buffer.from('different')))).toThrow(NpmUnitWorkerError); + try { + verifyIntegrity(archive, sri(Buffer.from('different'))); + } catch (error) { + expect((error as NpmUnitWorkerError).code).toBe('integrity_mismatch'); + } + }); + + test('caps gzip output and tar entry count before archive-wide work grows unbounded', () => { + const bomb = tar([{ name: 'package/ignored.bin', body: Buffer.alloc(128 * 1024) }]); + expect(() => extractDeclarationFiles(bomb, { ...LIMITS, maxUnpackedBytes: 16 * 1024 })).toThrow(); + try { + extractDeclarationFiles(bomb, { ...LIMITS, maxUnpackedBytes: 16 * 1024 }); + } catch (error) { + expect((error as NpmUnitWorkerError).code).toBe('decompression_limit'); + } + + const many = tar(Array.from({ length: 4 }, (_, index) => ({ + name: `package/${index}.d.ts`, + body: `type T${index} = ${index}`, + }))); + try { + extractDeclarationFiles(many, { ...LIMITS, maxEntries: 3 }); + } catch (error) { + expect((error as NpmUnitWorkerError).code).toBe('too_large'); + expect((error as NpmUnitWorkerError).rejected?.oversize).toBe(1); + } + }); + + test('rejects a truncated tar that omits its end marker', () => { + const archive = tar([{ name: 'package/index.d.ts', body: 'type X = 1' }]); + const unpacked = zlib.gunzipSync(archive); + const truncated = zlib.gzipSync(unpacked.subarray(0, unpacked.length - 1024)); + + try { + extractDeclarationFiles(truncated, LIMITS); + throw new Error('expected truncated tar to fail'); + } catch (error) { + expect((error as NpmUnitWorkerError).code).toBe('unsafe_entry'); + } + }); + + test('emits deterministic symbols in declaration order and import specifiers', () => { + type FakeNode = { + type: string; + text: string; + startPosition: { row: number; column: number }; + endPosition: { row: number; column: number }; + namedChildren: FakeNode[]; + parent?: FakeNode; + hasError?: boolean; + fields?: Record; + childForFieldName(name: string): FakeNode | null; + }; + const node = (type: string, text: string, row: number, children: FakeNode[] = []): FakeNode => { + const value: FakeNode = { + type, + text, + startPosition: { row, column: 0 }, + endPosition: { row: row + text.split('\n').length - 1, column: 0 }, + namedChildren: children, + childForFieldName(name: string) { return this.fields?.[name] ?? null; }, + }; + for (const child of children) child.parent = value; + return value; + }; + const source = node('string', `'./queryClient'`, 0); + const importNode = node('import_statement', `import type { QueryClient } from './queryClient'`, 0, [source]); + importNode.fields = { source }; + const interfaceName = node('type_identifier', 'UseQueryOptions', 1); + const interfaceNode = node( + 'interface_declaration', + 'interface UseQueryOptions { queryClient: QueryClient }', + 1, + [interfaceName], + ); + interfaceNode.fields = { name: interfaceName }; + const exported = node('export_statement', `export ${interfaceNode.text}`, 1, [interfaceNode]); + const aliasName = node('type_identifier', 'QueryKey', 2); + const aliasNode = node('type_alias_declaration', 'type QueryKey = readonly unknown[]', 2, [aliasName]); + aliasNode.fields = { name: aliasName }; + const root = node('program', '', 0, [importNode, exported, aliasNode]); + const parser = { + setLanguage() {}, + parse: () => ({ rootNode: root }), + }; + + const parsed = parseDeclarationSource(parser, 'index.d.ts', 'ignored'); + + expect(parsed.imports).toEqual([{ file: 'index.d.ts', spec: './queryClient' }]); + expect(parsed.symbols.map(symbol => ({ + ordinal: symbol.ordinal, + label: symbol.label, + name: symbol.name, + isExported: symbol.isExported, + }))).toEqual([ + { ordinal: 0, label: 'Interface', name: 'UseQueryOptions', isExported: true }, + { ordinal: 1, label: 'TypeAlias', name: 'QueryKey', isExported: false }, + ]); + }); +}); diff --git a/api/src/npm-unit-worker.ts b/api/src/npm-unit-worker.ts new file mode 100644 index 0000000..164a2fa --- /dev/null +++ b/api/src/npm-unit-worker.ts @@ -0,0 +1,17 @@ +import fs from 'fs'; +import { buildNpmUnitResult, npmWorkerFailure, type NpmUnitWorkerRequest } from './npm-unit-worker-lib'; + +async function main(): Promise { + const started = performance.now(); + let tarball = Buffer.alloc(0); + try { + const request = JSON.parse(fs.readFileSync('/mnt/data/request.json', 'utf8')) as NpmUnitWorkerRequest; + tarball = fs.readFileSync('/mnt/data/package.tgz'); + const result = await buildNpmUnitResult(request, tarball); + process.stdout.write(JSON.stringify(result)); + } catch (error) { + process.stdout.write(JSON.stringify(npmWorkerFailure(error, tarball.length, started))); + } +} + +void main(); diff --git a/build-packages.sh b/build-packages.sh index 2ae04d0..ef7d6ce 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -31,6 +31,7 @@ NODE_VERSION="${NODE_VERSION:-24.15.0}" BUN_VERSION="${BUN_VERSION:-1.3.14}" PACKAGES_DIR="./data/pkgs" JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" +NPM_UNIT_PACKAGES=("web-tree-sitter@0.24.7" "tree-sitter-wasms@0.1.13") load_js_packages() { if [ ! -f "$JS_PACKAGE_MANIFEST" ]; then @@ -307,7 +308,18 @@ install_node_packages() { --no-fund \ --save-exact \ --package-lock=false \ - "${JS_PACKAGES[@]}" + "${JS_PACKAGES[@]}" \ + "${NPM_UNIT_PACKAGES[@]}" + + docker exec "$CONTAINER_NAME" bash -c " + root=${pkg_dest}/node_modules/tree-sitter-wasms/out + if [ -d \"\$root\" ]; then + find \"\$root\" -type f -name '*.wasm' \\ + ! -name 'tree-sitter-typescript.wasm' \\ + ! -name 'tree-sitter-tsx.wasm' \\ + ! -name 'tree-sitter-javascript.wasm' -delete + fi + " # Mirror the manifest into npm's global tree (${pkg_dest}/lib/node_modules # + bin shims on PATH) so `npm list -g`, `npm root -g`, and CLI shims work diff --git a/docker-compose.yaml b/docker-compose.yaml index ac98d91..f285bfd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -32,6 +32,9 @@ services: - EXECUTION_MANIFEST_MAX_OUTPUT_FILES=${EXECUTION_MANIFEST_MAX_OUTPUT_FILES:-} - EXECUTION_MANIFEST_MAX_REQUESTS=${EXECUTION_MANIFEST_MAX_REQUESTS:-} - CODEAPI_HTTP_JSON_LIMIT=${CODEAPI_HTTP_JSON_LIMIT:-50mb} + - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} + - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} + - CODEAPI_NPM_UNIT_DISPATCH_URL=http://service-worker:3113/internal/npm-unit - SERVICE_PORT=3112 - SANDBOX_ENDPOINT=http://sandbox-runner:2000/api/v2 - EGRESS_GATEWAY_URL=http://egress_gateway:3190 @@ -65,6 +68,10 @@ services: - SANDBOX_ENDPOINT=http://sandbox-runner:2000/api/v2 - EGRESS_GATEWAY_URL=http://egress_gateway:3190 - CODEAPI_INTERNAL_SERVICE_TOKEN=${CODEAPI_INTERNAL_SERVICE_TOKEN:-localdev-internal-service-token} + - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} + - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} + - NPM_UNIT_CONCURRENCY=${NPM_UNIT_CONCURRENCY:-8} + - NPM_UNIT_REQUEST_TIMEOUT=${NPM_UNIT_REQUEST_TIMEOUT:-45000} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=localdev @@ -93,6 +100,11 @@ services: - EGRESS_GATEWAY_FILE_SERVER_URL=http://file_server:3000 - EGRESS_GATEWAY_TOOL_CALL_SERVER_URL=http://tool_call_server:3033 - EGRESS_GATEWAY_MAX_TOOL_CALL_BYTES=1048576 + - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} + - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} + - CODEAPI_NPM_TARBALL_MAX_BYTES=${CODEAPI_NPM_TARBALL_MAX_BYTES:-33554432} + - CODEAPI_NPM_FETCH_TIMEOUT_MS=${CODEAPI_NPM_FETCH_TIMEOUT_MS:-15000} + - CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS=${CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS:-120} - CODEAPI_EGRESS_GRANT_SECRET=${CODEAPI_EGRESS_GRANT_SECRET:-localdev-egress-grant-secret-change-me-32b} - CODEAPI_INTERNAL_SERVICE_TOKEN=${CODEAPI_INTERNAL_SERVICE_TOKEN:-localdev-internal-service-token} - REDIS_HOST=redis @@ -161,6 +173,17 @@ services: - SANDBOX_FORWARD_TARGET=egress_gateway:3190 - SANDBOX_REQUIRE_EGRESS_MANIFEST=${SANDBOX_REQUIRE_EGRESS_MANIFEST:-true} - SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY=${SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY:-MCowBQYDK2VwAyEAeY3PRoTS3adfU6E3gQUB5hSZdrdMSw6OrKkH4UhYh0U=} + - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} + - CODEAPI_NPM_TARBALL_MAX_BYTES=${CODEAPI_NPM_TARBALL_MAX_BYTES:-33554432} + - SANDBOX_NPM_UNIT_MAX_UNPACKED_BYTES=${SANDBOX_NPM_UNIT_MAX_UNPACKED_BYTES:-67108864} + - SANDBOX_NPM_UNIT_MAX_KEPT_BYTES=${SANDBOX_NPM_UNIT_MAX_KEPT_BYTES:-33554432} + - SANDBOX_NPM_UNIT_MAX_FILE_BYTES=${SANDBOX_NPM_UNIT_MAX_FILE_BYTES:-8388608} + - SANDBOX_NPM_UNIT_MAX_ENTRIES=${SANDBOX_NPM_UNIT_MAX_ENTRIES:-50000} + - SANDBOX_NPM_UNIT_MAX_RESPONSE_BYTES=${SANDBOX_NPM_UNIT_MAX_RESPONSE_BYTES:-33554432} + - SANDBOX_NPM_UNIT_RUN_TIMEOUT=${SANDBOX_NPM_UNIT_RUN_TIMEOUT:-15000} + - SANDBOX_NPM_UNIT_CPU_TIME=${SANDBOX_NPM_UNIT_CPU_TIME:-15000} + - SANDBOX_NPM_UNIT_MEMORY_LIMIT=${SANDBOX_NPM_UNIT_MEMORY_LIMIT:-402653184} + - SANDBOX_NPM_UNIT_FETCH_TIMEOUT=${SANDBOX_NPM_UNIT_FETCH_TIMEOUT:-20000} depends_on: egress_gateway: condition: service_healthy diff --git a/docker/Dockerfile.worker-sandbox b/docker/Dockerfile.worker-sandbox index cd3edd3..294f4f9 100644 --- a/docker/Dockerfile.worker-sandbox +++ b/docker/Dockerfile.worker-sandbox @@ -97,7 +97,8 @@ RUN bun install --frozen-lockfile COPY api/src ./src COPY shared /shared COPY api/tsconfig.json ./ -RUN bun build ./src/index.ts --minify --outdir .build --target bun --external '@opentelemetry/*' +RUN bun build ./src/index.ts --minify --outdir .build --target bun --external '@opentelemetry/*' \ + && bun build ./src/npm-unit-worker.ts --target=node --format=cjs --outfile=.build/npm-unit-worker.cjs --external web-tree-sitter # ============================================================================ # Stage 4: Build sandbox rootfs (full OS layer for the microVM guest) diff --git a/docker/package-init.sh b/docker/package-init.sh index 60855b9..e955765 100644 --- a/docker/package-init.sh +++ b/docker/package-init.sh @@ -25,6 +25,7 @@ BUN_VERSION="${BUN_VERSION:-1.3.14}" BASH_PACKAGE_VERSION="${BASH_PACKAGE_VERSION:-5.2.0}" INSTALL_FAILED=false JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" +NPM_UNIT_PACKAGES=("web-tree-sitter@0.24.7" "tree-sitter-wasms@0.1.13") load_js_packages() { if [ ! -f "$JS_PACKAGE_MANIFEST" ]; then @@ -79,6 +80,23 @@ js_packages_ready() { done } +npm_unit_packages_ready() { + local pkg_root="$1" + [ -f "${pkg_root}/node_modules/web-tree-sitter/package.json" ] && + [ -f "${pkg_root}/node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm" ] && + [ -f "${pkg_root}/node_modules/tree-sitter-wasms/out/tree-sitter-tsx.wasm" ] && + [ -f "${pkg_root}/node_modules/tree-sitter-wasms/out/tree-sitter-javascript.wasm" ] +} + +prune_tree_sitter_wasms() { + local root="$1/node_modules/tree-sitter-wasms/out" + [ -d "$root" ] || return 0 + find "$root" -type f -name '*.wasm' \ + ! -name 'tree-sitter-typescript.wasm' \ + ! -name 'tree-sitter-tsx.wasm' \ + ! -name 'tree-sitter-javascript.wasm' -delete +} + load_js_packages echo "==============================================" @@ -95,6 +113,7 @@ packages_ready() { [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/rasterio" ] && [ -f "/pkgs/node/${NODE_VERSION}/.package-installed" ] && js_packages_ready "/pkgs/node/${NODE_VERSION}" && + npm_unit_packages_ready "/pkgs/node/${NODE_VERSION}" && [ -f "/pkgs/bun/${BUN_VERSION}/.package-installed" ] && js_packages_ready "/pkgs/bun/${BUN_VERSION}" && [ -f "/pkgs/bash/${BASH_PACKAGE_VERSION}/.package-installed" ] @@ -346,10 +365,12 @@ if [ "$NODE_INSTALLED" = true ] && [ "${#JS_PACKAGES[@]}" -gt 0 ] && [ -f "$NODE --no-fund \ --save-exact \ --package-lock=false \ - "${JS_PACKAGES[@]}"; then + "${JS_PACKAGES[@]}" \ + "${NPM_UNIT_PACKAGES[@]}"; then echo "ERROR: Node.js package installation failed" INSTALL_FAILED=true else + prune_tree_sitter_wasms "$NODE_DEST" echo "$(date +%s)000" > "$NODE_DEST/.package-installed" fi diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 9198b1a..30aa4e8 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -45,6 +45,34 @@ to a values file. is publicly known (the same keypair is hardcoded in the unit tests), so never use it outside local development. +## Stateless npm declaration indexing (opt in) + +Set `npmUnit.enabled=true` to expose `POST /v1/sandbox/npm-unit`. The route +accepts one exact `name@version`, one SHA-512 SRI digest, the canonical tarball +URL on `npmUnit.registryOrigin`, and the fixed keep set +`["**/*.d.ts", "package.json"]`. It returns deterministic declaration symbols, +imports, rejection counters, and resource usage without running package code, +install scripts, or `tsc`. + +The feature is off by default because enabling it gives only the egress-gateway +public TCP/443 access. Kubernetes NetworkPolicy still excludes private, +loopback, link-local, carrier-grade NAT, benchmark, multicast, and reserved +IPv4 ranges. At the application layer the worker mints a short-lived encrypted +capability for the exact registry tarball, cross-origin redirects are refused, +the tarball is byte-capped in transit, and the parse stage runs in a fresh +network-disabled NsJail with route-specific memory, CPU, wall-clock, +decompression, entry, file, retained-byte, and response limits. + +The route is a synchronous API → service-worker → sandbox request and never +creates a BullMQ job or persists an npm-unit result. Capacity is fail-fast: a +busy dispatcher returns a retryable structured failure instead of retaining a +background job after the caller disconnects. + +The main tuning values live under `npmUnit`; defaults are 8 concurrent requests, a +32 MiB compressed tarball, 64 MiB decompressed archive, 384 MiB cgroup memory, +and 15 seconds of parse time. Rebuild the sandbox/package image when enabling +the route so the pinned `web-tree-sitter` and grammar WASM assets are present. + ## Production deployment notes This chart deploys the full service stack on a single cluster and is the diff --git a/helm/codeapi/templates/api-deployment.yaml b/helm/codeapi/templates/api-deployment.yaml index 0c801ab..3396ee5 100644 --- a/helm/codeapi/templates/api-deployment.yaml +++ b/helm/codeapi/templates/api-deployment.yaml @@ -1,11 +1,15 @@ {{/* API Deployment This creates the API pods that handle HTTP requests. -It doesn't run workers - just submits jobs to the queue. +It submits execution jobs to the queue and forwards npm-unit synchronously to +the authenticated direct dispatcher. */}} {{- if and .Values.api.enabled (not .Values.egressGateway.enabled) }} {{- fail "api.enabled requires egressGateway.enabled=true because sandbox-originated file and PTC traffic must not bypass the egress gateway" }} {{- end }} +{{- if and .Values.npmUnit.enabled (or (not .Values.api.enabled) (not .Values.workerSandbox.enabled)) }} +{{- fail "npmUnit.enabled requires both api.enabled=true and workerSandbox.enabled=true for direct stateless dispatch" }} +{{- end }} {{- if .Values.api.enabled }} apiVersion: apps/v1 kind: Deployment @@ -65,6 +69,12 @@ spec: key: codeapi-internal-service-token - name: EGRESS_GRANT_TTL_SECONDS value: {{ .Values.egressGrant.ttlSeconds | quote }} + - name: CODEAPI_NPM_UNIT_ENABLED + value: {{ .Values.npmUnit.enabled | quote }} + - name: CODEAPI_NPM_REGISTRY_ORIGIN + value: {{ .Values.npmUnit.registryOrigin | quote }} + - name: CODEAPI_NPM_UNIT_DISPATCH_URL + value: "http://{{ include "codeapi.fullname" . }}-service-worker:{{ .Values.workerSandbox.healthPort }}/internal/npm-unit" # Server config - name: SERVICE_PORT value: {{ .Values.api.service.port | quote }} diff --git a/helm/codeapi/templates/egress-gateway-deployment.yaml b/helm/codeapi/templates/egress-gateway-deployment.yaml index 6e39490..c891a2f 100644 --- a/helm/codeapi/templates/egress-gateway-deployment.yaml +++ b/helm/codeapi/templates/egress-gateway-deployment.yaml @@ -50,6 +50,16 @@ spec: value: "http://{{ include "codeapi.fullname" . }}-tool-call-server:{{ .Values.toolCallServer.service.port }}" - name: EGRESS_GATEWAY_MAX_TOOL_CALL_BYTES value: {{ printf "%.0f" (.Values.egressGateway.config.maxToolCallBytes | float64) | quote }} + - name: CODEAPI_NPM_UNIT_ENABLED + value: {{ .Values.npmUnit.enabled | quote }} + - name: CODEAPI_NPM_REGISTRY_ORIGIN + value: {{ .Values.npmUnit.registryOrigin | quote }} + - name: CODEAPI_NPM_TARBALL_MAX_BYTES + value: {{ .Values.npmUnit.tarballMaxBytes | quote }} + - name: CODEAPI_NPM_FETCH_TIMEOUT_MS + value: {{ .Values.npmUnit.fetchTimeoutMs | quote }} + - name: CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS + value: {{ .Values.npmUnit.fetchTokenTtlSeconds | quote }} - name: CODEAPI_EGRESS_GRANT_SECRET valueFrom: secretKeyRef: diff --git a/helm/codeapi/templates/network-policy.yaml b/helm/codeapi/templates/network-policy.yaml index 2184e9f..a46ce34 100644 --- a/helm/codeapi/templates/network-policy.yaml +++ b/helm/codeapi/templates/network-policy.yaml @@ -142,6 +142,32 @@ spec: - protocol: TCP port: {{ .Values.toolCallServer.service.port }} {{- include "codeapi.redisEgress" . | nindent 4 }} + {{- if .Values.npmUnit.enabled }} + # Only the gateway may reach the registry. Its npm route separately pins + # the request to the configured origin and exact name/version tarball URL. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.88.99.0/24 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + ports: + - protocol: TCP + port: 443 + {{- end }} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -192,6 +218,13 @@ spec: ports: - protocol: TCP port: {{ .Values.egressGateway.service.port }} + - to: + - podSelector: + matchLabels: + {{- include "codeapi.serviceWorker.selectorLabels" . | nindent 14 }} + ports: + - protocol: TCP + port: {{ .Values.workerSandbox.healthPort }} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy diff --git a/helm/codeapi/templates/worker-sandbox-deployment.yaml b/helm/codeapi/templates/worker-sandbox-deployment.yaml index 3484a61..31c9aef 100644 --- a/helm/codeapi/templates/worker-sandbox-deployment.yaml +++ b/helm/codeapi/templates/worker-sandbox-deployment.yaml @@ -174,6 +174,14 @@ spec: value: {{ .Values.executionManifest.ttlSeconds | quote }} - name: EGRESS_GRANT_TTL_SECONDS value: {{ .Values.egressGrant.ttlSeconds | quote }} + - name: CODEAPI_NPM_UNIT_ENABLED + value: {{ .Values.npmUnit.enabled | quote }} + - name: CODEAPI_NPM_REGISTRY_ORIGIN + value: {{ .Values.npmUnit.registryOrigin | quote }} + - name: NPM_UNIT_CONCURRENCY + value: {{ .Values.npmUnit.concurrency | quote }} + - name: NPM_UNIT_REQUEST_TIMEOUT + value: {{ .Values.npmUnit.requestTimeoutMs | quote }} - name: PYTHON_CONCURRENCY value: {{ .Values.workerSandbox.config.pythonConcurrency | quote }} - name: OTHER_CONCURRENCY @@ -322,6 +330,28 @@ spec: value: "true" - name: SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY value: {{ .Values.executionManifest.publicKey | quote }} + - name: CODEAPI_NPM_UNIT_ENABLED + value: {{ .Values.npmUnit.enabled | quote }} + - name: CODEAPI_NPM_TARBALL_MAX_BYTES + value: {{ .Values.npmUnit.tarballMaxBytes | quote }} + - name: SANDBOX_NPM_UNIT_MAX_UNPACKED_BYTES + value: {{ .Values.npmUnit.sandbox.maxUnpackedBytes | quote }} + - name: SANDBOX_NPM_UNIT_MAX_KEPT_BYTES + value: {{ .Values.npmUnit.sandbox.maxKeptBytes | quote }} + - name: SANDBOX_NPM_UNIT_MAX_FILE_BYTES + value: {{ .Values.npmUnit.sandbox.maxFileBytes | quote }} + - name: SANDBOX_NPM_UNIT_MAX_ENTRIES + value: {{ .Values.npmUnit.sandbox.maxEntries | quote }} + - name: SANDBOX_NPM_UNIT_MAX_RESPONSE_BYTES + value: {{ .Values.npmUnit.sandbox.maxResponseBytes | quote }} + - name: SANDBOX_NPM_UNIT_RUN_TIMEOUT + value: {{ .Values.npmUnit.sandbox.runTimeoutMs | quote }} + - name: SANDBOX_NPM_UNIT_CPU_TIME + value: {{ .Values.npmUnit.sandbox.cpuTimeMs | quote }} + - name: SANDBOX_NPM_UNIT_MEMORY_LIMIT + value: {{ .Values.npmUnit.sandbox.memoryLimitBytes | quote }} + - name: SANDBOX_NPM_UNIT_FETCH_TIMEOUT + value: {{ .Values.npmUnit.sandbox.fetchTimeoutMs | quote }} {{- with .Values.workerSandbox.sandboxExtraEnv }} {{- toYaml . | nindent 12 }} {{- end }} @@ -442,6 +472,22 @@ spec: --- apiVersion: v1 kind: Service +metadata: + name: {{ include "codeapi.fullname" . }}-service-worker + labels: + {{- include "codeapi.serviceWorker.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.workerSandbox.healthPort }} + targetPort: health + protocol: TCP + name: internal + selector: + {{- include "codeapi.serviceWorker.selectorLabels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Service metadata: name: {{ include "codeapi.fullname" . }}-sandbox-runner labels: diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index 2385814..cd8bb9e 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -55,6 +55,30 @@ executionManifest: publicKey: "" ttlSeconds: 300 +# Opt-in stateless indexing of an exact npm package tarball. The egress gateway +# alone receives public HTTPS access, and it accepts only short-lived, +# body-bound capabilities for the configured registry URL. +npmUnit: + enabled: false + registryOrigin: "https://registry.npmjs.org" + tarballMaxBytes: 33554432 + fetchTimeoutMs: 15000 + fetchTokenTtlSeconds: 120 + # Fail fast above this per-service-worker cap; npm-unit requests are never + # written to the Redis job queue. + concurrency: 8 + requestTimeoutMs: 45000 + sandbox: + maxUnpackedBytes: 67108864 + maxKeptBytes: 33554432 + maxFileBytes: 8388608 + maxEntries: 50000 + maxResponseBytes: 33554432 + runTimeoutMs: 15000 + cpuTimeMs: 15000 + memoryLimitBytes: 402653184 + fetchTimeoutMs: 20000 + # ============================================================================= # API SERVICE (HTTP handlers, scales based on traffic) # ============================================================================= diff --git a/service/openapi.yml b/service/openapi.yml index c1f8f6e..d99c6af 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -156,7 +156,190 @@ components: details: type: string + NpmUnitRequest: + type: object + additionalProperties: false + required: [name, version, integrity, resolved, keep] + properties: + name: + type: string + description: Lowercase unscoped name or one @scope/package pair. + version: + type: string + description: Exact semantic version; ranges and tags are refused. + integrity: + type: string + pattern: '^sha512-' + description: One canonical SHA-512 SRI digest verified before decompression. + resolved: + type: string + format: uri + description: Exact name@version tarball URL on the configured registry origin. + keep: + type: array + minItems: 2 + maxItems: 2 + uniqueItems: true + description: Must contain exactly **/*.d.ts and package.json. + items: + type: string + enum: ['**/*.d.ts', 'package.json'] + + NpmUnitRejected: + type: object + required: [link, device, unsafePath, oversize, other] + properties: + link: { type: integer, minimum: 0 } + device: { type: integer, minimum: 0 } + unsafePath: { type: integer, minimum: 0 } + oversize: { type: integer, minimum: 0 } + other: { type: integer, minimum: 0 } + + NpmUnitUsage: + type: object + required: [tarballBytes, unpackedBytes, peakRssBytes, wallMs] + properties: + tarballBytes: { type: integer, minimum: 0 } + unpackedBytes: { type: integer, minimum: 0 } + peakRssBytes: { type: integer, minimum: 0 } + wallMs: { type: integer, minimum: 0 } + + NpmUnitSuccess: + type: object + required: [status, name, version, integrityVerified, files, symbols, imports, rejected, usage] + properties: + status: + type: string + enum: [complete, partial] + name: { type: string } + version: { type: string } + integrityVerified: { type: boolean, enum: [true] } + files: + type: array + items: + type: object + required: [path, sha1, bytes] + properties: + path: { type: string } + sha1: { type: string, pattern: '^[0-9a-f]{40}$' } + bytes: { type: integer, minimum: 0 } + symbols: + type: array + description: Deterministically ordered by file, then declaration order. + items: + type: object + required: [file, ordinal, label, name, startLine, endLine, isExported, signature, tokens] + properties: + file: { type: string } + ordinal: { type: integer, minimum: 0 } + label: { type: string } + name: { type: string } + startLine: { type: integer, minimum: 1 } + endLine: { type: integer, minimum: 1 } + isExported: { type: boolean } + signature: { type: string } + tokens: { type: string } + imports: + type: array + items: + type: object + required: [file, spec] + properties: + file: { type: string } + spec: { type: string } + rejected: + $ref: '#/components/schemas/NpmUnitRejected' + usage: + $ref: '#/components/schemas/NpmUnitUsage' + errors: + type: array + items: + type: object + required: [code, message] + properties: + file: { type: string } + code: { type: string, enum: [parse_failed] } + message: { type: string } + + NpmUnitFailure: + type: object + required: [error, message, retryable] + properties: + error: + type: string + enum: [integrity_mismatch, not_found, registry_unavailable, too_large, decompression_limit, timeout, unsafe_entry, parse_failed, invalid_request, disabled, sandbox_unavailable] + message: { type: string } + retryable: { type: boolean } + rejected: + $ref: '#/components/schemas/NpmUnitRejected' + usage: + $ref: '#/components/schemas/NpmUnitUsage' + paths: + /sandbox/npm-unit: + post: + summary: Index an exact npm package declaration surface + description: >- + Fetches one registry tarball through a short-lived exact-package + capability, verifies SHA-512 before decompression, and parses only + TypeScript declarations in a fresh network-disabled sandbox. The + request is dispatched synchronously without a persisted job or result; + the feature is disabled by default. + operationId: indexNpmUnit + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NpmUnitRequest' + responses: + '200': + description: Complete or explicitly partial deterministic surface. + content: + application/json: + schema: + $ref: '#/components/schemas/NpmUnitSuccess' + '400': + description: Invalid or non-canonical request. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '404': + description: Exact package tarball not found. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '413': + description: Tarball, decompressed archive, file, entry, or output limit exceeded. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '422': + description: Integrity, archive-safety, or parse failure. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '501': + description: The configured sandbox backend does not support this stateless route. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '502': + description: Terminal registry policy refusal or internal dispatch misconfiguration. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '503': + description: Feature disabled or retryable registry/sandbox outage. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + '504': + description: Fetch or parse wall-clock limit exceeded. + content: + application/json: + schema: { $ref: '#/components/schemas/NpmUnitFailure' } + /exec: post: summary: Execute code diff --git a/service/src/api-server.ts b/service/src/api-server.ts index 89aba48..2b7ee28 100644 --- a/service/src/api-server.ts +++ b/service/src/api-server.ts @@ -4,6 +4,7 @@ * This is a stateless API server that: * - Handles HTTP requests * - Submits jobs to the global queue + * - Forwards npm-unit requests synchronously to the direct worker dispatcher * - Waits for results via Redis pub/sub * - Does NOT run workers (workers run in separate pods) * @@ -18,6 +19,7 @@ import { requestErrorLogger, requestNotFoundLogger } from './middleware/request- import { localAuth } from './auth/local'; import serviceRouter from './service/router'; import programmaticRouter from './service/programmatic-router'; +import npmUnitRouter from './service/npm-unit-router'; import { connection } from './queue'; import { metricsHandler } from './metrics'; import { httpMetricsMiddleware } from './middleware/httpMetrics'; @@ -53,6 +55,7 @@ v1.use(isLocalMode ? localAuth : apiKeyAuth); v1.use(serviceRouter); v1.use(programmaticRouter); +v1.use(npmUnitRouter); app.use('/v1', v1); app.use(requestNotFoundLogger); diff --git a/service/src/config.ts b/service/src/config.ts index c9dcb68..e73dc19 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -226,6 +226,11 @@ function configuredNumber(raw: string | undefined, fallback: number): number { return raw == null || raw.trim() === '' ? fallback : Number(raw); } +function positiveWholeNumber(raw: string | undefined, fallback: number): number { + const value = Number(raw); + return Number.isSafeInteger(value) && value > 0 ? value : fallback; +} + function configuredChoice( raw: string | undefined, name: string, @@ -278,6 +283,17 @@ export const env = { EGRESS_GATEWAY_MAX_NESTING_DEPTH: Number(process.env.EGRESS_GATEWAY_MAX_NESTING_DEPTH ?? process.env.SANDBOX_MAX_NESTING_DEPTH) || 10, EGRESS_GATEWAY_REQUEST_TIMEOUT_MS: Number(process.env.EGRESS_GATEWAY_REQUEST_TIMEOUT_MS) || 30_000, EGRESS_GATEWAY_REVOKE_TIMEOUT_MS: Number(process.env.EGRESS_GATEWAY_REVOKE_TIMEOUT_MS) || 5_000, + NPM_UNIT_ENABLED: process.env.CODEAPI_NPM_UNIT_ENABLED === 'true', + NPM_REGISTRY_ORIGIN: process.env.CODEAPI_NPM_REGISTRY_ORIGIN ?? 'https://registry.npmjs.org', + NPM_TARBALL_MAX_BYTES: positiveWholeNumber(process.env.CODEAPI_NPM_TARBALL_MAX_BYTES, 32 * 1024 * 1024), + NPM_FETCH_TIMEOUT_MS: positiveWholeNumber(process.env.CODEAPI_NPM_FETCH_TIMEOUT_MS, 15_000), + NPM_FETCH_TOKEN_TTL_SECONDS: Math.min( + positiveWholeNumber(process.env.CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS, 120), + 600, + ), + NPM_UNIT_DISPATCH_URL: process.env.CODEAPI_NPM_UNIT_DISPATCH_URL ?? '', + NPM_UNIT_CONCURRENCY: positiveWholeNumber(process.env.NPM_UNIT_CONCURRENCY, 8), + NPM_UNIT_REQUEST_TIMEOUT: positiveWholeNumber(process.env.NPM_UNIT_REQUEST_TIMEOUT, 45_000), EGRESS_LEDGER_REQUIRED: process.env.CODEAPI_EGRESS_LEDGER_REQUIRED === 'true' || process.env.CODEAPI_HARDENED_SANDBOX_MODE === 'true', EGRESS_LEDGER_TTL_GRACE_SECONDS: Number(process.env.CODEAPI_EGRESS_LEDGER_TTL_GRACE_SECONDS) || 300, EGRESS_GRANT_SECRET: process.env.CODEAPI_EGRESS_GRANT_SECRET ?? '', diff --git a/service/src/egress-gateway-client.ts b/service/src/egress-gateway-client.ts index ebcef4a..1967672 100644 --- a/service/src/egress-gateway-client.ts +++ b/service/src/egress-gateway-client.ts @@ -5,6 +5,7 @@ import { CODEAPI_SYNTHETIC_INTERNAL_REQUEST_HEADER } from './internal-synthetic' import { internalServiceHeaders } from './internal-service-auth'; import { injectTraceHeaders, normalizeTracePath, withSpan } from './telemetry'; import type * as t from './types'; +import type { NpmUnitRequest } from './npm-unit-contract'; type GatewayRequestOptions = { signal?: AbortSignal; @@ -132,3 +133,24 @@ export async function createGatewayPtcCallbackToken(args: { ), 'CLIENT'); return response.data.callbackToken; } + +export async function createGatewayNpmTarballToken(args: { + executionId: string; + request: NpmUnitRequest; + isSynthetic?: boolean; + signal?: AbortSignal; +}): Promise<{ fetchToken: string; expiresAt: number }> { + const response = await withSpan('codeapi.npm_tarball_token.create', { + 'http.request.method': 'POST', + 'url.path': '/internal/npm-tarball-tokens', + }, () => axios.post<{ fetchToken: string; expiresAt: number }>( + gatewayUrl('/internal/npm-tarball-tokens'), + { executionId: args.executionId, request: args.request }, + { + headers: injectTraceHeaders(gatewayHeaders({ 'Content-Type': 'application/json' }, args.isSynthetic)), + signal: args.signal, + timeout: env.EGRESS_GATEWAY_REQUEST_TIMEOUT_MS, + }, + ), 'CLIENT'); + return response.data; +} diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 9f72da5..7bf9793 100644 --- a/service/src/egress-gateway.test.ts +++ b/service/src/egress-gateway.test.ts @@ -21,6 +21,7 @@ import { type EgressGrantClaims, } from './egress-grant'; import { INTERNAL_SERVICE_TOKEN_HEADER } from './internal-service-auth'; +import { NPM_FETCH_TOKEN_HEADER, NPM_UNIT_KEEP } from './npm-unit-contract'; import type * as t from './types'; const { app } = await import('./egress-gateway'); @@ -179,8 +180,11 @@ async function gatewayFetch(path: string, init: RequestInit = {}): Promise { - server = app.listen(0); +beforeAll(async () => { + await new Promise((resolve, reject) => { + server = app.listen(0, resolve); + server.once('error', reject); + }); const address = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${address.port}`; }); @@ -194,6 +198,11 @@ beforeEach(() => { env.EGRESS_GATEWAY_MAX_PATH_LENGTH = 256; env.EGRESS_GATEWAY_MAX_NESTING_DEPTH = 10; env.EGRESS_LEDGER_REQUIRED = false; + env.NPM_UNIT_ENABLED = true; + env.NPM_REGISTRY_ORIGIN = 'https://registry.npmjs.org'; + env.NPM_TARBALL_MAX_BYTES = 1024; + env.NPM_FETCH_TIMEOUT_MS = 1000; + env.NPM_FETCH_TOKEN_TTL_SECONDS = 60; process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = INTERNAL_TOKEN; upstreamCalls = []; upstreamResponse = new Response('ok', { status: 200 }); @@ -210,6 +219,106 @@ afterAll(() => { }); describe('egress gateway routes', () => { + test('mints an exact npm capability and streams only its registry tarball', async () => { + const tarball = Buffer.from('package tarball'); + upstreamResponse = new Response(tarball, { + status: 200, + headers: { 'Content-Length': String(tarball.length) }, + }); + const integrity = `sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}`; + const request = { + name: '@scope/pkg', + version: '1.2.3', + integrity, + resolved: 'https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz', + keep: [...NPM_UNIT_KEEP], + }; + const mint = await gatewayFetch('/internal/npm-tarball-tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_SERVICE_TOKEN_HEADER]: INTERNAL_TOKEN, + }, + body: JSON.stringify({ executionId: 'exec_npm', request }), + }); + expect(mint.status).toBe(201); + const { fetchToken } = await mint.json() as { fetchToken: string }; + + const fetched = await gatewayFetch('/npm/tarball', { + headers: { [NPM_FETCH_TOKEN_HEADER]: fetchToken }, + }); + expect(fetched.status).toBe(200); + expect(Buffer.from(await fetched.arrayBuffer())).toEqual(tarball); + expect(upstreamCalls.map(call => call.url)).toEqual([request.resolved]); + expect(upstreamCalls[0].init.redirect).toBe('manual'); + }); + + test('refuses off-registry npm URLs before minting or fetching', async () => { + const integrity = `sha512-${crypto.createHash('sha512').update('x').digest('base64')}`; + const response = await gatewayFetch('/internal/npm-tarball-tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_SERVICE_TOKEN_HEADER]: INTERNAL_TOKEN, + }, + body: JSON.stringify({ + executionId: 'exec_npm', + request: { + name: 'pkg', + version: '1.2.3', + integrity, + resolved: 'https://attacker.example/pkg-1.2.3.tgz', + keep: [...NPM_UNIT_KEEP], + }, + }), + }); + + expect(response.status).toBe(400); + expect(upstreamCalls).toHaveLength(0); + }); + + test('refuses cross-origin npm redirects and oversized tarballs', async () => { + const tarball = Buffer.from('x'); + const integrity = `sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}`; + const request = { + name: 'pkg', + version: '1.2.3', + integrity, + resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', + keep: [...NPM_UNIT_KEEP], + }; + const mint = await gatewayFetch('/internal/npm-tarball-tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_SERVICE_TOKEN_HEADER]: INTERNAL_TOKEN, + }, + body: JSON.stringify({ executionId: 'exec_npm', request }), + }); + const { fetchToken } = await mint.json() as { fetchToken: string }; + + upstreamResponse = new Response(null, { + status: 302, + headers: { Location: 'https://attacker.example/pkg.tgz' }, + }); + const redirected = await gatewayFetch('/npm/tarball', { + headers: { [NPM_FETCH_TOKEN_HEADER]: fetchToken }, + }); + expect(redirected.status).toBe(502); + expect(await redirected.json()).toMatchObject({ error: 'registry_unavailable', retryable: false }); + + upstreamCalls = []; + upstreamResponse = new Response('x', { + status: 200, + headers: { 'Content-Length': '1025' }, + }); + const oversized = await gatewayFetch('/npm/tarball', { + headers: { [NPM_FETCH_TOKEN_HEADER]: fetchToken }, + }); + expect(oversized.status).toBe(413); + expect(await oversized.json()).toMatchObject({ error: 'too_large', retryable: false }); + }); + test('protects internal grant create, restore, and revoke routes', async () => { const createBody = JSON.stringify({ payload: payload(), claims: executionClaims() }); const unauthorized = await gatewayFetch('/internal/egress-grants', { diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index 3499de8..196b2b4 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -3,6 +3,8 @@ import express, { type Express, type NextFunction, type Request, type Response } import { nanoid } from 'nanoid'; import path from 'path'; import { Readable } from 'stream'; +import { Transform } from 'stream'; +import { pipeline } from 'stream/promises'; import { env } from './config'; import { EGRESS_GRANT_HEADER, @@ -10,12 +12,19 @@ import { egressGrantFromExecutionClaims, openEgressGrant, openPtcCallbackToken, + openNpmTarballToken, prepareSandboxEgress, restoreSandboxExecuteResult, sealEgressHandle, sealPtcCallbackToken, + sealNpmTarballToken, type EgressGrantClaims, } from './egress-grant'; +import { + NPM_FETCH_TOKEN_HEADER, + NpmUnitValidationError, + validateNpmUnitRequest, +} from './npm-unit-contract'; import type { ExecutionManifestClaims } from './execution-manifest'; import { openEgressRouteHandle } from './egress-route-params'; import { internalServiceHeaders, requireConfiguredInternalServiceAuth } from './internal-service-auth'; @@ -85,6 +94,7 @@ function routeFamily(req: Request): string { if (req.path === '/live' || req.path === '/health' || req.path === '/ready' || req.path === '/metrics') return req.path.slice(1); if (req.path.startsWith('/internal/')) return 'internal'; if (req.path === '/tool-call') return 'ptc-tool-call'; + if (req.path === '/npm/tarball') return 'npm-tarball'; if (req.path.startsWith('/sessions/')) { if (req.method === 'PUT') return 'file-upload'; if (req.method === 'GET' && req.path.includes('/objects/')) return 'file-download'; @@ -551,6 +561,175 @@ app.post('/internal/ptc-callback-token', express.json({ limit: '128kb' }), requi } }); +app.post('/internal/npm-tarball-tokens', express.json({ limit: '32kb' }), requireConfiguredInternalServiceAuth, async (req, res) => { + try { + if (!env.NPM_UNIT_ENABLED) { + return res.status(503).json({ error: 'disabled', message: 'npm unit indexing is disabled', retryable: false }); + } + const executionId = typeof req.body?.executionId === 'string' ? req.body.executionId : ''; + if (!executionId || executionId.length > 256) { + return res.status(400).json({ error: 'invalid_request', message: 'executionId is required', retryable: false }); + } + const request = validateNpmUnitRequest(req.body?.request, env.NPM_REGISTRY_ORIGIN); + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = issuedAt + env.NPM_FETCH_TOKEN_TTL_SECONDS; + const fetchToken = sealNpmTarballToken({ + executionId, + name: request.name, + version: request.version, + integrity: request.integrity, + resolved: request.resolved, + maxBytes: env.NPM_TARBALL_MAX_BYTES, + issuedAt, + expiresAt, + secret: env.EGRESS_GRANT_SECRET, + }); + return res.status(201).json({ fetchToken, expiresAt }); + } catch (error) { + if (error instanceof NpmUnitValidationError) { + return res.status(400).json({ error: 'invalid_request', message: error.message, retryable: false }); + } + return sendEgressError(req, res, error); + } +}); + +class NpmTarballLimitTransform extends Transform { + private seen = 0; + + constructor(private readonly maxBytes: number) { + super(); + } + + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void): void { + this.seen += chunk.length; + if (this.seen > this.maxBytes) { + callback(new Error('npm_tarball_too_large')); + return; + } + callback(null, chunk); + } +} + +async function fetchRegistryTarball( + initialUrl: string, + signal: AbortSignal, +): Promise { + let current = new URL(initialUrl); + const allowedOrigin = current.origin; + for (let redirects = 0; redirects <= 3; redirects++) { + const upstream = await fetch(current, { + method: 'GET', + headers: { Accept: 'application/octet-stream' }, + redirect: 'manual', + signal, + }); + if (upstream.status < 300 || upstream.status >= 400) return upstream; + const location = upstream.headers.get('location'); + await upstream.body?.cancel().catch(() => {}); + if (!location || redirects === 3) { + throw new Error('npm_registry_redirect_limit'); + } + const next = new URL(location, current); + if (next.protocol !== 'https:' || next.origin !== allowedOrigin || next.username || next.password) { + throw new Error('npm_registry_cross_origin_redirect'); + } + current = next; + } + throw new Error('npm_registry_redirect_limit'); +} + +app.get('/npm/tarball', async (req, res) => { + let timeout: ReturnType | undefined; + try { + if (!env.NPM_UNIT_ENABLED) { + return res.status(503).json({ error: 'disabled', message: 'npm unit indexing is disabled', retryable: false }); + } + const token = req.header(NPM_FETCH_TOKEN_HEADER); + if (!token) { + return res.status(401).json({ error: 'invalid_request', message: `${NPM_FETCH_TOKEN_HEADER} is required`, retryable: false }); + } + const capability = openNpmTarballToken(token, env.EGRESS_GRANT_SECRET); + const request = validateNpmUnitRequest({ + name: capability.name, + version: capability.version, + integrity: capability.integrity, + resolved: capability.resolved, + keep: ['**/*.d.ts', 'package.json'], + }, env.NPM_REGISTRY_ORIGIN); + const maxBytes = Math.min(capability.max_bytes, env.NPM_TARBALL_MAX_BYTES); + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), env.NPM_FETCH_TIMEOUT_MS); + req.once('aborted', () => controller.abort()); + const upstream = await fetchRegistryTarball(request.resolved, controller.signal); + + if (upstream.status === 404) { + await upstream.body?.cancel().catch(() => {}); + return res.status(404).json({ error: 'not_found', message: 'Package tarball was not found', retryable: false }); + } + if (!upstream.ok) { + await upstream.body?.cancel().catch(() => {}); + const retryable = upstream.status >= 500 || upstream.status === 408 || upstream.status === 429; + return res.status(retryable ? 503 : 502).json({ + error: 'registry_unavailable', + message: `Registry returned HTTP ${upstream.status}`, + retryable, + }); + } + + const contentLength = upstream.headers.get('content-length'); + if (contentLength !== null) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + await upstream.body?.cancel().catch(() => {}); + return res.status(502).json({ error: 'registry_unavailable', message: 'Registry returned an invalid Content-Length', retryable: true }); + } + if (parsed > maxBytes) { + await upstream.body?.cancel().catch(() => {}); + return res.status(413).json({ error: 'too_large', message: 'Package tarball exceeds the configured byte limit', retryable: false }); + } + res.setHeader('Content-Length', String(parsed)); + } + if (!upstream.body) { + return res.status(502).json({ error: 'registry_unavailable', message: 'Registry response had no body', retryable: true }); + } + + res.status(200); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-CodeAPI-Npm-Max-Bytes', String(maxBytes)); + await pipeline( + Readable.fromWeb(upstream.body as unknown as import('stream/web').ReadableStream), + new NpmTarballLimitTransform(maxBytes), + res, + ); + return; + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + if (!res.headersSent) { + return res.status(503).json({ error: 'registry_unavailable', message: 'Registry request timed out', retryable: true }); + } + res.destroy(error as Error); + return; + } + if (!res.headersSent && !(error instanceof EgressGrantError)) { + const message = (error as Error)?.message ?? ''; + const policyRefusal = message === 'npm_registry_cross_origin_redirect'; + return res.status(policyRefusal ? 502 : 503).json({ + error: 'registry_unavailable', + message: policyRefusal + ? 'Registry redirect left the configured origin' + : 'Registry request failed', + retryable: !policyRefusal, + }); + } + if (!res.headersSent) return sendEgressError(req, res, error); + res.destroy(error as Error); + return; + } finally { + if (timeout) clearTimeout(timeout); + } +}); + app.get('/sessions/:sessionHandle/objects', async (req, res) => { try { if (Object.keys(req.query).some(key => key !== 'detail') || req.query.detail !== 'normalized') { diff --git a/service/src/egress-grant.test.ts b/service/src/egress-grant.test.ts index 32ef298..14e7ec7 100644 --- a/service/src/egress-grant.test.ts +++ b/service/src/egress-grant.test.ts @@ -21,6 +21,8 @@ import { sealEgressHandle, sealPtcCallbackToken, openPtcCallbackToken, + sealNpmTarballToken, + openNpmTarballToken, } from './egress-grant'; import { openEgressRouteHandle } from './egress-route-params'; import type * as t from './types'; @@ -463,6 +465,35 @@ describe('egress encrypted grants and handles', () => { }); }); + test('seals a short-lived npm capability to one exact tarball and byte limit', () => { + const token = sealNpmTarballToken({ + executionId: 'exec_npm', + name: '@scope/pkg', + version: '1.2.3', + integrity: 'sha512-deadbeef', + resolved: 'https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz', + maxBytes: 1024, + issuedAt: 100, + expiresAt: 200, + secret: SECRET, + }); + + expect(token).not.toContain('@scope/pkg'); + expect(openNpmTarballToken(token, SECRET, 150)).toEqual({ + v: 1, + typ: 'npm-tarball', + exec_id: 'exec_npm', + name: '@scope/pkg', + version: '1.2.3', + integrity: 'sha512-deadbeef', + resolved: 'https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz', + max_bytes: 1024, + iat: 100, + exp: 200, + }); + expectEgressError(() => openNpmTarballToken(token, SECRET, 231), 'expired'); + }); + test('converts PTC callback timeout milliseconds to grant seconds', () => { expect(timeoutMsToGrantSeconds(300000)).toBe(300); expect(timeoutMsToGrantSeconds(300001)).toBe(301); diff --git a/service/src/egress-grant.ts b/service/src/egress-grant.ts index 8d8b135..d564008 100644 --- a/service/src/egress-grant.ts +++ b/service/src/egress-grant.ts @@ -88,6 +88,19 @@ export type EgressHandleClaims = allowed_tool_names?: string[]; iat: number; exp: number; + } + | { + v: typeof EGRESS_GRANT_VERSION; + typ: 'npm-tarball'; + grant_id?: never; + exec_id: string; + name: string; + version: string; + integrity: string; + resolved: string; + max_bytes: number; + iat: number; + exp: number; }; type WithoutVersion = T extends unknown ? Omit : never; @@ -280,6 +293,20 @@ function validateHandle(value: unknown): EgressHandleClaims { } return claims as EgressHandleClaims; } + if (claims.typ === 'npm-tarball') { + assertString(claims.name, 'name'); + assertString(claims.version, 'version'); + assertString(claims.integrity, 'integrity'); + assertString(claims.resolved, 'resolved'); + if ( + typeof claims.max_bytes !== 'number' || + !Number.isSafeInteger(claims.max_bytes) || + claims.max_bytes <= 0 + ) { + throw new EgressGrantError('malformed', 'Egress npm max_bytes is invalid'); + } + return claims as EgressHandleClaims; + } throw new EgressGrantError('wrong_type', 'Egress token is not a recognized handle'); } @@ -622,3 +649,39 @@ export function openPtcCallbackToken(token: string, secret: string, nowSeconds?: } return handle; } + +export function sealNpmTarballToken(args: { + executionId: string; + name: string; + version: string; + integrity: string; + resolved: string; + maxBytes: number; + issuedAt: number; + expiresAt: number; + secret: string; +}): string { + return sealEgressHandle({ + typ: 'npm-tarball', + exec_id: args.executionId, + name: args.name, + version: args.version, + integrity: args.integrity, + resolved: args.resolved, + max_bytes: args.maxBytes, + iat: args.issuedAt, + exp: args.expiresAt, + }, args.secret); +} + +export function openNpmTarballToken( + token: string, + secret: string, + nowSeconds?: number, +): Extract { + const handle = openEgressHandle(token, secret, nowSeconds); + if (handle.typ !== 'npm-tarball') { + throw new EgressGrantError('wrong_type', 'Egress token is not an npm tarball capability'); + } + return handle; +} diff --git a/service/src/execution-manifest.ts b/service/src/execution-manifest.ts index 35513aa..b6782e9 100644 --- a/service/src/execution-manifest.ts +++ b/service/src/execution-manifest.ts @@ -73,6 +73,7 @@ export interface ExecutionManifestClaims { exp: number; execute_body_sha256?: string; tool_call_socket?: boolean; + operation?: 'execute' | 'npm-unit'; external_user_id?: string; org_id?: string; service_id?: string; @@ -218,6 +219,9 @@ function validateClaimsShape(value: unknown): asserts value is ExecutionManifest if (claims.tool_call_socket !== undefined && typeof claims.tool_call_socket !== 'boolean') { throw new ExecutionManifestError('malformed', 'Execution manifest tool_call_socket is invalid'); } + if (claims.operation !== undefined && claims.operation !== 'execute' && claims.operation !== 'npm-unit') { + throw new ExecutionManifestError('malformed', 'Execution manifest operation is invalid'); + } for (const file of claims.input_files) { if ( file == null || diff --git a/service/src/local-api.ts b/service/src/local-api.ts index b9b280d..faec79a 100644 --- a/service/src/local-api.ts +++ b/service/src/local-api.ts @@ -10,6 +10,7 @@ import express, { json, Router } from 'express'; import serviceRouter from './service/router'; import programmaticRouter from './service/programmatic-router'; +import npmUnitRouter from './service/npm-unit-router'; import { requestErrorLogger, requestNotFoundLogger } from './middleware/request-error-logger'; import { localAuth } from './auth/local'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, connection } from './queue'; @@ -44,6 +45,7 @@ app.get('/v1/health', async (_, res) => { v1.use(localAuth); v1.use(serviceRouter); v1.use(programmaticRouter); +v1.use(npmUnitRouter); app.use('/v1', v1); app.use(requestNotFoundLogger); app.use(requestErrorLogger); diff --git a/service/src/npm-unit-contract.test.ts b/service/src/npm-unit-contract.test.ts new file mode 100644 index 0000000..c6f2338 --- /dev/null +++ b/service/src/npm-unit-contract.test.ts @@ -0,0 +1,61 @@ +import { createHash } from 'crypto'; +import { describe, expect, test } from 'bun:test'; +import { + NPM_UNIT_KEEP, + NpmUnitValidationError, + canonicalNpmTarballUrl, + validateNpmUnitRequest, +} from './npm-unit-contract'; + +const REGISTRY = 'https://registry.npmjs.org'; +const INTEGRITY = `sha512-${createHash('sha512').update('tarball').digest('base64')}`; + +function valid(overrides: Record = {}): Record { + return { + name: '@tanstack/react-query', + version: '4.36.1', + integrity: INTEGRITY, + resolved: 'https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz', + keep: [...NPM_UNIT_KEEP], + ...overrides, + }; +} + +describe('npm unit request contract', () => { + test('normalizes one exact scoped registry tarball request', () => { + expect(validateNpmUnitRequest(valid(), REGISTRY)).toEqual({ + name: '@tanstack/react-query', + version: '4.36.1', + integrity: INTEGRITY, + resolved: canonicalNpmTarballUrl('@tanstack/react-query', '4.36.1', REGISTRY), + keep: [...NPM_UNIT_KEEP], + }); + }); + + test.each([ + '../../evil', + '@scope/../evil', + '@scope', + '@scope/pkg/extra', + 'UpperCase', + '.hidden', + ])('rejects unsafe or non-canonical package name %s', name => { + expect(() => validateNpmUnitRequest(valid({ name }), REGISTRY)).toThrow(NpmUnitValidationError); + }); + + test('rejects an off-registry URL and a cross-package registry URL', () => { + expect(() => validateNpmUnitRequest(valid({ + resolved: 'https://evil.example/react-query-4.36.1.tgz', + }), REGISTRY)).toThrow('configured registry'); + expect(() => validateNpmUnitRequest(valid({ + resolved: 'https://registry.npmjs.org/zod/-/zod-4.36.1.tgz', + }), REGISTRY)).toThrow('exactly match'); + }); + + test('rejects flexible versions, non-sha512 integrity, mutable keep globs, and unknown fields', () => { + expect(() => validateNpmUnitRequest(valid({ version: '^4.36.1' }), REGISTRY)).toThrow('exact semantic'); + expect(() => validateNpmUnitRequest(valid({ integrity: 'sha1-deadbeef' }), REGISTRY)).toThrow('sha512'); + expect(() => validateNpmUnitRequest(valid({ keep: ['**/*'] }), REGISTRY)).toThrow('keep must be exactly'); + expect(() => validateNpmUnitRequest(valid({ extra: true }), REGISTRY)).toThrow('Unknown request fields'); + }); +}); diff --git a/service/src/npm-unit-contract.ts b/service/src/npm-unit-contract.ts new file mode 100644 index 0000000..501f06e --- /dev/null +++ b/service/src/npm-unit-contract.ts @@ -0,0 +1,222 @@ +export const NPM_UNIT_KEEP = ['**/*.d.ts', 'package.json'] as const; +export const NPM_FETCH_TOKEN_HEADER = 'X-CodeAPI-Npm-Fetch-Token'; + +const NPM_NAME_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/; +const EXACT_SEMVER_RE = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const SHA512_SRI_RE = /^sha512-([A-Za-z0-9+/]+={0,2})$/; + +export interface NpmUnitRequest { + name: string; + version: string; + integrity: string; + resolved: string; + keep: Array<(typeof NPM_UNIT_KEEP)[number]>; +} + +export type NpmUnitFailureCode = + | 'integrity_mismatch' + | 'not_found' + | 'registry_unavailable' + | 'too_large' + | 'decompression_limit' + | 'timeout' + | 'unsafe_entry' + | 'parse_failed'; + +export interface NpmUnitFailure { + error: NpmUnitFailureCode | 'invalid_request' | 'disabled' | 'sandbox_unavailable'; + message: string; + retryable: boolean; + rejected?: NpmUnitRejected; + usage?: NpmUnitUsage; +} + +export interface NpmUnitRejected { + link: number; + device: number; + unsafePath: number; + oversize: number; + other: number; +} + +export interface NpmUnitUsage { + tarballBytes: number; + unpackedBytes: number; + peakRssBytes: number; + wallMs: number; +} + +export interface NpmUnitFile { + path: string; + sha1: string; + bytes: number; +} + +export interface NpmUnitSymbol { + file: string; + ordinal: number; + label: string; + name: string; + startLine: number; + endLine: number; + isExported: boolean; + signature: string; + tokens: string; +} + +export interface NpmUnitImport { + file: string; + spec: string; +} + +export interface NpmUnitSuccess { + status: 'complete' | 'partial'; + name: string; + version: string; + integrityVerified: true; + files: NpmUnitFile[]; + symbols: NpmUnitSymbol[]; + imports: NpmUnitImport[]; + rejected: NpmUnitRejected; + usage: NpmUnitUsage; + errors?: Array<{ file?: string; code: NpmUnitFailureCode; message: string }>; +} + +export type NpmUnitResponse = NpmUnitSuccess | NpmUnitFailure; + +export class NpmUnitValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'NpmUnitValidationError'; + } +} + +function assertNpmName(name: unknown): asserts name is string { + if (typeof name !== 'string' || name.length === 0 || name.length > 214 || name !== name.toLowerCase()) { + throw new NpmUnitValidationError('name must be a lowercase npm package name of at most 214 characters'); + } + const parts = name.startsWith('@') ? name.slice(1).split('/') : name.split('/'); + if ( + parts.length !== (name.startsWith('@') ? 2 : 1) || + parts.some(part => !NPM_NAME_SEGMENT_RE.test(part) || part === '.' || part === '..') + ) { + throw new NpmUnitValidationError('name must be an unscoped package or one @scope/package pair'); + } +} + +function assertExactVersion(version: unknown): asserts version is string { + if (typeof version !== 'string' || version.length > 128 || !EXACT_SEMVER_RE.test(version)) { + throw new NpmUnitValidationError('version must be an exact semantic version'); + } +} + +function assertIntegrity(integrity: unknown): asserts integrity is string { + if (typeof integrity !== 'string') { + throw new NpmUnitValidationError('integrity must be a sha512 SRI string'); + } + const match = integrity.match(SHA512_SRI_RE); + if (!match) { + throw new NpmUnitValidationError('integrity must contain exactly one sha512 SRI digest'); + } + const digest = Buffer.from(match[1], 'base64'); + if (digest.length !== 64 || digest.toString('base64') !== match[1]) { + throw new NpmUnitValidationError('integrity must contain a canonical 64-byte sha512 digest'); + } +} + +function normalizedRegistryOrigin(raw: string): URL { + let registry: URL; + try { + registry = new URL(raw); + } catch { + throw new NpmUnitValidationError('The configured npm registry origin is invalid'); + } + if (registry.protocol !== 'https:' || registry.username || registry.password || registry.search || registry.hash) { + throw new NpmUnitValidationError('The configured npm registry must be an HTTPS origin'); + } + registry.pathname = registry.pathname.replace(/\/+$/, ''); + return registry; +} + +export function canonicalNpmTarballUrl(name: string, version: string, registryOrigin: string): string { + assertNpmName(name); + assertExactVersion(version); + const registry = normalizedRegistryOrigin(registryOrigin); + const baseName = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name; + const registryPrefix = registry.pathname === '/' ? '' : registry.pathname; + registry.pathname = `${registryPrefix}/${name}/-/${baseName}-${version}.tgz`; + return registry.toString(); +} + +function assertResolvedUrl( + resolved: unknown, + name: string, + version: string, + registryOrigin: string, +): asserts resolved is string { + if (typeof resolved !== 'string' || resolved.length > 2048) { + throw new NpmUnitValidationError('resolved must be the package tarball URL'); + } + let actual: URL; + let expected: URL; + try { + actual = new URL(resolved); + expected = new URL(canonicalNpmTarballUrl(name, version, registryOrigin)); + } catch { + throw new NpmUnitValidationError('resolved must be a valid registry tarball URL'); + } + let actualPath: string; + let expectedPath: string; + try { + actualPath = decodeURIComponent(actual.pathname); + expectedPath = decodeURIComponent(expected.pathname); + } catch { + throw new NpmUnitValidationError('resolved contains invalid path encoding'); + } + if ( + actual.protocol !== 'https:' || + actual.origin !== expected.origin || + actual.username || + actual.password || + actual.search || + actual.hash || + actualPath !== expectedPath + ) { + throw new NpmUnitValidationError('resolved must exactly match name@version on the configured registry'); + } +} + +function assertKeep(keep: unknown): asserts keep is NpmUnitRequest['keep'] { + if (!Array.isArray(keep) || keep.length !== NPM_UNIT_KEEP.length) { + throw new NpmUnitValidationError(`keep must be exactly ${JSON.stringify(NPM_UNIT_KEEP)}`); + } + const unique = new Set(keep); + if (unique.size !== NPM_UNIT_KEEP.length || NPM_UNIT_KEEP.some(value => !unique.has(value))) { + throw new NpmUnitValidationError(`keep must be exactly ${JSON.stringify(NPM_UNIT_KEEP)}`); + } +} + +export function validateNpmUnitRequest(raw: unknown, registryOrigin: string): NpmUnitRequest { + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new NpmUnitValidationError('Request body must be an object'); + } + const body = raw as Record; + const allowed = new Set(['name', 'version', 'integrity', 'resolved', 'keep']); + const unknown = Object.keys(body).filter(key => !allowed.has(key)); + if (unknown.length > 0) { + throw new NpmUnitValidationError(`Unknown request fields: ${unknown.sort().join(', ')}`); + } + assertNpmName(body.name); + assertExactVersion(body.version); + assertIntegrity(body.integrity); + assertResolvedUrl(body.resolved, body.name, body.version, registryOrigin); + assertKeep(body.keep); + return { + name: body.name, + version: body.version, + integrity: body.integrity, + resolved: canonicalNpmTarballUrl(body.name, body.version, registryOrigin), + keep: [...NPM_UNIT_KEEP], + }; +} diff --git a/service/src/npm-unit-dispatch.test.ts b/service/src/npm-unit-dispatch.test.ts new file mode 100644 index 0000000..56a4fce --- /dev/null +++ b/service/src/npm-unit-dispatch.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { NPM_UNIT_KEEP, type NpmUnitRequest } from './npm-unit-contract'; +import { + buildNpmUnitDispatchRequest, + validateNpmUnitDispatchRequest, +} from './npm-unit-dispatch'; + +const request: NpmUnitRequest = { + name: '@tanstack/react-query', + version: '4.36.1', + integrity: `sha512-${Buffer.alloc(64, 7).toString('base64')}`, + resolved: 'https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz', + keep: [...NPM_UNIT_KEEP], +}; + +describe('direct npm unit dispatch contract', () => { + test('builds opaque fixed-width identity labels and validates the request', () => { + const dispatch = buildNpmUnitDispatchRequest({ + executionId: 'abcdefghij_1234567890', + tenantId: 'tenant-secret', + canonicalUserId: 'user-secret', + principalSource: 'librechat_jwt', + request, + }); + + expect(dispatch.tenantLabel).toMatch(/^tenant:[A-Za-z0-9_-]{32}$/); + expect(dispatch.userLabel).toMatch(/^user:[A-Za-z0-9_-]{32}$/); + expect(JSON.stringify(dispatch)).not.toContain('tenant-secret'); + expect(JSON.stringify(dispatch)).not.toContain('user-secret'); + expect(validateNpmUnitDispatchRequest(dispatch)).toEqual(dispatch); + }); + + test('rejects extra fields and forged identity labels', () => { + const dispatch = buildNpmUnitDispatchRequest({ + executionId: 'abcdefghij_1234567890', + tenantId: 'tenant-secret', + canonicalUserId: 'user-secret', + principalSource: 'librechat_jwt', + request, + }); + + expect(() => validateNpmUnitDispatchRequest({ ...dispatch, queued: true })).toThrow('unknown dispatch field'); + expect(() => validateNpmUnitDispatchRequest({ ...dispatch, tenantLabel: 'tenant:raw-value' })).toThrow('tenantLabel'); + }); +}); diff --git a/service/src/npm-unit-dispatch.ts b/service/src/npm-unit-dispatch.ts new file mode 100644 index 0000000..4a1e2bb --- /dev/null +++ b/service/src/npm-unit-dispatch.ts @@ -0,0 +1,265 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import { env } from './config'; +import { createGatewayNpmTarballToken } from './egress-gateway-client'; +import { + EXECUTION_MANIFEST_VERSION, + executionManifestBodySha256, + signExecutionManifestWithKey, + type ExecutionManifestClaims, +} from './execution-manifest'; +import { internalServiceHeaders } from './internal-service-auth'; +import { isSyntheticPrincipalSource } from './auth/synthetic'; +import { + NpmUnitValidationError, + validateNpmUnitRequest, + type NpmUnitFailure, + type NpmUnitRequest, + type NpmUnitResponse, +} from './npm-unit-contract'; +import { getAxiosErrorDetails } from './utils'; +import { injectTraceHeaders, withSpan } from './telemetry'; +import logger from './logger'; + +export interface NpmUnitDispatchRequest { + executionId: string; + tenantLabel: string; + userLabel: string; + principalSource: string; + request: NpmUnitRequest; +} + +const EXECUTION_ID_RE = /^[A-Za-z0-9_-]{10,64}$/; +const TENANT_LABEL_RE = /^tenant:[A-Za-z0-9_-]{32}$/; +const USER_LABEL_RE = /^user:[A-Za-z0-9_-]{32}$/; + +function opaqueLabel(prefix: string, value: string): string { + return `${prefix}:${crypto.createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 32)}`; +} + +export function buildNpmUnitDispatchRequest(args: { + executionId: string; + tenantId: string; + canonicalUserId: string; + principalSource: string; + request: NpmUnitRequest; +}): NpmUnitDispatchRequest { + return { + executionId: args.executionId, + tenantLabel: opaqueLabel('tenant', args.tenantId), + userLabel: opaqueLabel('user', args.canonicalUserId), + principalSource: args.principalSource, + request: args.request, + }; +} + +function requiredString(value: unknown, name: string, pattern?: RegExp): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 128 || (pattern && !pattern.test(value))) { + throw new NpmUnitValidationError(`${name} is invalid`); + } + return value; +} + +export function validateNpmUnitDispatchRequest(value: unknown): NpmUnitDispatchRequest { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + throw new NpmUnitValidationError('dispatch body must be an object'); + } + const body = value as Record; + const allowed = new Set(['executionId', 'tenantLabel', 'userLabel', 'principalSource', 'request']); + const unknown = Object.keys(body).filter(key => !allowed.has(key)); + if (unknown.length > 0) throw new NpmUnitValidationError(`unknown dispatch field: ${unknown[0]}`); + return { + executionId: requiredString(body.executionId, 'executionId', EXECUTION_ID_RE), + tenantLabel: requiredString(body.tenantLabel, 'tenantLabel', TENANT_LABEL_RE), + userLabel: requiredString(body.userLabel, 'userLabel', USER_LABEL_RE), + principalSource: requiredString(body.principalSource, 'principalSource'), + request: validateNpmUnitRequest(body.request, env.NPM_REGISTRY_ORIGIN), + }; +} + +function structuredFailure(error: unknown): NpmUnitFailure | undefined { + if (!axios.isAxiosError(error)) return undefined; + const data = error.response?.data; + if (data == null || typeof data !== 'object' || Array.isArray(data)) return undefined; + const body = data as Record; + if (typeof body.error !== 'string' || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') { + return undefined; + } + const failure: NpmUnitFailure = { + error: body.error as NpmUnitFailure['error'], + message: body.message, + retryable: body.retryable, + }; + if (body.rejected && typeof body.rejected === 'object' && !Array.isArray(body.rejected)) { + failure.rejected = body.rejected as NpmUnitFailure['rejected']; + } + if (body.usage && typeof body.usage === 'object' && !Array.isArray(body.usage)) { + failure.usage = body.usage as NpmUnitFailure['usage']; + } + return failure; +} + +let activeDispatches = 0; + +/** + * Runs one request synchronously. Capacity is deliberately fail-fast rather + * than queued: callers retain ownership of retries and this service persists + * no npm-unit job or result state. + */ +export async function processNpmUnitDispatch( + raw: NpmUnitDispatchRequest, + callerSignal?: AbortSignal, +): Promise { + let input: NpmUnitDispatchRequest; + try { + input = validateNpmUnitDispatchRequest(raw); + } catch (error) { + return { + error: 'invalid_request', + message: error instanceof Error ? error.message : 'invalid dispatch request', + retryable: false, + }; + } + if (!env.NPM_UNIT_ENABLED) { + return { error: 'disabled', message: 'npm unit indexing is disabled', retryable: false }; + } + if (env.SANDBOX_BACKEND !== 'http') { + return { + error: 'sandbox_unavailable', + message: 'npm unit indexing currently requires the stateless HTTP sandbox backend', + retryable: false, + }; + } + if (activeDispatches >= env.NPM_UNIT_CONCURRENCY) { + return { + error: 'sandbox_unavailable', + message: 'npm unit sandbox is at capacity', + retryable: true, + }; + } + + activeDispatches += 1; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, env.NPM_UNIT_REQUEST_TIMEOUT); + const abortFromCaller = () => controller.abort(); + callerSignal?.addEventListener('abort', abortFromCaller, { once: true }); + if (callerSignal?.aborted) controller.abort(); + + try { + return await withSpan('codeapi.npm_unit.dispatch', { + 'codeapi.language': 'npm-unit', + 'codeapi.dispatch_mode': 'direct', + }, async () => { + const isSynthetic = isSyntheticPrincipalSource(input.principalSource); + const { fetchToken } = await createGatewayNpmTarballToken({ + executionId: input.executionId, + request: input.request, + isSynthetic, + signal: controller.signal, + }); + const body: Record = { + execution_id: input.executionId, + ...input.request, + fetch_token: fetchToken, + }; + if (env.EXECUTION_MANIFEST_PRIVATE_KEY || env.EXECUTION_MANIFEST_SECRET) { + const now = Math.floor(Date.now() / 1000); + const claims: ExecutionManifestClaims = { + v: EXECUTION_MANIFEST_VERSION, + operation: 'npm-unit', + exec_id: input.executionId, + tenant_id: input.tenantLabel, + user_id: input.userLabel, + session_key: opaqueLabel('session', input.executionId), + input_files: [], + read_sessions: [], + output_session_id: opaqueLabel('output', input.executionId), + max_upload_bytes: 0, + max_output_files: 0, + max_requests: 1, + iat: now, + exp: now + env.EXECUTION_MANIFEST_TTL_SECONDS, + execute_body_sha256: executionManifestBodySha256(body), + principal_source: input.principalSource, + }; + body.execution_manifest = signExecutionManifestWithKey(claims, { + privateKey: env.EXECUTION_MANIFEST_PRIVATE_KEY, + secret: env.EXECUTION_MANIFEST_SECRET, + }); + } + + try { + const response = await axios.post( + `${env.SANDBOX_ENDPOINT.replace(/\/+$/, '')}/npm-unit`, + body, + { + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + timeout: env.NPM_UNIT_REQUEST_TIMEOUT, + }, + ); + return response.data; + } catch (error) { + const failure = structuredFailure(error); + if (failure) return failure; + if (timedOut || controller.signal.aborted) { + return { error: 'timeout', message: 'npm unit request exceeded its wall-clock limit', retryable: true }; + } + logger.error('npm unit sandbox request failed', { + error: getAxiosErrorDetails(error), + executionId: input.executionId, + }); + return { error: 'sandbox_unavailable', message: 'npm unit sandbox was unavailable', retryable: true }; + } + }, 'INTERNAL'); + } catch (error) { + const failure = structuredFailure(error); + if (failure) return failure; + if (timedOut || controller.signal.aborted) { + return { error: 'timeout', message: 'npm unit request exceeded its wall-clock limit', retryable: true }; + } + logger.error('npm unit direct dispatch failed', { + error: getAxiosErrorDetails(error), + executionId: input.executionId, + }); + return { error: 'sandbox_unavailable', message: 'npm unit sandbox was unavailable', retryable: true }; + } finally { + clearTimeout(timeout); + callerSignal?.removeEventListener('abort', abortFromCaller); + activeDispatches -= 1; + } +} + +export async function dispatchNpmUnitOverHttp( + input: NpmUnitDispatchRequest, + signal?: AbortSignal, +): Promise { + if (!env.NPM_UNIT_DISPATCH_URL.trim()) return processNpmUnitDispatch(input, signal); + try { + const response = await withSpan('codeapi.npm_unit.forward', { + 'http.request.method': 'POST', + 'url.path': '/internal/npm-unit', + }, () => axios.post( + env.NPM_UNIT_DISPATCH_URL, + input, + { + headers: injectTraceHeaders(internalServiceHeaders({ 'Content-Type': 'application/json' })), + signal, + timeout: env.NPM_UNIT_REQUEST_TIMEOUT + 1_000, + }, + ), 'CLIENT'); + return response.data; + } catch (error) { + if (signal?.aborted) throw error; + const failure = structuredFailure(error); + if (failure) return failure; + if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { + return { error: 'timeout', message: 'npm unit request exceeded its wall-clock limit', retryable: true }; + } + throw error; + } +} diff --git a/service/src/secure-startup.test.ts b/service/src/secure-startup.test.ts index 720809b..eb4d4d8 100644 --- a/service/src/secure-startup.test.ts +++ b/service/src/secure-startup.test.ts @@ -38,6 +38,8 @@ const saved = { ledgerRequired: env.EGRESS_LEDGER_REQUIRED, fileServerUrl: env.EGRESS_GATEWAY_FILE_SERVER_URL, toolCallUrl: env.EGRESS_GATEWAY_TOOL_CALL_SERVER_URL, + npmUnitEnabled: env.NPM_UNIT_ENABLED, + npmUnitDispatchUrl: env.NPM_UNIT_DISPATCH_URL, }; function restore(): void { @@ -74,6 +76,8 @@ function restore(): void { env.EGRESS_LEDGER_REQUIRED = saved.ledgerRequired; env.EGRESS_GATEWAY_FILE_SERVER_URL = saved.fileServerUrl; env.EGRESS_GATEWAY_TOOL_CALL_SERVER_URL = saved.toolCallUrl; + env.NPM_UNIT_ENABLED = saved.npmUnitEnabled; + env.NPM_UNIT_DISPATCH_URL = saved.npmUnitDispatchUrl; } afterEach(restore); @@ -135,6 +139,19 @@ describe('hardened CodeAPI startup config', () => { expect(() => validateWorkerHardenedConfig()).toThrow('CODEAPI_EXECUTION_MANIFEST_PRIVATE_KEY'); }); + test('requires direct npm-unit dispatch when the route is enabled on an API pod', () => { + env.HARDENED_SANDBOX_MODE = true; + env.EGRESS_GATEWAY_URL = 'http://egress-gateway:3190'; + process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = 'internal-token'; + env.NPM_UNIT_ENABLED = true; + env.NPM_UNIT_DISPATCH_URL = ''; + + expect(() => validateApiHardenedConfig()).toThrow('CODEAPI_NPM_UNIT_DISPATCH_URL'); + + env.NPM_UNIT_DISPATCH_URL = 'http://service-worker:3113/internal/npm-unit'; + expect(() => validateApiHardenedConfig()).not.toThrow(); + }); + test('requires strong gateway secret, Redis ledger, and upstream URLs', () => { env.HARDENED_SANDBOX_MODE = true; env.EGRESS_GRANT_SECRET = 'strong-egress-grant-secret-32-bytes'; diff --git a/service/src/secure-startup.ts b/service/src/secure-startup.ts index 8d4729d..970fc00 100644 --- a/service/src/secure-startup.ts +++ b/service/src/secure-startup.ts @@ -51,6 +51,9 @@ export function validateApiHardenedConfig(): void { rejectValue('CODEAPI_EGRESS_GRANT_SECRET', process.env.CODEAPI_EGRESS_GRANT_SECRET); requireValue('EGRESS_GATEWAY_URL', env.EGRESS_GATEWAY_URL); requireValue(INTERNAL_SERVICE_TOKEN_ENV, process.env[INTERNAL_SERVICE_TOKEN_ENV]); + if (env.NPM_UNIT_ENABLED) { + requireValue('CODEAPI_NPM_UNIT_DISPATCH_URL', env.NPM_UNIT_DISPATCH_URL); + } } export function validateWorkerHardenedConfig(): void { diff --git a/service/src/service-api.ts b/service/src/service-api.ts index c664f14..ec732a7 100644 --- a/service/src/service-api.ts +++ b/service/src/service-api.ts @@ -4,6 +4,7 @@ import { apiKeyAuth } from './middleware/auth'; import { requestErrorLogger, requestNotFoundLogger } from './middleware/request-error-logger'; import serviceRouter from './service/router'; import programmaticRouter from './service/programmatic-router'; +import npmUnitRouter from './service/npm-unit-router'; import { connection } from './queue'; import { env } from './config'; import logger from './logger'; @@ -30,6 +31,7 @@ v1.use(apiKeyAuth); v1.use(serviceRouter); v1.use(programmaticRouter); +v1.use(npmUnitRouter); app.use('/v1', v1); app.use(requestNotFoundLogger); diff --git a/service/src/service/npm-unit-router.ts b/service/src/service/npm-unit-router.ts new file mode 100644 index 0000000..26b27fb --- /dev/null +++ b/service/src/service/npm-unit-router.ts @@ -0,0 +1,101 @@ +import { nanoid } from 'nanoid'; +import { Router } from 'express'; +import type * as t from '../types'; +import { checkServiceShutDown, checkServiceStartUp } from '../lifecycle'; +import { env } from '../config'; +import { executionLimiter } from '../middleware/limits'; +import { getPrincipalOrReject } from '../auth/principal'; +import { getExecutionIdentity } from '../execution-identity'; +import { + NpmUnitValidationError, + validateNpmUnitRequest, + type NpmUnitFailure, + type NpmUnitRequest, + type NpmUnitResponse, +} from '../npm-unit-contract'; +import { + buildNpmUnitDispatchRequest, + dispatchNpmUnitOverHttp, +} from '../npm-unit-dispatch'; +import logger from '../logger'; + +const router = Router(); + +function responseStatus(body: NpmUnitResponse): number { + if (!('error' in body)) return 200; + if (body.error === 'not_found') return 404; + if (body.error === 'too_large' || body.error === 'decompression_limit') return 413; + if (body.error === 'integrity_mismatch' || body.error === 'unsafe_entry' || body.error === 'parse_failed') return 422; + if (body.error === 'timeout') return 504; + if (body.error === 'invalid_request') return 400; + if (body.error === 'disabled') return 503; + return body.retryable ? 503 : 502; +} + +router.post('/sandbox/npm-unit', executionLimiter, async (req: t.AuthenticatedRequest, res) => { + const principal = getPrincipalOrReject(req, res); + if (!principal) return; + if (!env.NPM_UNIT_ENABLED) { + const body: NpmUnitFailure = { + error: 'disabled', + message: 'npm unit indexing is disabled', + retryable: false, + }; + return res.status(503).json(body); + } + if (env.SANDBOX_BACKEND !== 'http') { + const body: NpmUnitFailure = { + error: 'sandbox_unavailable', + message: 'npm unit indexing currently requires the stateless HTTP sandbox backend', + retryable: false, + }; + return res.status(501).json(body); + } + if (checkServiceShutDown()) { + return res.status(503).json({ error: 'sandbox_unavailable', message: 'Service is shutting down', retryable: true }); + } + if (checkServiceStartUp()) { + return res.status(503).json({ error: 'sandbox_unavailable', message: 'Service is starting up', retryable: true }); + } + + let request: NpmUnitRequest; + try { + request = validateNpmUnitRequest(req.body, env.NPM_REGISTRY_ORIGIN); + } catch (error) { + if (error instanceof NpmUnitValidationError) { + return res.status(400).json({ error: 'invalid_request', message: error.message, retryable: false }); + } + throw error; + } + + const identity = getExecutionIdentity(req, principal.userId); + const executionId = nanoid(); + const controller = new AbortController(); + const abort = () => controller.abort(); + req.once('aborted', abort); + res.once('close', abort); + try { + const result = await dispatchNpmUnitOverHttp(buildNpmUnitDispatchRequest({ + request, + executionId, + tenantId: identity.storageNamespace, + canonicalUserId: identity.canonicalUserId, + principalSource: identity.principalSource, + }), controller.signal); + if (res.writableEnded || controller.signal.aborted) return; + return res.status(responseStatus(result)).json(result); + } catch (error) { + if (!controller.signal.aborted) logger.error('npm unit request failed', { executionId, error }); + if (res.writableEnded || controller.signal.aborted) return; + return res.status(503).json({ + error: 'sandbox_unavailable', + message: 'npm unit sandbox was unavailable', + retryable: true, + }); + } finally { + req.off('aborted', abort); + res.off('close', abort); + } +}); + +export default router; diff --git a/service/src/worker-server.ts b/service/src/worker-server.ts index 8904904..80e02fd 100644 --- a/service/src/worker-server.ts +++ b/service/src/worker-server.ts @@ -3,9 +3,10 @@ * * This is a worker process that: * - Processes jobs from the global queue + * - Directly dispatches stateless npm-unit requests without queueing them * - Sends code to co-located sandbox for execution * - Returns results via Redis pub/sub - * - Does NOT handle HTTP requests (API runs in separate pods) + * - Exposes only health/metrics and the authenticated internal npm dispatcher * * For horizontal scaling: * - Deploy this as a pod WITH a sandbox sidecar @@ -26,6 +27,15 @@ import { startWorkerServer, gracefulShutdown } from './lifecycle'; import { httpLatencyElapsedSeconds, httpLatencyStartMs, metricsResponse, recordHttpRequest } from './metrics'; import { env } from './config'; import logger from './logger'; +import { + internalServiceAuthEnabled, + isAuthorizedInternalServiceRequest, +} from './internal-service-auth'; +import { + processNpmUnitDispatch, + validateNpmUnitDispatchRequest, +} from './npm-unit-dispatch'; +import { NpmUnitValidationError } from './npm-unit-contract'; // Health check endpoint (optional, for K8s liveness probes) import http from 'http'; @@ -49,6 +59,34 @@ import { connection } from './queue'; import { pyWorker, otherWorker } from './workers'; const HEALTH_PORT = Number(process.env.WORKER_HEALTH_PORT) || 3113; +const INTERNAL_BODY_LIMIT = 64 * 1024; +const activeDirectDispatches = new Set(); + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + if (res.writableEnded) return; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: http.IncomingMessage): Promise { + const declared = Number(req.headers['content-length']); + if (Number.isFinite(declared) && declared > INTERNAL_BODY_LIMIT) { + throw new NpmUnitValidationError('dispatch body is too large'); + } + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > INTERNAL_BODY_LIMIT) throw new NpmUnitValidationError('dispatch body is too large'); + chunks.push(buffer); + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new NpmUnitValidationError('dispatch body must be valid JSON'); + } +} function workerRouteLabel(url: string | undefined, method: string | undefined): string { if (url === '/health' && method === 'GET') { @@ -60,6 +98,9 @@ function workerRouteLabel(url: string | undefined, method: string | undefined): if (url === '/metrics' && method === 'GET') { return '/metrics'; } + if (url === '/internal/npm-unit' && method === 'POST') { + return '/internal/npm-unit'; + } return 'unmatched'; } @@ -93,7 +134,43 @@ const healthServer = http.createServer(async (req, res) => { } }); - if (pathname === '/health' && method === 'GET') { + if (pathname === '/internal/npm-unit' && method === 'POST') { + if (!internalServiceAuthEnabled()) { + sendJson(res, 503, { error: 'sandbox_unavailable', message: 'internal service auth is not configured', retryable: true }); + return; + } + if (!isAuthorizedInternalServiceRequest(req.headers)) { + sendJson(res, 401, { error: 'sandbox_unavailable', message: 'unauthorized', retryable: false }); + return; + } + if (!env.NPM_UNIT_ENABLED) { + sendJson(res, 503, { error: 'disabled', message: 'npm unit indexing is disabled', retryable: false }); + return; + } + const controller = new AbortController(); + activeDirectDispatches.add(controller); + const abort = () => controller.abort(); + req.once('aborted', abort); + res.once('close', abort); + try { + const input = validateNpmUnitDispatchRequest(await readJsonBody(req)); + const result = await processNpmUnitDispatch(input, controller.signal); + sendJson(res, 200, result); + } catch (error) { + if (!controller.signal.aborted) { + if (error instanceof NpmUnitValidationError) { + sendJson(res, 400, { error: 'invalid_request', message: error.message, retryable: false }); + } else { + logger.error('Direct npm unit dispatch failed', { error }); + sendJson(res, 500, { error: 'sandbox_unavailable', message: 'npm unit dispatcher failed', retryable: true }); + } + } + } finally { + activeDirectDispatches.delete(controller); + req.off('aborted', abort); + res.off('close', abort); + } + } else if (pathname === '/health' && method === 'GET') { try { // Check Redis connection await connection.ping(); @@ -108,11 +185,12 @@ const healthServer = http.createServer(async (req, res) => { status: 'healthy', workers: { python: pyRunning, - other: otherRunning + other: otherRunning, }, config: { pythonConcurrency: env.PYTHON_CONCURRENCY, otherConcurrency: env.OTHER_CONCURRENCY, + npmUnitDirectConcurrency: env.NPM_UNIT_CONCURRENCY, sandboxEndpoint: env.SANDBOX_ENDPOINT } })); @@ -166,28 +244,33 @@ startWorkerServer(async () => { }); }); +async function closeWorkerHttpServer(): Promise { + for (const controller of activeDirectDispatches) controller.abort(); + await new Promise(resolve => healthServer.close(() => resolve())); +} + // Graceful shutdown handlers process.on('SIGTERM', async () => { logger.info('SIGTERM received, initiating graceful shutdown...'); - healthServer.close(); + await closeWorkerHttpServer(); await gracefulShutdown(); }); process.on('SIGINT', async () => { logger.info('SIGINT received, initiating graceful shutdown...'); - healthServer.close(); + await closeWorkerHttpServer(); await gracefulShutdown(); }); process.on('SIGUSR2', async () => { logger.info('SIGUSR2 received, initiating graceful shutdown...'); - healthServer.close(); + await closeWorkerHttpServer(); await gracefulShutdown(); }); process.on('uncaughtException', async (error) => { logger.error('Uncaught Exception', error); - healthServer.close(); + await closeWorkerHttpServer(); await gracefulShutdown(); }); From d28babbdd37a75a85c9c9ea9f79c8aed577d11dd Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 7 Aug 2026 22:10:50 -0400 Subject: [PATCH 2/2] fix: enforce anonymous public npm indexing --- api/src/api/npm-unit.ts | 31 +++++++++-- api/src/config.ts | 1 + api/src/job.ts | 4 ++ api/src/nsjail.test.ts | 32 ++++++++++- api/src/nsjail.ts | 54 +++++++++++++++++- docker-compose.yaml | 3 - helm/codeapi/README.md | 12 +++- helm/codeapi/templates/api-deployment.yaml | 2 - .../templates/egress-gateway-deployment.yaml | 2 - .../templates/worker-sandbox-deployment.yaml | 2 - helm/codeapi/values.yaml | 3 +- service/openapi.yml | 24 ++++++-- service/src/config.ts | 1 - service/src/egress-gateway.test.ts | 33 ++++++++++- service/src/egress-gateway.ts | 55 +++++++++---------- service/src/npm-unit-contract.test.ts | 27 +++++---- service/src/npm-unit-contract.ts | 53 ++++++++---------- service/src/npm-unit-dispatch.test.ts | 17 ++---- service/src/npm-unit-dispatch.ts | 42 ++++---------- service/src/service/npm-unit-router.ts | 16 ++---- 20 files changed, 264 insertions(+), 150 deletions(-) diff --git a/api/src/api/npm-unit.ts b/api/src/api/npm-unit.ts index 90e457a..8ab146a 100644 --- a/api/src/api/npm-unit.ts +++ b/api/src/api/npm-unit.ts @@ -33,8 +33,21 @@ function failure( error: string, message: string, retryable: boolean, + usage?: Record, ): Response { - return res.status(status).json({ error, message, retryable }); + return res.status(status).json({ error, message, retryable, ...(usage ? { usage } : {}) }); +} + +function parentObservedUsage( + started: number, + tarballBytes: number, + cgroupPeakBytes: number | null | undefined, +): Record { + return { + tarballBytes, + ...(cgroupPeakBytes == null ? {} : { cgroupPeakBytes }), + wallMs: Math.round(performance.now() - started), + }; } function validateBody(raw: unknown): NpmUnitSandboxBody { @@ -122,7 +135,8 @@ async function gatewayError(response: globalThis.Response): Promise<{ error: str } function publicStatus(error: string, retryable: boolean): number { - if (error === 'not_found') return 404; + if (error === 'not_publicly_fetchable') return 404; + if (error === 'unsupported_registry' || error === 'invalid_request') return 400; if (error === 'too_large' || error === 'decompression_limit') return 413; if (error === 'integrity_mismatch' || error === 'unsafe_entry' || error === 'parse_failed') return 422; if (error === 'timeout') return 504; @@ -225,17 +239,21 @@ router.post('/npm-unit', express.json({ limit: '64kb' }), async (req: Request, r timeouts: { compile: 0, run: config.npm_unit_run_timeout }, cpu_times: { compile: 0, run: config.npm_unit_cpu_time }, memory_limits: { compile: config.npm_unit_memory_limit, run: config.npm_unit_memory_limit }, + report_memory_peak: true, }); await job.prime(); const result = await job.execute(); const run = result.run ?? result.compile; - if (run?.status === 'TO') return failure(res, 504, 'timeout', 'Package parsing exceeded the wall-clock limit', false); + const observedUsage = parentObservedUsage(started, tarball.length, run?.memory); + if (run?.status === 'TO') { + return failure(res, 504, 'timeout', 'Package parsing exceeded the wall-clock limit', false, observedUsage); + } if (run?.message === 'Out of memory' || run?.signal === 'SIGKILL') { - return failure(res, 413, 'too_large', 'Package parsing exceeded the sandbox resource limit', false); + return failure(res, 413, 'too_large', 'Package parsing exceeded the sandbox resource limit', false, observedUsage); } if (!run || run.code !== 0) { logger.warn({ executionId: body.execution_id, status: run?.status, code: run?.code }, 'npm unit worker failed'); - return failure(res, 422, 'parse_failed', 'Package surface could not be parsed', false); + return failure(res, 422, 'parse_failed', 'Package surface could not be parsed', false, observedUsage); } let response: Record; try { @@ -246,6 +264,9 @@ router.post('/npm-unit', express.json({ limit: '64kb' }), async (req: Request, r const usage = response.usage; if (usage && typeof usage === 'object') { (usage as Record).wallMs = Math.round(performance.now() - started); + if (run.memory != null) { + (usage as Record).cgroupPeakBytes = run.memory; + } } if (typeof response.error === 'string') { const retryable = response.retryable === true; diff --git a/api/src/config.ts b/api/src/config.ts index 9bf52d5..21383dd 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -52,6 +52,7 @@ export const config = { ?? '/pkgs', disable_networking: (process.env.SANDBOX_DISABLE_NETWORKING ?? 'true') === 'true', use_cgroupv2: (process.env.SANDBOX_USE_CGROUPV2 ?? 'true') === 'true', + cgroupv2_mount: cleanDirectory(process.env.SANDBOX_CGROUPV2_MOUNT) ?? '/sys/fs/cgroup', allowed_local_network_port: Number(process.env.SANDBOX_ALLOWED_LOCAL_NETWORK_PORT ?? 0), output_max_size: Number(process.env.SANDBOX_OUTPUT_MAX_SIZE ?? 1024), max_process_count: Number(process.env.SANDBOX_MAX_PROCESS_COUNT ?? 64), diff --git a/api/src/job.ts b/api/src/job.ts index 1193469..177e83a 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -692,6 +692,7 @@ export class Job { egressGrantToken?: string; toolCallSocketEnabled: boolean; isSynthetic: boolean; + reportMemoryPeak: boolean; outputSessionId: string; private log: Logger; @@ -732,6 +733,7 @@ export class Job { egress_grant?: string; tool_call_socket_enabled?: boolean; is_synthetic?: boolean; + report_memory_peak?: boolean; /* Injected when this VM is bound to a stateful runtime session. */ session?: SessionWorkspace | null; }) { @@ -769,6 +771,7 @@ export class Job { this.egressGrantToken = opts.egress_grant; this.toolCallSocketEnabled = opts.tool_call_socket_enabled === true; this.isSynthetic = opts.is_synthetic === true; + this.reportMemoryPeak = opts.report_memory_peak === true; } /** Marks a persistent workspace unusable after a post-prime failure. Returns @@ -1506,6 +1509,7 @@ export class Job { identity: this.sandboxIdentity(), enableToolCallSocket: this.toolCallSocketEnabled && script === 'run', suppressSuccessLogs: this.isSynthetic, + trackMemoryPeak: this.reportMemoryPeak, }); } diff --git a/api/src/nsjail.test.ts b/api/src/nsjail.test.ts index ee6fbbc..5808961 100644 --- a/api/src/nsjail.test.ts +++ b/api/src/nsjail.test.ts @@ -3,7 +3,7 @@ import * as fsp from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { config } from './config'; -import { buildArgs, execute, renderJobConfigOverlay } from './nsjail'; +import { buildArgs, execute, readCgroupPeakBytes, renderJobConfigOverlay } from './nsjail'; function valueAfter(args: string[], flag: string): string | undefined { const idx = args.indexOf(flag); @@ -33,6 +33,36 @@ function seccompPolicy(): string { } describe('NsJail args', () => { + test('scopes tracked jobs to a dedicated cgroup-v2 parent', () => { + const originalUseCgroup = config.use_cgroupv2; + config.use_cgroupv2 = true; + try { + const args = buildArgs({ + logPath: '/tmp/nsjail-test.log', + pkgdir: '/pkgs/node/24.15.0', + timeout: 1000, + memoryLimit: 1024, + envVars: {}, + command: ['/bin/bash', '/pkgs/node/24.15.0/run', 'worker.cjs'], + identity: { slot: 0, uid: 65534, gid: 65534, perJobUid: false }, + cgroupv2Mount: '/sys/fs/cgroup/CODEAPI.test', + }); + expect(valueAfter(args, '--cgroupv2_mount')).toBe('/sys/fs/cgroup/CODEAPI.test'); + } finally { + config.use_cgroupv2 = originalUseCgroup; + } + }); + + test('reads a kernel cgroup memory peak without rounding', async () => { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cgroup-peak-')); + try { + await fsp.writeFile(path.join(tmp, 'memory.peak'), '419430401\n'); + expect(readCgroupPeakBytes(tmp)).toBe(419430401); + } finally { + await fsp.rm(tmp, { recursive: true, force: true }); + } + }); + test('passes dynamic per-job UID/GID mappings', () => { const args = buildArgs({ logPath: '/tmp/nsjail-test.log', diff --git a/api/src/nsjail.ts b/api/src/nsjail.ts index 87a2b79..c0f1d05 100644 --- a/api/src/nsjail.ts +++ b/api/src/nsjail.ts @@ -21,6 +21,43 @@ export interface NsJailResult { wall_time: number | null; } +const TRACKED_CGROUP_CHILD_RE = /^NSJAIL(?:_SELF)?\.\d+$/; + +function trackedCgroupPath(logId: string): string | undefined { + if (!config.use_cgroupv2) return undefined; + const cgroupPath = path.join(config.cgroupv2_mount, `CODEAPI.${logId}`); + try { + fs.mkdirSync(cgroupPath, { mode: 0o700 }); + return cgroupPath; + } catch (error) { + logger.warn({ err: error, cgroupPath }, 'Could not create per-job memory telemetry cgroup'); + return undefined; + } +} + +export function readCgroupPeakBytes(cgroupPath: string): number | null { + try { + const value = Number(fs.readFileSync(path.join(cgroupPath, 'memory.peak'), 'utf8').trim()); + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +function cleanupTrackedCgroup(cgroupPath: string | undefined): void { + if (!cgroupPath) return; + try { + for (const entry of fs.readdirSync(cgroupPath, { withFileTypes: true })) { + if (entry.isDirectory() && TRACKED_CGROUP_CHILD_RE.test(entry.name)) { + fs.rmdirSync(path.join(cgroupPath, entry.name)); + } + } + fs.rmdirSync(cgroupPath); + } catch (error) { + logger.warn({ err: error, cgroupPath }, 'Could not remove per-job memory telemetry cgroup'); + } +} + const SIGNALS: Record = { 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 5: 'SIGTRAP', 6: 'SIGABRT', 7: 'SIGBUS', 8: 'SIGFPE', @@ -227,6 +264,7 @@ interface ExecuteOptions { identity: SandboxJobIdentity; enableToolCallSocket?: boolean; suppressSuccessLogs?: boolean; + trackMemoryPeak?: boolean; } export async function execute(opts: ExecuteOptions, setupGate: NsJailSetupGate = defaultNsJailSetupGate): Promise { @@ -243,11 +281,16 @@ export async function execute(opts: ExecuteOptions, setupGate: NsJailSetupGate = identity, enableToolCallSocket, suppressSuccessLogs, + trackMemoryPeak, } = opts; const logId = nanoid(); const logPath = `/tmp/nsjail-${logId}.log`; const cfgPath = `/tmp/nsjail-${logId}.cfg`; + const memoryCgroupPath = trackMemoryPeak === true + ? trackedCgroupPath(logId) + : undefined; + try { fs.writeFileSync(cfgPath, readBaseConfig() + renderJobConfigOverlay(submissionDir), { mode: 0o600 }); const nsjailArgs = buildArgs({ @@ -261,6 +304,7 @@ export async function execute(opts: ExecuteOptions, setupGate: NsJailSetupGate = extraPkgdirs, identity, enableToolCallSocket, + cgroupv2Mount: memoryCgroupPath, }); const startTime = Date.now(); @@ -615,12 +659,15 @@ export async function execute(opts: ExecuteOptions, setupGate: NsJailSetupGate = code, signal, output, - memory: null, + memory: memoryCgroupPath ? readCgroupPeakBytes(memoryCgroupPath) : null, message: finalMessage, status: finalStatus, cpu_time: null, wall_time: wallTime, }; + } finally { + cleanupTrackedCgroup(memoryCgroupPath); + } } interface BuildArgsOptions { @@ -638,6 +685,7 @@ interface BuildArgsOptions { extraPkgdirs?: string[]; identity: SandboxJobIdentity; enableToolCallSocket?: boolean; + cgroupv2Mount?: string; } export function buildArgs(opts: BuildArgsOptions): string[] { @@ -652,6 +700,7 @@ export function buildArgs(opts: BuildArgsOptions): string[] { extraPkgdirs, identity, enableToolCallSocket, + cgroupv2Mount, } = opts; const timeoutSecs = Math.max(1, Math.ceil(timeout / 1000)); @@ -670,6 +719,9 @@ export function buildArgs(opts: BuildArgsOptions): string[] { if (config.use_cgroupv2) { args.push('--use_cgroupv2'); + if (cgroupv2Mount) { + args.push('--cgroupv2_mount', cgroupv2Mount); + } } if (extraPkgdirs) { diff --git a/docker-compose.yaml b/docker-compose.yaml index f285bfd..a1979c2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,7 +33,6 @@ services: - EXECUTION_MANIFEST_MAX_REQUESTS=${EXECUTION_MANIFEST_MAX_REQUESTS:-} - CODEAPI_HTTP_JSON_LIMIT=${CODEAPI_HTTP_JSON_LIMIT:-50mb} - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} - - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} - CODEAPI_NPM_UNIT_DISPATCH_URL=http://service-worker:3113/internal/npm-unit - SERVICE_PORT=3112 - SANDBOX_ENDPOINT=http://sandbox-runner:2000/api/v2 @@ -69,7 +68,6 @@ services: - EGRESS_GATEWAY_URL=http://egress_gateway:3190 - CODEAPI_INTERNAL_SERVICE_TOKEN=${CODEAPI_INTERNAL_SERVICE_TOKEN:-localdev-internal-service-token} - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} - - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} - NPM_UNIT_CONCURRENCY=${NPM_UNIT_CONCURRENCY:-8} - NPM_UNIT_REQUEST_TIMEOUT=${NPM_UNIT_REQUEST_TIMEOUT:-45000} - REDIS_HOST=redis @@ -101,7 +99,6 @@ services: - EGRESS_GATEWAY_TOOL_CALL_SERVER_URL=http://tool_call_server:3033 - EGRESS_GATEWAY_MAX_TOOL_CALL_BYTES=1048576 - CODEAPI_NPM_UNIT_ENABLED=${CODEAPI_NPM_UNIT_ENABLED:-false} - - CODEAPI_NPM_REGISTRY_ORIGIN=${CODEAPI_NPM_REGISTRY_ORIGIN:-https://registry.npmjs.org} - CODEAPI_NPM_TARBALL_MAX_BYTES=${CODEAPI_NPM_TARBALL_MAX_BYTES:-33554432} - CODEAPI_NPM_FETCH_TIMEOUT_MS=${CODEAPI_NPM_FETCH_TIMEOUT_MS:-15000} - CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS=${CODEAPI_NPM_FETCH_TOKEN_TTL_SECONDS:-120} diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 30aa4e8..d999bee 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -49,7 +49,7 @@ use it outside local development. Set `npmUnit.enabled=true` to expose `POST /v1/sandbox/npm-unit`. The route accepts one exact `name@version`, one SHA-512 SRI digest, the canonical tarball -URL on `npmUnit.registryOrigin`, and the fixed keep set +URL on the anonymous public registry at `https://registry.npmjs.org`, and the fixed keep set `["**/*.d.ts", "package.json"]`. It returns deterministic declaration symbols, imports, rejection counters, and resource usage without running package code, install scripts, or `tsc`. @@ -58,7 +58,8 @@ The feature is off by default because enabling it gives only the egress-gateway public TCP/443 access. Kubernetes NetworkPolicy still excludes private, loopback, link-local, carrier-grade NAT, benchmark, multicast, and reserved IPv4 ranges. At the application layer the worker mints a short-lived encrypted -capability for the exact registry tarball, cross-origin redirects are refused, +capability for the exact registry tarball, all redirects are refused, no +registry credentials or caller headers are forwarded, the tarball is byte-capped in transit, and the parse stage runs in a fresh network-disabled NsJail with route-specific memory, CPU, wall-clock, decompression, entry, file, retained-byte, and response limits. @@ -68,6 +69,13 @@ creates a BullMQ job or persists an npm-unit result. Capacity is fail-fast: a busy dispatcher returns a retryable structured failure instead of retaining a background job after the caller disconnects. +`npm-unit` is an ecosystem-specific public-artifact profile, not a configurable +registry proxy. Additional ecosystems should use sibling profiles with closed +anonymous-fetch hosts, integrity rules, archive limits, retained-file policy, +and parsers of their own while reusing the stateless jail and resource +telemetry. Private registries and credential-bearing dependency installation +require a separate threat model and are intentionally outside this route. + The main tuning values live under `npmUnit`; defaults are 8 concurrent requests, a 32 MiB compressed tarball, 64 MiB decompressed archive, 384 MiB cgroup memory, and 15 seconds of parse time. Rebuild the sandbox/package image when enabling diff --git a/helm/codeapi/templates/api-deployment.yaml b/helm/codeapi/templates/api-deployment.yaml index 3396ee5..ff4a198 100644 --- a/helm/codeapi/templates/api-deployment.yaml +++ b/helm/codeapi/templates/api-deployment.yaml @@ -71,8 +71,6 @@ spec: value: {{ .Values.egressGrant.ttlSeconds | quote }} - name: CODEAPI_NPM_UNIT_ENABLED value: {{ .Values.npmUnit.enabled | quote }} - - name: CODEAPI_NPM_REGISTRY_ORIGIN - value: {{ .Values.npmUnit.registryOrigin | quote }} - name: CODEAPI_NPM_UNIT_DISPATCH_URL value: "http://{{ include "codeapi.fullname" . }}-service-worker:{{ .Values.workerSandbox.healthPort }}/internal/npm-unit" # Server config diff --git a/helm/codeapi/templates/egress-gateway-deployment.yaml b/helm/codeapi/templates/egress-gateway-deployment.yaml index c891a2f..256c390 100644 --- a/helm/codeapi/templates/egress-gateway-deployment.yaml +++ b/helm/codeapi/templates/egress-gateway-deployment.yaml @@ -52,8 +52,6 @@ spec: value: {{ printf "%.0f" (.Values.egressGateway.config.maxToolCallBytes | float64) | quote }} - name: CODEAPI_NPM_UNIT_ENABLED value: {{ .Values.npmUnit.enabled | quote }} - - name: CODEAPI_NPM_REGISTRY_ORIGIN - value: {{ .Values.npmUnit.registryOrigin | quote }} - name: CODEAPI_NPM_TARBALL_MAX_BYTES value: {{ .Values.npmUnit.tarballMaxBytes | quote }} - name: CODEAPI_NPM_FETCH_TIMEOUT_MS diff --git a/helm/codeapi/templates/worker-sandbox-deployment.yaml b/helm/codeapi/templates/worker-sandbox-deployment.yaml index 31c9aef..2549ec3 100644 --- a/helm/codeapi/templates/worker-sandbox-deployment.yaml +++ b/helm/codeapi/templates/worker-sandbox-deployment.yaml @@ -176,8 +176,6 @@ spec: value: {{ .Values.egressGrant.ttlSeconds | quote }} - name: CODEAPI_NPM_UNIT_ENABLED value: {{ .Values.npmUnit.enabled | quote }} - - name: CODEAPI_NPM_REGISTRY_ORIGIN - value: {{ .Values.npmUnit.registryOrigin | quote }} - name: NPM_UNIT_CONCURRENCY value: {{ .Values.npmUnit.concurrency | quote }} - name: NPM_UNIT_REQUEST_TIMEOUT diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index cd8bb9e..4325109 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -57,10 +57,9 @@ executionManifest: # Opt-in stateless indexing of an exact npm package tarball. The egress gateway # alone receives public HTTPS access, and it accepts only short-lived, -# body-bound capabilities for the configured registry URL. +# body-bound capabilities for the fixed anonymous public npm registry. npmUnit: enabled: false - registryOrigin: "https://registry.npmjs.org" tarballMaxBytes: 33554432 fetchTimeoutMs: 15000 fetchTokenTtlSeconds: 120 diff --git a/service/openapi.yml b/service/openapi.yml index d99c6af..6cb85fa 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -202,6 +202,20 @@ components: tarballBytes: { type: integer, minimum: 0 } unpackedBytes: { type: integer, minimum: 0 } peakRssBytes: { type: integer, minimum: 0 } + cgroupPeakBytes: + type: integer + minimum: 0 + description: Peak memory charged to the fresh per-request cgroup, including descendants. + wallMs: { type: integer, minimum: 0 } + + NpmUnitFailureUsage: + type: object + description: Parent-observed usage may be partial when the jailed process is terminated. + properties: + tarballBytes: { type: integer, minimum: 0 } + unpackedBytes: { type: integer, minimum: 0 } + peakRssBytes: { type: integer, minimum: 0 } + cgroupPeakBytes: { type: integer, minimum: 0 } wallMs: { type: integer, minimum: 0 } NpmUnitSuccess: @@ -267,24 +281,24 @@ components: properties: error: type: string - enum: [integrity_mismatch, not_found, registry_unavailable, too_large, decompression_limit, timeout, unsafe_entry, parse_failed, invalid_request, disabled, sandbox_unavailable] + enum: [integrity_mismatch, not_publicly_fetchable, registry_unavailable, too_large, decompression_limit, timeout, unsafe_entry, parse_failed, invalid_request, unsupported_registry, disabled, sandbox_unavailable] message: { type: string } retryable: { type: boolean } rejected: $ref: '#/components/schemas/NpmUnitRejected' usage: - $ref: '#/components/schemas/NpmUnitUsage' + $ref: '#/components/schemas/NpmUnitFailureUsage' paths: /sandbox/npm-unit: post: summary: Index an exact npm package declaration surface description: >- - Fetches one registry tarball through a short-lived exact-package + Fetches one anonymously readable tarball from registry.npmjs.org through a short-lived exact-package capability, verifies SHA-512 before decompression, and parses only TypeScript declarations in a fresh network-disabled sandbox. The - request is dispatched synchronously without a persisted job or result; - the feature is disabled by default. + request carries no tenant context and is dispatched synchronously + without a persisted job or result; the feature is disabled by default. operationId: indexNpmUnit requestBody: required: true diff --git a/service/src/config.ts b/service/src/config.ts index e73dc19..e44a8aa 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -284,7 +284,6 @@ export const env = { EGRESS_GATEWAY_REQUEST_TIMEOUT_MS: Number(process.env.EGRESS_GATEWAY_REQUEST_TIMEOUT_MS) || 30_000, EGRESS_GATEWAY_REVOKE_TIMEOUT_MS: Number(process.env.EGRESS_GATEWAY_REVOKE_TIMEOUT_MS) || 5_000, NPM_UNIT_ENABLED: process.env.CODEAPI_NPM_UNIT_ENABLED === 'true', - NPM_REGISTRY_ORIGIN: process.env.CODEAPI_NPM_REGISTRY_ORIGIN ?? 'https://registry.npmjs.org', NPM_TARBALL_MAX_BYTES: positiveWholeNumber(process.env.CODEAPI_NPM_TARBALL_MAX_BYTES, 32 * 1024 * 1024), NPM_FETCH_TIMEOUT_MS: positiveWholeNumber(process.env.CODEAPI_NPM_FETCH_TIMEOUT_MS, 15_000), NPM_FETCH_TOKEN_TTL_SECONDS: Math.min( diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 7bf9793..e599451 100644 --- a/service/src/egress-gateway.test.ts +++ b/service/src/egress-gateway.test.ts @@ -199,7 +199,6 @@ beforeEach(() => { env.EGRESS_GATEWAY_MAX_NESTING_DEPTH = 10; env.EGRESS_LEDGER_REQUIRED = false; env.NPM_UNIT_ENABLED = true; - env.NPM_REGISTRY_ORIGIN = 'https://registry.npmjs.org'; env.NPM_TARBALL_MAX_BYTES = 1024; env.NPM_FETCH_TIMEOUT_MS = 1000; env.NPM_FETCH_TOKEN_TTL_SECONDS = 60; @@ -251,6 +250,7 @@ describe('egress gateway routes', () => { expect(Buffer.from(await fetched.arrayBuffer())).toEqual(tarball); expect(upstreamCalls.map(call => call.url)).toEqual([request.resolved]); expect(upstreamCalls[0].init.redirect).toBe('manual'); + expect(upstreamCalls[0].init.headers).toEqual({ Accept: 'application/octet-stream' }); }); test('refuses off-registry npm URLs before minting or fetching', async () => { @@ -274,10 +274,11 @@ describe('egress gateway routes', () => { }); expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'unsupported_registry', retryable: false }); expect(upstreamCalls).toHaveLength(0); }); - test('refuses cross-origin npm redirects and oversized tarballs', async () => { + test('refuses every npm redirect and oversized tarballs', async () => { const tarball = Buffer.from('x'); const integrity = `sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}`; const request = { @@ -319,6 +320,34 @@ describe('egress gateway routes', () => { expect(await oversized.json()).toMatchObject({ error: 'too_large', retryable: false }); }); + test.each([401, 403, 404])('reports anonymous registry HTTP %d without claiming whether a package exists', async status => { + const tarball = Buffer.from('x'); + const request = { + name: 'pkg', + version: '1.2.3', + integrity: `sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}`, + resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', + keep: [...NPM_UNIT_KEEP], + }; + const mint = await gatewayFetch('/internal/npm-tarball-tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_SERVICE_TOKEN_HEADER]: INTERNAL_TOKEN, + }, + body: JSON.stringify({ executionId: 'exec_npm', request }), + }); + const { fetchToken } = await mint.json() as { fetchToken: string }; + + upstreamResponse = new Response(null, { status }); + const fetched = await gatewayFetch('/npm/tarball', { + headers: { [NPM_FETCH_TOKEN_HEADER]: fetchToken }, + }); + + expect(fetched.status).toBe(404); + expect(await fetched.json()).toMatchObject({ error: 'not_publicly_fetchable', retryable: false }); + }); + test('protects internal grant create, restore, and revoke routes', async () => { const createBody = JSON.stringify({ payload: payload(), claims: executionClaims() }); const unauthorized = await gatewayFetch('/internal/egress-grants', { diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index 196b2b4..f628cb8 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -570,7 +570,7 @@ app.post('/internal/npm-tarball-tokens', express.json({ limit: '32kb' }), requir if (!executionId || executionId.length > 256) { return res.status(400).json({ error: 'invalid_request', message: 'executionId is required', retryable: false }); } - const request = validateNpmUnitRequest(req.body?.request, env.NPM_REGISTRY_ORIGIN); + const request = validateNpmUnitRequest(req.body?.request); const issuedAt = Math.floor(Date.now() / 1000); const expiresAt = issuedAt + env.NPM_FETCH_TOKEN_TTL_SECONDS; const fetchToken = sealNpmTarballToken({ @@ -587,7 +587,7 @@ app.post('/internal/npm-tarball-tokens', express.json({ limit: '32kb' }), requir return res.status(201).json({ fetchToken, expiresAt }); } catch (error) { if (error instanceof NpmUnitValidationError) { - return res.status(400).json({ error: 'invalid_request', message: error.message, retryable: false }); + return res.status(400).json({ error: error.code, message: error.message, retryable: false }); } return sendEgressError(req, res, error); } @@ -611,31 +611,23 @@ class NpmTarballLimitTransform extends Transform { } async function fetchRegistryTarball( - initialUrl: string, + url: string, signal: AbortSignal, ): Promise { - let current = new URL(initialUrl); - const allowedOrigin = current.origin; - for (let redirects = 0; redirects <= 3; redirects++) { - const upstream = await fetch(current, { - method: 'GET', - headers: { Accept: 'application/octet-stream' }, - redirect: 'manual', - signal, - }); - if (upstream.status < 300 || upstream.status >= 400) return upstream; - const location = upstream.headers.get('location'); + const upstream = await fetch(url, { + method: 'GET', + /* Deliberately omit Authorization, Cookie, npm tokens, and caller + * headers. Anonymous readability from the one fixed public registry is + * the proof that a tarball is eligible for this globally reusable parse. */ + headers: { Accept: 'application/octet-stream' }, + redirect: 'manual', + signal, + }); + if (upstream.status >= 300 && upstream.status < 400) { await upstream.body?.cancel().catch(() => {}); - if (!location || redirects === 3) { - throw new Error('npm_registry_redirect_limit'); - } - const next = new URL(location, current); - if (next.protocol !== 'https:' || next.origin !== allowedOrigin || next.username || next.password) { - throw new Error('npm_registry_cross_origin_redirect'); - } - current = next; + throw new Error('npm_registry_redirect_refused'); } - throw new Error('npm_registry_redirect_limit'); + return upstream; } app.get('/npm/tarball', async (req, res) => { @@ -655,16 +647,20 @@ app.get('/npm/tarball', async (req, res) => { integrity: capability.integrity, resolved: capability.resolved, keep: ['**/*.d.ts', 'package.json'], - }, env.NPM_REGISTRY_ORIGIN); + }); const maxBytes = Math.min(capability.max_bytes, env.NPM_TARBALL_MAX_BYTES); const controller = new AbortController(); timeout = setTimeout(() => controller.abort(), env.NPM_FETCH_TIMEOUT_MS); req.once('aborted', () => controller.abort()); const upstream = await fetchRegistryTarball(request.resolved, controller.signal); - if (upstream.status === 404) { + if (upstream.status === 401 || upstream.status === 403 || upstream.status === 404) { await upstream.body?.cancel().catch(() => {}); - return res.status(404).json({ error: 'not_found', message: 'Package tarball was not found', retryable: false }); + return res.status(404).json({ + error: 'not_publicly_fetchable', + message: 'Package tarball is not anonymously fetchable from the public npm registry', + retryable: false, + }); } if (!upstream.ok) { await upstream.body?.cancel().catch(() => {}); @@ -704,6 +700,9 @@ app.get('/npm/tarball', async (req, res) => { ); return; } catch (error) { + if (!res.headersSent && error instanceof NpmUnitValidationError) { + return res.status(400).json({ error: error.code, message: error.message, retryable: false }); + } if ((error as Error)?.name === 'AbortError') { if (!res.headersSent) { return res.status(503).json({ error: 'registry_unavailable', message: 'Registry request timed out', retryable: true }); @@ -713,11 +712,11 @@ app.get('/npm/tarball', async (req, res) => { } if (!res.headersSent && !(error instanceof EgressGrantError)) { const message = (error as Error)?.message ?? ''; - const policyRefusal = message === 'npm_registry_cross_origin_redirect'; + const policyRefusal = message === 'npm_registry_redirect_refused'; return res.status(policyRefusal ? 502 : 503).json({ error: 'registry_unavailable', message: policyRefusal - ? 'Registry redirect left the configured origin' + ? 'Public npm registry redirects are not permitted' : 'Registry request failed', retryable: !policyRefusal, }); diff --git a/service/src/npm-unit-contract.test.ts b/service/src/npm-unit-contract.test.ts index c6f2338..d0c3ef6 100644 --- a/service/src/npm-unit-contract.test.ts +++ b/service/src/npm-unit-contract.test.ts @@ -7,7 +7,6 @@ import { validateNpmUnitRequest, } from './npm-unit-contract'; -const REGISTRY = 'https://registry.npmjs.org'; const INTEGRITY = `sha512-${createHash('sha512').update('tarball').digest('base64')}`; function valid(overrides: Record = {}): Record { @@ -23,11 +22,11 @@ function valid(overrides: Record = {}): Record describe('npm unit request contract', () => { test('normalizes one exact scoped registry tarball request', () => { - expect(validateNpmUnitRequest(valid(), REGISTRY)).toEqual({ + expect(validateNpmUnitRequest(valid())).toEqual({ name: '@tanstack/react-query', version: '4.36.1', integrity: INTEGRITY, - resolved: canonicalNpmTarballUrl('@tanstack/react-query', '4.36.1', REGISTRY), + resolved: canonicalNpmTarballUrl('@tanstack/react-query', '4.36.1'), keep: [...NPM_UNIT_KEEP], }); }); @@ -40,22 +39,28 @@ describe('npm unit request contract', () => { 'UpperCase', '.hidden', ])('rejects unsafe or non-canonical package name %s', name => { - expect(() => validateNpmUnitRequest(valid({ name }), REGISTRY)).toThrow(NpmUnitValidationError); + expect(() => validateNpmUnitRequest(valid({ name }))).toThrow(NpmUnitValidationError); }); test('rejects an off-registry URL and a cross-package registry URL', () => { - expect(() => validateNpmUnitRequest(valid({ + let offRegistry: NpmUnitValidationError | undefined; + try { + validateNpmUnitRequest(valid({ resolved: 'https://evil.example/react-query-4.36.1.tgz', - }), REGISTRY)).toThrow('configured registry'); + })); + } catch (error) { + offRegistry = error as NpmUnitValidationError; + } + expect(offRegistry?.code).toBe('unsupported_registry'); expect(() => validateNpmUnitRequest(valid({ resolved: 'https://registry.npmjs.org/zod/-/zod-4.36.1.tgz', - }), REGISTRY)).toThrow('exactly match'); + }))).toThrow('exactly match'); }); test('rejects flexible versions, non-sha512 integrity, mutable keep globs, and unknown fields', () => { - expect(() => validateNpmUnitRequest(valid({ version: '^4.36.1' }), REGISTRY)).toThrow('exact semantic'); - expect(() => validateNpmUnitRequest(valid({ integrity: 'sha1-deadbeef' }), REGISTRY)).toThrow('sha512'); - expect(() => validateNpmUnitRequest(valid({ keep: ['**/*'] }), REGISTRY)).toThrow('keep must be exactly'); - expect(() => validateNpmUnitRequest(valid({ extra: true }), REGISTRY)).toThrow('Unknown request fields'); + expect(() => validateNpmUnitRequest(valid({ version: '^4.36.1' }))).toThrow('exact semantic'); + expect(() => validateNpmUnitRequest(valid({ integrity: 'sha1-deadbeef' }))).toThrow('sha512'); + expect(() => validateNpmUnitRequest(valid({ keep: ['**/*'] }))).toThrow('keep must be exactly'); + expect(() => validateNpmUnitRequest(valid({ extra: true }))).toThrow('Unknown request fields'); }); }); diff --git a/service/src/npm-unit-contract.ts b/service/src/npm-unit-contract.ts index 501f06e..a695644 100644 --- a/service/src/npm-unit-contract.ts +++ b/service/src/npm-unit-contract.ts @@ -1,5 +1,6 @@ export const NPM_UNIT_KEEP = ['**/*.d.ts', 'package.json'] as const; export const NPM_FETCH_TOKEN_HEADER = 'X-CodeAPI-Npm-Fetch-Token'; +export const PUBLIC_NPM_REGISTRY_ORIGIN = 'https://registry.npmjs.org'; const NPM_NAME_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/; const EXACT_SEMVER_RE = @@ -16,7 +17,7 @@ export interface NpmUnitRequest { export type NpmUnitFailureCode = | 'integrity_mismatch' - | 'not_found' + | 'not_publicly_fetchable' | 'registry_unavailable' | 'too_large' | 'decompression_limit' @@ -25,11 +26,11 @@ export type NpmUnitFailureCode = | 'parse_failed'; export interface NpmUnitFailure { - error: NpmUnitFailureCode | 'invalid_request' | 'disabled' | 'sandbox_unavailable'; + error: NpmUnitFailureCode | 'invalid_request' | 'unsupported_registry' | 'disabled' | 'sandbox_unavailable'; message: string; retryable: boolean; rejected?: NpmUnitRejected; - usage?: NpmUnitUsage; + usage?: Partial; } export interface NpmUnitRejected { @@ -44,6 +45,8 @@ export interface NpmUnitUsage { tarballBytes: number; unpackedBytes: number; peakRssBytes: number; + /** Peak bytes charged to the per-request cgroup, including descendants. */ + cgroupPeakBytes?: number; wallMs: number; } @@ -86,7 +89,10 @@ export interface NpmUnitSuccess { export type NpmUnitResponse = NpmUnitSuccess | NpmUnitFailure; export class NpmUnitValidationError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly code: 'invalid_request' | 'unsupported_registry' = 'invalid_request', + ) { super(message); this.name = 'NpmUnitValidationError'; } @@ -125,27 +131,12 @@ function assertIntegrity(integrity: unknown): asserts integrity is string { } } -function normalizedRegistryOrigin(raw: string): URL { - let registry: URL; - try { - registry = new URL(raw); - } catch { - throw new NpmUnitValidationError('The configured npm registry origin is invalid'); - } - if (registry.protocol !== 'https:' || registry.username || registry.password || registry.search || registry.hash) { - throw new NpmUnitValidationError('The configured npm registry must be an HTTPS origin'); - } - registry.pathname = registry.pathname.replace(/\/+$/, ''); - return registry; -} - -export function canonicalNpmTarballUrl(name: string, version: string, registryOrigin: string): string { +export function canonicalNpmTarballUrl(name: string, version: string): string { assertNpmName(name); assertExactVersion(version); - const registry = normalizedRegistryOrigin(registryOrigin); + const registry = new URL(PUBLIC_NPM_REGISTRY_ORIGIN); const baseName = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name; - const registryPrefix = registry.pathname === '/' ? '' : registry.pathname; - registry.pathname = `${registryPrefix}/${name}/-/${baseName}-${version}.tgz`; + registry.pathname = `/${name}/-/${baseName}-${version}.tgz`; return registry.toString(); } @@ -153,7 +144,6 @@ function assertResolvedUrl( resolved: unknown, name: string, version: string, - registryOrigin: string, ): asserts resolved is string { if (typeof resolved !== 'string' || resolved.length > 2048) { throw new NpmUnitValidationError('resolved must be the package tarball URL'); @@ -162,10 +152,16 @@ function assertResolvedUrl( let expected: URL; try { actual = new URL(resolved); - expected = new URL(canonicalNpmTarballUrl(name, version, registryOrigin)); + expected = new URL(canonicalNpmTarballUrl(name, version)); } catch { throw new NpmUnitValidationError('resolved must be a valid registry tarball URL'); } + if (actual.origin !== PUBLIC_NPM_REGISTRY_ORIGIN) { + throw new NpmUnitValidationError( + `resolved must use the anonymous public npm registry at ${PUBLIC_NPM_REGISTRY_ORIGIN}`, + 'unsupported_registry', + ); + } let actualPath: string; let expectedPath: string; try { @@ -176,14 +172,13 @@ function assertResolvedUrl( } if ( actual.protocol !== 'https:' || - actual.origin !== expected.origin || actual.username || actual.password || actual.search || actual.hash || actualPath !== expectedPath ) { - throw new NpmUnitValidationError('resolved must exactly match name@version on the configured registry'); + throw new NpmUnitValidationError('resolved must exactly match name@version on the public npm registry'); } } @@ -197,7 +192,7 @@ function assertKeep(keep: unknown): asserts keep is NpmUnitRequest['keep'] { } } -export function validateNpmUnitRequest(raw: unknown, registryOrigin: string): NpmUnitRequest { +export function validateNpmUnitRequest(raw: unknown): NpmUnitRequest { if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { throw new NpmUnitValidationError('Request body must be an object'); } @@ -210,13 +205,13 @@ export function validateNpmUnitRequest(raw: unknown, registryOrigin: string): Np assertNpmName(body.name); assertExactVersion(body.version); assertIntegrity(body.integrity); - assertResolvedUrl(body.resolved, body.name, body.version, registryOrigin); + assertResolvedUrl(body.resolved, body.name, body.version); assertKeep(body.keep); return { name: body.name, version: body.version, integrity: body.integrity, - resolved: canonicalNpmTarballUrl(body.name, body.version, registryOrigin), + resolved: canonicalNpmTarballUrl(body.name, body.version), keep: [...NPM_UNIT_KEEP], }; } diff --git a/service/src/npm-unit-dispatch.test.ts b/service/src/npm-unit-dispatch.test.ts index 56a4fce..5293083 100644 --- a/service/src/npm-unit-dispatch.test.ts +++ b/service/src/npm-unit-dispatch.test.ts @@ -14,32 +14,23 @@ const request: NpmUnitRequest = { }; describe('direct npm unit dispatch contract', () => { - test('builds opaque fixed-width identity labels and validates the request', () => { + test('dispatches only an execution id and public package input', () => { const dispatch = buildNpmUnitDispatchRequest({ executionId: 'abcdefghij_1234567890', - tenantId: 'tenant-secret', - canonicalUserId: 'user-secret', - principalSource: 'librechat_jwt', request, }); - expect(dispatch.tenantLabel).toMatch(/^tenant:[A-Za-z0-9_-]{32}$/); - expect(dispatch.userLabel).toMatch(/^user:[A-Za-z0-9_-]{32}$/); - expect(JSON.stringify(dispatch)).not.toContain('tenant-secret'); - expect(JSON.stringify(dispatch)).not.toContain('user-secret'); + expect(Object.keys(dispatch).sort()).toEqual(['executionId', 'request']); expect(validateNpmUnitDispatchRequest(dispatch)).toEqual(dispatch); }); - test('rejects extra fields and forged identity labels', () => { + test('rejects extra fields, including identity context', () => { const dispatch = buildNpmUnitDispatchRequest({ executionId: 'abcdefghij_1234567890', - tenantId: 'tenant-secret', - canonicalUserId: 'user-secret', - principalSource: 'librechat_jwt', request, }); expect(() => validateNpmUnitDispatchRequest({ ...dispatch, queued: true })).toThrow('unknown dispatch field'); - expect(() => validateNpmUnitDispatchRequest({ ...dispatch, tenantLabel: 'tenant:raw-value' })).toThrow('tenantLabel'); + expect(() => validateNpmUnitDispatchRequest({ ...dispatch, tenantLabel: 'tenant:raw-value' })).toThrow('unknown dispatch field'); }); }); diff --git a/service/src/npm-unit-dispatch.ts b/service/src/npm-unit-dispatch.ts index 4a1e2bb..62e908e 100644 --- a/service/src/npm-unit-dispatch.ts +++ b/service/src/npm-unit-dispatch.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import crypto from 'crypto'; import { env } from './config'; import { createGatewayNpmTarballToken } from './egress-gateway-client'; import { @@ -9,7 +8,6 @@ import { type ExecutionManifestClaims, } from './execution-manifest'; import { internalServiceHeaders } from './internal-service-auth'; -import { isSyntheticPrincipalSource } from './auth/synthetic'; import { NpmUnitValidationError, validateNpmUnitRequest, @@ -23,32 +21,17 @@ import logger from './logger'; export interface NpmUnitDispatchRequest { executionId: string; - tenantLabel: string; - userLabel: string; - principalSource: string; request: NpmUnitRequest; } const EXECUTION_ID_RE = /^[A-Za-z0-9_-]{10,64}$/; -const TENANT_LABEL_RE = /^tenant:[A-Za-z0-9_-]{32}$/; -const USER_LABEL_RE = /^user:[A-Za-z0-9_-]{32}$/; - -function opaqueLabel(prefix: string, value: string): string { - return `${prefix}:${crypto.createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 32)}`; -} export function buildNpmUnitDispatchRequest(args: { executionId: string; - tenantId: string; - canonicalUserId: string; - principalSource: string; request: NpmUnitRequest; }): NpmUnitDispatchRequest { return { executionId: args.executionId, - tenantLabel: opaqueLabel('tenant', args.tenantId), - userLabel: opaqueLabel('user', args.canonicalUserId), - principalSource: args.principalSource, request: args.request, }; } @@ -65,15 +48,12 @@ export function validateNpmUnitDispatchRequest(value: unknown): NpmUnitDispatchR throw new NpmUnitValidationError('dispatch body must be an object'); } const body = value as Record; - const allowed = new Set(['executionId', 'tenantLabel', 'userLabel', 'principalSource', 'request']); + const allowed = new Set(['executionId', 'request']); const unknown = Object.keys(body).filter(key => !allowed.has(key)); if (unknown.length > 0) throw new NpmUnitValidationError(`unknown dispatch field: ${unknown[0]}`); return { executionId: requiredString(body.executionId, 'executionId', EXECUTION_ID_RE), - tenantLabel: requiredString(body.tenantLabel, 'tenantLabel', TENANT_LABEL_RE), - userLabel: requiredString(body.userLabel, 'userLabel', USER_LABEL_RE), - principalSource: requiredString(body.principalSource, 'principalSource'), - request: validateNpmUnitRequest(body.request, env.NPM_REGISTRY_ORIGIN), + request: validateNpmUnitRequest(body.request), }; } @@ -115,7 +95,7 @@ export async function processNpmUnitDispatch( input = validateNpmUnitDispatchRequest(raw); } catch (error) { return { - error: 'invalid_request', + error: error instanceof NpmUnitValidationError ? error.code : 'invalid_request', message: error instanceof Error ? error.message : 'invalid dispatch request', retryable: false, }; @@ -154,11 +134,9 @@ export async function processNpmUnitDispatch( 'codeapi.language': 'npm-unit', 'codeapi.dispatch_mode': 'direct', }, async () => { - const isSynthetic = isSyntheticPrincipalSource(input.principalSource); const { fetchToken } = await createGatewayNpmTarballToken({ executionId: input.executionId, request: input.request, - isSynthetic, signal: controller.signal, }); const body: Record = { @@ -172,19 +150,23 @@ export async function processNpmUnitDispatch( v: EXECUTION_MANIFEST_VERSION, operation: 'npm-unit', exec_id: input.executionId, - tenant_id: input.tenantLabel, - user_id: input.userLabel, - session_key: opaqueLabel('session', input.executionId), + /* npm-unit is a public, stateless pure-function route. These + * execution-scoped labels satisfy the common manifest schema + * without carrying tenant- or user-correlated data into the + * sandbox control plane. */ + tenant_id: `npm-unit:${input.executionId}`, + user_id: `npm-unit:${input.executionId}`, + session_key: `npm-unit:${input.executionId}`, input_files: [], read_sessions: [], - output_session_id: opaqueLabel('output', input.executionId), + output_session_id: `npm-unit:${input.executionId}`, max_upload_bytes: 0, max_output_files: 0, max_requests: 1, iat: now, exp: now + env.EXECUTION_MANIFEST_TTL_SECONDS, execute_body_sha256: executionManifestBodySha256(body), - principal_source: input.principalSource, + principal_source: 'npm-unit', }; body.execution_manifest = signExecutionManifestWithKey(claims, { privateKey: env.EXECUTION_MANIFEST_PRIVATE_KEY, diff --git a/service/src/service/npm-unit-router.ts b/service/src/service/npm-unit-router.ts index 26b27fb..b5497d8 100644 --- a/service/src/service/npm-unit-router.ts +++ b/service/src/service/npm-unit-router.ts @@ -3,9 +3,7 @@ import { Router } from 'express'; import type * as t from '../types'; import { checkServiceShutDown, checkServiceStartUp } from '../lifecycle'; import { env } from '../config'; -import { executionLimiter } from '../middleware/limits'; import { getPrincipalOrReject } from '../auth/principal'; -import { getExecutionIdentity } from '../execution-identity'; import { NpmUnitValidationError, validateNpmUnitRequest, @@ -23,16 +21,16 @@ const router = Router(); function responseStatus(body: NpmUnitResponse): number { if (!('error' in body)) return 200; - if (body.error === 'not_found') return 404; + if (body.error === 'not_publicly_fetchable') return 404; if (body.error === 'too_large' || body.error === 'decompression_limit') return 413; if (body.error === 'integrity_mismatch' || body.error === 'unsafe_entry' || body.error === 'parse_failed') return 422; if (body.error === 'timeout') return 504; - if (body.error === 'invalid_request') return 400; + if (body.error === 'invalid_request' || body.error === 'unsupported_registry') return 400; if (body.error === 'disabled') return 503; return body.retryable ? 503 : 502; } -router.post('/sandbox/npm-unit', executionLimiter, async (req: t.AuthenticatedRequest, res) => { +router.post('/sandbox/npm-unit', async (req: t.AuthenticatedRequest, res) => { const principal = getPrincipalOrReject(req, res); if (!principal) return; if (!env.NPM_UNIT_ENABLED) { @@ -60,15 +58,14 @@ router.post('/sandbox/npm-unit', executionLimiter, async (req: t.AuthenticatedRe let request: NpmUnitRequest; try { - request = validateNpmUnitRequest(req.body, env.NPM_REGISTRY_ORIGIN); + request = validateNpmUnitRequest(req.body); } catch (error) { if (error instanceof NpmUnitValidationError) { - return res.status(400).json({ error: 'invalid_request', message: error.message, retryable: false }); + return res.status(400).json({ error: error.code, message: error.message, retryable: false }); } throw error; } - const identity = getExecutionIdentity(req, principal.userId); const executionId = nanoid(); const controller = new AbortController(); const abort = () => controller.abort(); @@ -78,9 +75,6 @@ router.post('/sandbox/npm-unit', executionLimiter, async (req: t.AuthenticatedRe const result = await dispatchNpmUnitOverHttp(buildNpmUnitDispatchRequest({ request, executionId, - tenantId: identity.storageNamespace, - canonicalUserId: identity.canonicalUserId, - principalSource: identity.principalSource, }), controller.signal); if (res.writableEnded || controller.signal.aborted) return; return res.status(responseStatus(result)).json(result);