From 0b66a3a722bdafbcb48b8a32f91bb2ae0997a685 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 21:24:06 +0200 Subject: [PATCH 1/8] fix(api): align the public CodeAPI contract --- api/openapi.yaml | 190 ++++-- service/openapi.yml | 638 ++++++++++++++++----- service/src/openapi-contract.test.ts | 234 ++++++++ service/src/service/programmatic-router.ts | 6 +- service/src/service/replay-state.ts | 12 +- service/src/service/router.ts | 8 +- service/src/types/service.ts | 6 +- service/src/utils.test.ts | 19 +- service/src/utils.ts | 10 + service/src/workers.ts | 8 +- 10 files changed, 924 insertions(+), 207 deletions(-) create mode 100644 service/src/openapi-contract.test.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index e4c04c14..7c64b230 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,12 +1,17 @@ -openapi: 3.0.0 +openapi: 3.0.3 info: - title: Code Execution API - version: 1.0.0 + title: CodeAPI Internal Sandbox Runner API + version: 2.0.0 + description: >- + Internal service-to-sandbox contract. This API is not a public client + surface. The public authenticated contract is service/openapi.yml. +x-internal: true paths: - /execute: + /api/v2/execute: post: - summary: Execute code + summary: Execute a prepared sandbox job + operationId: executeSandboxJob requestBody: required: true content: @@ -15,80 +20,195 @@ paths: $ref: '#/components/schemas/ExecuteRequest' responses: '200': - description: Successful execution + description: Sandbox execution result content: application/json: schema: $ref: '#/components/schemas/ExecuteResponse' '400': - description: Bad request + description: Invalid execution request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Runtime session workspace conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: Request body exceeds the configured limit + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': - description: Internal server error + description: Sandbox execution failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: schemas: + Error: + type: object + required: [message] + properties: + message: + type: string + code: + type: string + ExecuteRequest: type: object - required: - - language - - version - - files + required: [language, version, files] properties: session_id: type: string + description: Top-level execution session identifier. + output_session_id: + type: string + description: Storage session for generated files. language: type: string version: type: string - files: + args: type: array items: - $ref: '#/components/schemas/File' + type: string stdin: type: string - args: + files: type: array items: - type: string - compileTimeout: + $ref: '#/components/schemas/InputFile' + compile_memory_limit: + type: integer + run_memory_limit: type: integer - runTimeout: + run_timeout: type: integer - compileMemoryLimit: + compile_timeout: type: integer - runMemoryLimit: + run_cpu_time: type: integer - - File: + compile_cpu_time: + type: integer + env_vars: + type: object + additionalProperties: + type: string + egress_grant: + type: string + description: Opaque internal egress capability. + execution_manifest: + type: string + description: Signed internal execution scope. + tool_call_socket: + type: boolean + + InputFile: type: object - required: - - name - - content + oneOf: + - required: [content] + - required: [id, storage_session_id] properties: - name: - type: string id: type: string + description: Storage object identifier for a by-reference input. + storage_session_id: + type: string + description: Storage session for a by-reference input. + input_cache_key: + type: string + description: Stable opaque runner-local cache identity. + name: + type: string + description: Optional destination path; the runner supplies a default when omitted. content: type: string + description: Inline file content. encoding: type: string enum: [base64, hex, utf8] - - ExecuteResponse: + entity_id: + type: string + description: Caller authorization scope echoed on inherited outputs. + + FileRef: type: object + required: [id, name, storage_session_id] properties: - compile: - $ref: '#/components/schemas/ExecutionStage' - run: - $ref: '#/components/schemas/ExecutionStage' - + id: + type: string + name: + type: string + storage_session_id: + type: string + modified_from: + type: object + required: [id, storage_session_id] + properties: + id: + type: string + storage_session_id: + type: string + inherited: + type: boolean + enum: [true] + entity_id: + type: string + ExecutionStage: type: object + required: [stdout, stderr, output] properties: stdout: type: string stderr: type: string - exitCode: + code: type: integer + nullable: true + signal: + type: string + nullable: true + output: + type: string + memory: + type: integer + nullable: true + message: + type: string + nullable: true + status: + type: string + nullable: true + cpu_time: + type: number + nullable: true + wall_time: + type: number + nullable: true + + ExecuteResponse: + type: object + required: [language, version, session_id, files] + properties: + compile: + $ref: '#/components/schemas/ExecutionStage' + run: + $ref: '#/components/schemas/ExecutionStage' + language: + type: string + version: + type: string + session_id: + type: string + files: + type: array + items: + $ref: '#/components/schemas/FileRef' diff --git a/service/openapi.yml b/service/openapi.yml index 913e7809..60f2a18b 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -1,14 +1,14 @@ -openapi: '3.0.0' +openapi: 3.0.3 info: - title: LibreChat Code Interpreter API - version: '1.0.0' + title: CodeAPI Public Service API + version: 1.0.0 description: >- - API for sandbox code execution and file management. Trusted callers should - assert the intended deployment with X-CodeAPI-Expected-Profile on every - request; responses advertise the actual profile. + Public authenticated API for sandboxed code execution and file management. + This contract describes the service gateway mounted at /v1. It does not + describe the internal sandbox-runner API. servers: - url: https://api.librechat.ai/v1 - description: LibreChat API server + description: Public CodeAPI service security: - BearerAuth: [] @@ -27,10 +27,22 @@ components: required: false description: >- Trusted routing assertion. A mismatched endpoint returns HTTP 409 - before any work is enqueued. Optional only for backwards compatibility. + before work is enqueued. Optional for backwards compatibility. schema: type: string enum: [default, stateful] + SessionId: + name: session_id + in: path + required: true + schema: + type: string + FileId: + name: fileId + in: path + required: true + schema: + type: string headers: ExecutionProfile: @@ -38,19 +50,45 @@ components: schema: type: string enum: [default, stateful] + RetryAfter: + description: Seconds until the caller should retry. + schema: + type: integer + minimum: 1 + RateLimitLimit: + description: Request limit for the current window. + schema: + type: integer + minimum: 1 + RateLimitRemaining: + description: Requests remaining in the current window. + schema: + type: integer + minimum: 0 + RateLimitReset: + description: Seconds until the current rate-limit window resets. + schema: + type: integer + minimum: 1 responses: BadRequest: - description: Invalid request or invalid expected execution profile + description: Invalid request or expected execution profile headers: X-CodeAPI-Execution-Profile: $ref: '#/components/headers/ExecutionProfile' content: application/json: schema: - anyOf: + oneOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/ExecutionProfileError' + Unauthorized: + description: Missing or invalid authentication + content: + application/json: + schema: + $ref: '#/components/schemas/Error' Conflict: description: Request conflict or execution-profile mismatch headers: @@ -59,84 +97,119 @@ components: content: application/json: schema: - anyOf: + oneOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/ExecutionProfileError' + GenericRateLimited: + description: Request rate limit exceeded + headers: + Retry-After: + $ref: '#/components/headers/RetryAfter' + RateLimit-Limit: + $ref: '#/components/headers/RateLimitLimit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimitRemaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimitReset' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ExecutionRateLimited: + description: Execution request rate limit exceeded + headers: + Retry-After: + $ref: '#/components/headers/RetryAfter' + RateLimit-Limit: + $ref: '#/components/headers/RateLimitLimit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimitRemaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimitReset' + content: + application/json: + schema: + $ref: '#/components/schemas/RateLimitError' + InternalError: + description: Internal server error without backend details + content: + application/json: + schema: + $ref: '#/components/schemas/Error' schemas: - FileRef: + Error: type: object + required: [error] properties: - id: + error: type: string - name: + message: type: string - path: + details: type: string - - RequestFile: + description: Optional fixed public guidance, never an internal error payload. + PublicExecutionError: type: object + required: [error, message] properties: - id: + error: type: string - session_id: + message: type: string - name: + RateLimitError: + type: object + required: [error, message, retry_after_seconds] + properties: + error: type: string - required: - - id - - session_id - - name - - ExecuteResponse: + enum: [rate_limited] + message: + type: string + retry_after_seconds: + type: integer + minimum: 1 + ExecutionProfileError: type: object + required: [error, message, actual_profile] properties: - run: - type: object - properties: - stdout: - type: string - stderr: - type: string - code: - type: integer - nullable: true - signal: - type: string - nullable: true - output: - type: string - memory: - type: integer - nullable: true - message: - type: string - nullable: true - status: - type: string - nullable: true - cpu_time: - type: number - nullable: true - wall_time: - type: number - nullable: true - language: + error: type: string - version: + enum: [invalid_execution_profile, execution_profile_mismatch] + message: type: string - session_id: + expected_profile: type: string - files: - type: array - items: - $ref: '#/components/schemas/FileRef' + actual_profile: + type: string + enum: [default, stateful] - RequestBody: + RequestFile: type: object - required: - - code - - lang + additionalProperties: false + required: [id, resource_id, storage_session_id, name, kind] + properties: + id: + type: string + description: Storage object identifier. + resource_id: + type: string + description: Owner resource identifier used for authorization. + storage_session_id: + type: string + description: Storage session containing the object. + name: + type: string + kind: + type: string + enum: [skill, agent, user] + version: + type: integer + description: Required for skill resources and forbidden for other kinds. + ExecuteRequest: + type: object + additionalProperties: false + required: [code, lang] properties: code: type: string @@ -148,8 +221,8 @@ components: type: string user_id: type: string - entity_id: - type: string + deprecated: true + description: Legacy caller metadata. Authentication determines identity. files: type: array items: @@ -160,78 +233,233 @@ components: pattern: '^[A-Za-z0-9._:-]+$' description: >- Stable opaque hint for stateful runtime reuse. The server binds it - to the authenticated tenant and user. Required in strict runtime - session mode and ignored by the default stateless profile. + to authenticated identity. It is ignored by the stateless profile. - FileObject: + FileRef: type: object + required: [id, name] properties: - name: - type: string id: type: string - session_id: + name: type: string - content: + storage_session_id: type: string - size: - type: number - lastModified: - type: string - etag: + path: type: string - metadata: + modified_from: type: object + required: [id, storage_session_id] properties: - content-type: + id: type: string - original-filename: + storage_session_id: type: string - contentType: + inherited: + type: boolean + enum: [true] + ExecuteResponse: + type: object + required: [session_id, stdout, stderr, files] + properties: + session_id: + type: string + stdout: + type: string + stderr: + type: string + files: + type: array + items: + $ref: '#/components/schemas/FileRef' + code: + type: integer + nullable: true + signal: type: string + nullable: true + message: + type: string + nullable: true + status: + type: string + nullable: true + wall_time: + type: number + nullable: true + UploadRequest: + type: object + required: [kind, id, files] + properties: + kind: + type: string + enum: [skill, agent, user] + id: + type: string + version: + type: integer + read_only: + type: boolean + files: + type: array + items: + type: string + format: binary + UploadResult: + type: object + required: [filename, fileId] + properties: + filename: + type: string + fileId: + type: string UploadResponse: type: object + required: [message, storage_session_id, files] properties: message: type: string - session_id: + enum: [success] + storage_session_id: type: string files: type: array items: - $ref: '#/components/schemas/FileObject' - - Error: + $ref: '#/components/schemas/UploadResult' + BatchUploadFileSuccess: type: object + required: [status, filename, fileId] properties: - error: + status: type: string - details: + enum: [success] + filename: + type: string + fileId: + type: string + BatchUploadFileError: + type: object + required: [status, filename, error] + properties: + status: + type: string + enum: [error] + filename: type: string + error: + type: string + BatchUploadResponse: + type: object + required: [message, storage_session_id, files, succeeded, failed] + properties: message: type: string + enum: [success, partial_success, error] + storage_session_id: + type: string + files: + type: array + items: + oneOf: + - $ref: '#/components/schemas/BatchUploadFileSuccess' + - $ref: '#/components/schemas/BatchUploadFileError' + succeeded: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + filesLimitReached: + type: boolean + maxFiles: + type: integer + minimum: 1 - ExecutionProfileError: + SummaryFileObject: type: object - required: [error, message, actual_profile] properties: - error: + name: type: string - enum: [invalid_execution_profile, execution_profile_mismatch] + size: + type: number + lastModified: + type: string + format: date-time + etag: + type: string + FullFileObject: + allOf: + - $ref: '#/components/schemas/SummaryFileObject' + - type: object + properties: + metadata: + type: object + additionalProperties: true + versionId: + type: string + nullable: true + contentType: + type: string + NormalizedFileObject: + type: object + required: [id, name, storage_session_id, size, contentType, lastModified] + properties: + id: + type: string + name: + type: string + storage_session_id: + type: string + size: + type: number + contentType: + type: string + lastModified: + type: string + format: date-time + read_only: + type: boolean + FileListItem: + oneOf: + - type: string + - $ref: '#/components/schemas/SummaryFileObject' + - $ref: '#/components/schemas/FullFileObject' + - $ref: '#/components/schemas/NormalizedFileObject' + ObjectMetadata: + type: object + required: [name, originalFilename, size, lastModified, etag, contentType, readOnly] + properties: + name: + type: string + originalFilename: + type: string + size: + type: number + lastModified: + type: string + format: date-time + etag: + type: string + contentType: + type: string + readOnly: + type: boolean + DeleteResponse: + type: object + required: [message, session_id, fileId] + properties: message: type: string - expected_profile: + session_id: type: string - actual_profile: + fileId: type: string - enum: [default, stateful] paths: /exec: post: summary: Execute code - description: Execute code with specified language and parameters operationId: executeCode parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' @@ -240,7 +468,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RequestBody' + $ref: '#/components/schemas/ExecuteRequest' responses: '200': description: Successful execution @@ -251,38 +479,57 @@ paths: application/json: schema: $ref: '#/components/schemas/ExecuteResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/Error' '400': $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' + '413': + description: Input files exceed the delivery limit + content: + application/json: + schema: + $ref: '#/components/schemas/PublicExecutionError' + '422': + description: One or more input files are unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PublicExecutionError' + '429': + $ref: '#/components/responses/ExecutionRateLimited' + '500': + $ref: '#/components/responses/InternalError' + '502': + description: Sandbox or input-file service failed + content: + application/json: + schema: + $ref: '#/components/schemas/PublicExecutionError' '503': - description: Service unavailable + description: Service or sandbox unavailable content: application/json: schema: - $ref: '#/components/schemas/Error' + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/PublicExecutionError' + '504': + description: Execution or input delivery timed out + content: + application/json: + schema: + $ref: '#/components/schemas/PublicExecutionError' /download/{session_id}/{fileId}: get: summary: Download a file + operationId: downloadFile parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' - - name: session_id - in: path - required: true - schema: - type: string - - name: fileId - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/FileId' responses: '200': description: File content @@ -294,20 +541,27 @@ paths: schema: type: string format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '404': description: File not found content: application/json: schema: $ref: '#/components/schemas/Error' - '400': - $ref: '#/components/responses/BadRequest' '409': $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' /upload: post: summary: Upload files + operationId: uploadFiles parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' requestBody: @@ -315,15 +569,7 @@ paths: content: multipart/form-data: schema: - type: object - properties: - entity_id: - type: string - files: - type: array - items: - type: string - format: binary + $ref: '#/components/schemas/UploadRequest' responses: '200': description: Successful upload @@ -334,35 +580,85 @@ paths: application/json: schema: $ref: '#/components/schemas/UploadResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' '413': description: File size limit exceeded content: application/json: schema: $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' + '504': + description: Upload timed out + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /upload/batch: + post: + summary: Upload a bounded batch of files + operationId: uploadFilesBatch + parameters: + - $ref: '#/components/parameters/ExpectedExecutionProfile' + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/UploadRequest' + responses: + '200': + description: Complete or partial upload success + headers: + X-CodeAPI-Execution-Profile: + $ref: '#/components/headers/ExecutionProfile' + content: + application/json: + schema: + $ref: '#/components/schemas/BatchUploadResponse' '400': - $ref: '#/components/responses/BadRequest' + description: All files failed or the request contained no files + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/BatchUploadResponse' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' /files/{session_id}: get: - summary: Get files information + summary: List files in a storage session + operationId: listFiles parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' - - name: session_id - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/SessionId' - name: detail in: query + required: false schema: type: string + enum: [simple, summary, full, normalized] default: simple responses: '200': - description: Files information + description: Files at the requested detail level headers: X-CodeAPI-Execution-Profile: $ref: '#/components/headers/ExecutionProfile' @@ -371,40 +667,78 @@ paths: schema: type: array items: - $ref: '#/components/schemas/FileObject' + $ref: '#/components/schemas/FileListItem' '400': $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{session_id}/objects/{fileId}: + get: + summary: Read file metadata + operationId: getFileMetadata + parameters: + - $ref: '#/components/parameters/ExpectedExecutionProfile' + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: File metadata + headers: + X-CodeAPI-Execution-Profile: + $ref: '#/components/headers/ExecutionProfile' + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectMetadata' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: File not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' /files/{session_id}/{fileId}: delete: summary: Delete a file + operationId: deleteFile parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' - - name: session_id - in: path - required: true - schema: - type: string - - name: fileId - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/FileId' responses: '200': - description: File deleted successfully + description: File deleted headers: X-CodeAPI-Execution-Profile: $ref: '#/components/headers/ExecutionProfile' - '500': - description: Error deleting file content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/DeleteResponse' '400': $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/GenericRateLimited' + '500': + $ref: '#/components/responses/InternalError' diff --git a/service/src/openapi-contract.test.ts b/service/src/openapi-contract.test.ts new file mode 100644 index 00000000..810793cb --- /dev/null +++ b/service/src/openapi-contract.test.ts @@ -0,0 +1,234 @@ +import { YAML } from 'bun'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, test } from 'bun:test'; +import type { + ExecuteResponse, + ExecuteResult, + PublicExecuteResponse, +} from './types/service'; + +type Schema = { + oneOf?: Schema[]; + properties?: Record; + required?: string[]; +}; + +type Operation = { + responses: Record; +}; + +type OpenApiDocument = { + info: { title: string; description?: string }; + servers?: Array<{ url: string }>; + paths: Record>; + components: { + responses: Record< + string, + { + headers?: Record; + content?: { + 'application/json'?: { schema?: { $ref?: string } }; + }; + } + >; + schemas: Record; + }; + 'x-internal'?: boolean; +}; + +type IsExact = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) + ? true + : false; + +const publicResponseMatchesFlatResult: IsExact< + PublicExecuteResponse, + ExecuteResult +> = true; +const internalResponseRemainsSeparate: IsExact< + ExecuteResponse, + PublicExecuteResponse +> = false; + +function loadSpec(path: string): OpenApiDocument { + return YAML.parse(readFileSync(path, 'utf8')) as OpenApiDocument; +} + +function localRefs(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(localRefs); + if (value === null || typeof value !== 'object') return []; + + return Object.entries(value).flatMap(([key, nested]) => + key === '$ref' && typeof nested === 'string' && nested.startsWith('#/') + ? [nested] + : localRefs(nested), + ); +} + +function resolvesLocalRef(document: unknown, ref: string): boolean { + let value = document; + for (const segment of ref.slice(2).split('/')) { + if (value === null || typeof value !== 'object' || !(segment in value)) return false; + value = (value as Record)[segment]; + } + return true; +} + +const publicSpecPath = resolve(import.meta.dir, '../openapi.yml'); +const internalSpecPath = resolve(import.meta.dir, '../../api/openapi.yaml'); + +describe('OpenAPI contract boundaries', () => { + test('all local OpenAPI references resolve', () => { + for (const path of [publicSpecPath, internalSpecPath]) { + const spec = loadSpec(path); + for (const ref of localRefs(spec)) expect(resolvesLocalRef(spec, ref)).toBe(true); + } + }); + + test('the named public execution type is flat without changing the internal export', () => { + expect(publicResponseMatchesFlatResult).toBe(true); + expect(internalResponseRemainsSeparate).toBe(false); + }); + + test('the public spec exposes the supported v1 routes', () => { + const spec = loadSpec(publicSpecPath); + + expect(spec.info.title).toContain('Public'); + expect(spec.servers?.[0]?.url.endsWith('/v1')).toBe(true); + expect(Object.keys(spec.paths).sort()).toEqual([ + '/download/{session_id}/{fileId}', + '/exec', + '/files/{session_id}', + '/files/{session_id}/{fileId}', + '/sessions/{session_id}/objects/{fileId}', + '/upload', + '/upload/batch', + ]); + expect(spec.paths).not.toHaveProperty('/api/v2/execute'); + expect(spec.paths).not.toHaveProperty('/execute'); + }); + + test('the public request and response schemas match the service types', () => { + const schemas = loadSpec(publicSpecPath).components.schemas; + const requestFile = schemas.RequestFile; + const executeResponse = schemas.ExecuteResponse; + const fileRef = schemas.FileRef; + const uploadResponse = schemas.UploadResponse; + + expect(requestFile.required?.sort()).toEqual([ + 'id', + 'kind', + 'name', + 'resource_id', + 'storage_session_id', + ]); + expect(Object.keys(requestFile.properties ?? {}).sort()).toEqual([ + 'id', + 'kind', + 'name', + 'resource_id', + 'storage_session_id', + 'version', + ]); + expect(executeResponse.required?.sort()).toEqual([ + 'files', + 'session_id', + 'stderr', + 'stdout', + ]); + expect(executeResponse.properties).not.toHaveProperty('run'); + expect(executeResponse.properties).not.toHaveProperty('compile'); + expect(executeResponse.properties).not.toHaveProperty('language'); + expect(executeResponse.properties).not.toHaveProperty('version'); + expect(fileRef.required?.sort()).toEqual(['id', 'name']); + expect(Object.keys(fileRef.properties ?? {}).sort()).toEqual([ + 'id', + 'inherited', + 'modified_from', + 'name', + 'path', + 'storage_session_id', + ]); + expect(uploadResponse.required?.sort()).toEqual([ + 'files', + 'message', + 'storage_session_id', + ]); + expect(uploadResponse.properties).not.toHaveProperty('session_id'); + }); + + test('the public spec documents rate limits and timeout responses', () => { + const spec = loadSpec(publicSpecPath); + const rateLimitHeaders = Object.keys( + spec.components.responses.GenericRateLimited.headers ?? {}, + ).sort(); + + expect(rateLimitHeaders).toEqual([ + 'RateLimit-Limit', + 'RateLimit-Remaining', + 'RateLimit-Reset', + 'Retry-After', + ]); + expect( + spec.components.responses.ExecutionRateLimited.content?.[ + 'application/json' + ]?.schema?.$ref, + ).toBe('#/components/schemas/RateLimitError'); + + for (const path of Object.values(spec.paths)) { + for (const operation of Object.values(path)) { + expect(operation.responses).toHaveProperty('429'); + } + } + expect(spec.paths['/exec'].post.responses).toHaveProperty('504'); + expect(spec.paths['/upload'].post.responses).toHaveProperty('504'); + }); + + test('the internal spec describes only the sandbox v2 execute contract', () => { + const spec = loadSpec(internalSpecPath); + const schemas = spec.components.schemas; + + expect(spec['x-internal']).toBe(true); + expect(spec.info.title).toContain('Internal'); + expect(Object.keys(spec.paths)).toEqual(['/api/v2/execute']); + expect(Object.keys(schemas.ExecuteRequest.properties ?? {}).sort()).toEqual([ + 'args', + 'compile_cpu_time', + 'compile_memory_limit', + 'compile_timeout', + 'egress_grant', + 'env_vars', + 'execution_manifest', + 'files', + 'language', + 'output_session_id', + 'run_cpu_time', + 'run_memory_limit', + 'run_timeout', + 'session_id', + 'stdin', + 'tool_call_socket', + 'version', + ]); + expect(schemas.ExecuteRequest.required?.sort()).toEqual([ + 'files', + 'language', + 'version', + ]); + expect(schemas.InputFile.required).toBeUndefined(); + expect(schemas.InputFile.oneOf?.map((shape) => shape.required)).toEqual([ + ['content'], + ['id', 'storage_session_id'], + ]); + expect(Object.keys(schemas.ExecuteResponse.properties ?? {}).sort()).toEqual([ + 'compile', + 'files', + 'language', + 'run', + 'session_id', + 'version', + ]); + }); +}); diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index dd0d72f3..ce97fa72 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -365,7 +365,7 @@ async function runReplayIteration( state: ExecutionState, apiKeyId: string, userId: string, -): Promise { +): Promise { const history = await loadToolHistory(state.execution_id); const rawPayload = buildReplayPayload(req, state, history); const sessionKey = state.sessionKey ?? state.userId; @@ -423,7 +423,7 @@ async function runReplayIteration( return waitForJobFinished(job, queue, events, JOB_COMPLETION_WAIT_TIMEOUT_MS); } -function isSandboxRunSuccess(result: t.ExecuteResult): boolean { +function isSandboxRunSuccess(result: t.PublicExecuteResponse): boolean { if (result.code != null && result.code !== 0) return false; if (result.signal != null && result.signal !== '') return false; return true; @@ -825,7 +825,7 @@ async function runAndRespond( if (!res.writableEnded) disconnected = true; }); - let result: t.ExecuteResult; + let result: t.PublicExecuteResponse; try { result = await runReplayIteration(req, state, apiKeyId, userId); } catch (err) { diff --git a/service/src/service/replay-state.ts b/service/src/service/replay-state.ts index 562e06dd..f48431fc 100644 --- a/service/src/service/replay-state.ts +++ b/service/src/service/replay-state.ts @@ -155,7 +155,7 @@ export interface ExecutionState { * interface only as a deploy-time fallback so executions whose state was * persisted by an older binary still resolve correctly while in-flight. */ jobCompleted?: boolean; - jobResult?: t.ExecuteResult; + jobResult?: t.PublicExecuteResponse; jobError?: string; } @@ -395,7 +395,7 @@ export async function scanKeys( // Blocking-mode terminal result // --------------------------------------------------------------------------- -/** Blocking-mode result key. The full `t.ExecuteResult` (stdout / stderr / +/** Blocking-mode result key. The full `t.PublicExecuteResponse` (stdout / stderr / * file refs) lives here, separate from `exec_state:`, because a successful * blocking run with large stdout/stderr or many file refs can serialize past * the `MAX_EXECUTION_STATE_BYTES` cap; storing the result inline used to @@ -409,7 +409,7 @@ function blockingResultKey(execution_id: string): string { return `exec_result:${execution_id}`; } -export async function setBlockingResult(execution_id: string, result: t.ExecuteResult): Promise { +export async function setBlockingResult(execution_id: string, result: t.PublicExecuteResponse): Promise { await redis.set( blockingResultKey(execution_id), JSON.stringify(result), @@ -418,9 +418,9 @@ export async function setBlockingResult(execution_id: string, result: t.ExecuteR ); } -export async function getBlockingResult(execution_id: string): Promise { +export async function getBlockingResult(execution_id: string): Promise { const data = await redis.get(blockingResultKey(execution_id)); - return data != null ? (JSON.parse(data) as t.ExecuteResult) : null; + return data != null ? (JSON.parse(data) as t.PublicExecuteResponse) : null; } export async function deleteBlockingResult(execution_id: string): Promise { @@ -442,7 +442,7 @@ export async function deleteBlockingResult(execution_id: string): Promise * and, only if so, writes BOTH the updated state (with `jobCompleted=true`) * and the result blob in a single hop. If cleanup has already removed the * state, the entire update is skipped. */ -export async function setExecutionResult(execution_id: string, result: t.ExecuteResult): Promise { +export async function setExecutionResult(execution_id: string, result: t.PublicExecuteResponse): Promise { const stateKey = `exec_state:${execution_id}`; const resultKey = `exec_result:${execution_id}`; const existing = await getExecutionState(execution_id); diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 7854b958..87bba0a0 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -11,7 +11,7 @@ import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from ' import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection, waitForJobFinished } from '../queue'; -import { sleep, getAxiosErrorDetails, publicExecutionFailure } from '../utils'; +import { sleep, getAxiosErrorDetails, publicDownloadFailure, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; @@ -333,10 +333,8 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Session ID: ${session_id} | File ID: ${fileId} | Error downloading file:`, errorDetails); - return res.status(500).json({ - error: 'Error downloading file', - details: (error as Error).message - }); + const failure = publicDownloadFailure(error); + return res.status(failure.status).json(failure.body); } }); diff --git a/service/src/types/service.ts b/service/src/types/service.ts index a642dda3..891c7d9d 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -228,6 +228,10 @@ export type ExecuteResult = { wall_time?: number | null; }; +/** Public `/v1/exec` response. `ExecuteResponse` remains the established + * internal sandbox transport for source consumers of this repository. */ +export type PublicExecuteResponse = ExecuteResult; + export interface LanguageConfig { language: string; version: string; @@ -280,7 +284,7 @@ export type JobData = { /** W3C trace context carrier injected by the API before the BullMQ boundary. */ _otel?: Record; }; -export type JobResult = ExecuteResult; +export type JobResult = PublicExecuteResponse; export type ExecuteJob = Job; export interface CodeApiAuthContext { diff --git a/service/src/utils.test.ts b/service/src/utils.test.ts index f1952dfc..37f71b4a 100644 --- a/service/src/utils.test.ts +++ b/service/src/utils.test.ts @@ -1,6 +1,23 @@ import { describe, expect, test } from 'bun:test'; import type { AxiosError } from 'axios'; -import { isValidId, isValidResourceId, publicExecutionFailure, sandboxErrorMessageFromAxios } from './utils'; +import { + isValidId, + isValidResourceId, + publicDownloadFailure, + publicExecutionFailure, + sandboxErrorMessageFromAxios, +} from './utils'; + +test('download failures return a generic 500 without internal details', () => { + const failure = publicDownloadFailure(new Error('private-file.internal/object-123')); + + expect(failure).toEqual({ + status: 500, + body: { error: 'Error downloading file' }, + }); + expect(JSON.stringify(failure)).not.toContain('private-file.internal'); + expect(failure.body).not.toHaveProperty('details'); +}); describe('isValidId (21-char nanoid for sandbox-generated ids)', () => { test('accepts a canonical 21-char nanoid', () => { diff --git a/service/src/utils.ts b/service/src/utils.ts index 3078d6e8..16d8b500 100644 --- a/service/src/utils.ts +++ b/service/src/utils.ts @@ -106,6 +106,16 @@ export function sandboxErrorMessageFromAxios(error: AxiosError): string { return errorCode ? `[${errorCode}] ${message}` : message; } +export function publicDownloadFailure(_error: unknown): { + status: 500; + body: { error: 'Error downloading file' }; +} { + return { + status: 500, + body: { error: 'Error downloading file' }, + }; +} + export function publicExecutionFailure(error: unknown): { status: number; body: { error: string; message: string } } | null { const message = error instanceof Error ? error.message : ''; diff --git a/service/src/workers.ts b/service/src/workers.ts index ed2a46dd..18e10c7e 100644 --- a/service/src/workers.ts +++ b/service/src/workers.ts @@ -26,7 +26,7 @@ function isAbortError(error: unknown): boolean { return axios.isAxiosError(error) && (error.name === 'AbortError' || error.code === 'ERR_CANCELED'); } -async function processJob(job: t.ExecuteJob): Promise { +async function processJob(job: t.ExecuteJob): Promise { return withTraceContext(job.data._otel, () => withSpan('codeapi.job.process', { 'messaging.system': 'bullmq', 'messaging.operation.name': 'process', @@ -37,7 +37,7 @@ async function processJob(job: t.ExecuteJob): Promise { }, () => processJobInner(job), 'CONSUMER')); } -async function processJobInner(job: t.ExecuteJob): Promise { +async function processJobInner(job: t.ExecuteJob): Promise { const { code, payload, isPyPlot } = job.data; const isSyntheticJob = job.data.isSynthetic === true || isSyntheticPrincipalSource(job.data.principalSource); const language = payload?.language ?? 'unknown'; @@ -161,10 +161,10 @@ async function processJobInner(job: t.ExecuteJob): Promise { const stdout = applySystemReplacements(run?.stdout ?? ''); const stderr = filterSystemLogs(run?.stderr ?? '', isPyPlot); - const result: t.ExecuteResult = { + const result: t.PublicExecuteResponse = { session_id: responseData.session_id, /* `files` is optional on the sandbox response (e.g. dry-run - * execute with no outputs); the public `ExecuteResult.files` is + * execute with no outputs); the public response's `files` field is * required and downstream callers always iterate it. Default to * `[]` so the strictened response type from Phase B doesn't * surface a regression that wasn't there before. */ From 93211a32841922777a110c2324a65abe5c47908c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 21:26:10 +0200 Subject: [PATCH 2/8] docs(fork): record public contract patch --- docs/fork/patches.md | 65 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index d1c6802a..63d9c293 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,6 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | +| Keep public and sandbox wire contracts distinct | Active | `0b66a3a` | Public service and internal sandbox APIs | ## Publish exact-SHA UZH images @@ -280,6 +281,63 @@ Replay and drop condition: recreates the Redis client after terminal disconnect, with a readiness recovery test covering an outage longer than five attempts. +## Keep public and sandbox wire contracts distinct + +Required behavior: + +- Preserve the established exported `ExecuteResponse` sandbox transport while + naming the flat `/v1/exec` result `PublicExecuteResponse` for service-owned + producers and consumers. +- Describe the public execution, upload, batch-upload, listing, metadata, + deletion, and download wire shapes separately from the internal + `/api/v2/execute` contract. +- Keep the internal input filename optional and distinguish inline inputs from + stored-file references in the schema. +- Return a fixed download failure body with HTTP 500 and never include the + upstream error message or a `details` field. + +Owned paths: + +- `api/openapi.yaml` +- `service/openapi.yml` +- `service/src/openapi-contract.test.ts` +- `service/src/utils.test.ts` +- `service/src/utils.ts` + +Shared paths: + +- `service/src/service/programmatic-router.ts` +- `service/src/service/replay-state.ts` +- `service/src/service/router.ts` +- `service/src/types/service.ts` +- `service/src/workers.ts` + +Source and current-upstream evidence: + +- Commit `0b66a3a722bdafbcb48b8a32f91bb2ae0997a685` defines the separate public + type, corrected OpenAPI documents, contract tests, and generic download 500. +- The root, API, and service manifests at baseline + `83c4f7b105b6b3e69eda12701ad4ec437acba08f` have no package exports or + `publishConfig`; these are deployed applications, not published libraries. +- Complete-tree searches at the UZH baseline and upstream + `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` found `ExecuteResponse` only in + its definition, OpenAPI names, and the internal sandbox backend adapter. +- GitHub searches across `uzh-bf` found no external `ExecuteResponse` or direct + source import. The upstream fork network search found the same type + definition in eight indexed forks and no separate consumer contract. +- GitLab searches of `ai-infrastructure/deployment` and local AI and Klicker + source-checkout searches found no `ExecuteResponse` or direct import from the + CodeAPI source tree. The legacy export remains unchanged regardless. + +Replay and drop condition: + +- Reapply the public schemas around the current service routes and the internal + schema around the current sandbox request validator; do not rename the + established sandbox transport for source consumers. +- Drop when upstream publishes equivalent public and internal schemas, a + separately named flat public type, and a generic download failure contract + with matching executable tests. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; @@ -295,6 +353,7 @@ Replay and drop condition: - Every one of the 23 paths in the active merge-base-to-fork final-tree diff is assigned above. The chart values, package resources, worker deployment, queue module, and two routers are named shared seams in every contributing patch. -- Fork-authored non-merge commits were collapsed into the seven logical final - behaviors above. The only fork merge commit is classified as history-only; - no fork-authored final-tree path is left unowned. +- Fork-authored non-merge commits were collapsed into the seven historical + logical behaviors above. This branch adds one public-contract behavior with + ten owned or shared paths. The only fork merge commit is classified as + history-only; no fork-authored final-tree path is left unowned. From f34e51c84484a6e67642fe449e4515f0f63d22eb Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 21:37:38 +0200 Subject: [PATCH 3/8] fix(api): address public contract review findings --- api/openapi.yaml | 5 +++-- service/src/openapi-contract.test.ts | 8 ++++++++ service/src/service/router.ts | 5 ++--- service/src/utils.test.ts | 4 ++-- service/src/utils.ts | 13 ++++--------- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 7c64b230..bc36259d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -58,7 +58,7 @@ components: properties: message: type: string - code: + error: type: string ExecuteRequest: @@ -124,7 +124,8 @@ components: description: Storage session for a by-reference input. input_cache_key: type: string - description: Stable opaque runner-local cache identity. + pattern: '^[0-9a-f]{64}$' + description: Stable SHA-256 runner-local cache identity. name: type: string description: Optional destination path; the runner supplies a default when omitted. diff --git a/service/src/openapi-contract.test.ts b/service/src/openapi-contract.test.ts index 810793cb..086b9ec1 100644 --- a/service/src/openapi-contract.test.ts +++ b/service/src/openapi-contract.test.ts @@ -217,11 +217,19 @@ describe('OpenAPI contract boundaries', () => { 'language', 'version', ]); + expect(Object.keys(schemas.Error.properties ?? {}).sort()).toEqual([ + 'error', + 'message', + ]); expect(schemas.InputFile.required).toBeUndefined(); expect(schemas.InputFile.oneOf?.map((shape) => shape.required)).toEqual([ ['content'], ['id', 'storage_session_id'], ]); + expect( + (schemas.InputFile.properties?.input_cache_key as Record) + .pattern, + ).toBe('^[0-9a-f]{64}$'); expect(Object.keys(schemas.ExecuteResponse.properties ?? {}).sort()).toEqual([ 'compile', 'files', diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 87bba0a0..a1f40e16 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -11,7 +11,7 @@ import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from ' import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection, waitForJobFinished } from '../queue'; -import { sleep, getAxiosErrorDetails, publicDownloadFailure, publicExecutionFailure } from '../utils'; +import { sleep, getAxiosErrorDetails, PUBLIC_DOWNLOAD_FAILURE, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; @@ -333,8 +333,7 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Session ID: ${session_id} | File ID: ${fileId} | Error downloading file:`, errorDetails); - const failure = publicDownloadFailure(error); - return res.status(failure.status).json(failure.body); + return res.status(PUBLIC_DOWNLOAD_FAILURE.status).json(PUBLIC_DOWNLOAD_FAILURE.body); } }); diff --git a/service/src/utils.test.ts b/service/src/utils.test.ts index 37f71b4a..5fe069b6 100644 --- a/service/src/utils.test.ts +++ b/service/src/utils.test.ts @@ -3,13 +3,13 @@ import type { AxiosError } from 'axios'; import { isValidId, isValidResourceId, - publicDownloadFailure, + PUBLIC_DOWNLOAD_FAILURE, publicExecutionFailure, sandboxErrorMessageFromAxios, } from './utils'; test('download failures return a generic 500 without internal details', () => { - const failure = publicDownloadFailure(new Error('private-file.internal/object-123')); + const failure = PUBLIC_DOWNLOAD_FAILURE; expect(failure).toEqual({ status: 500, diff --git a/service/src/utils.ts b/service/src/utils.ts index 16d8b500..7d11a56e 100644 --- a/service/src/utils.ts +++ b/service/src/utils.ts @@ -106,15 +106,10 @@ export function sandboxErrorMessageFromAxios(error: AxiosError): string { return errorCode ? `[${errorCode}] ${message}` : message; } -export function publicDownloadFailure(_error: unknown): { - status: 500; - body: { error: 'Error downloading file' }; -} { - return { - status: 500, - body: { error: 'Error downloading file' }, - }; -} +export const PUBLIC_DOWNLOAD_FAILURE = { + status: 500, + body: { error: 'Error downloading file' }, +} as const; export function publicExecutionFailure(error: unknown): { status: number; body: { error: string; message: string } } | null { const message = error instanceof Error ? error.message : ''; From c1333da7f9e0637402c8ec9a116a9c72482b9c35 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 21:49:38 +0200 Subject: [PATCH 4/8] docs(api): complete reviewed response contracts --- api/openapi.yaml | 18 ++++++++++++++++++ service/openapi.yml | 5 +++-- service/src/openapi-contract.test.ts | 6 ++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index bc36259d..6d88c4b3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -31,6 +31,18 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '401': + description: Missing execution manifest + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Invalid or forbidden execution manifest + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '409': description: Runtime session workspace conflict content: @@ -43,6 +55,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '415': + description: JSON content type required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': description: Sandbox execution failed content: diff --git a/service/openapi.yml b/service/openapi.yml index 60f2a18b..989147e2 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -4,8 +4,9 @@ info: version: 1.0.0 description: >- Public authenticated API for sandboxed code execution and file management. - This contract describes the service gateway mounted at /v1. It does not - describe the internal sandbox-runner API. + This contract describes the stable execution and file-management routes + mounted at /v1. It does not describe other authenticated service routes or + the internal sandbox-runner API. servers: - url: https://api.librechat.ai/v1 description: Public CodeAPI service diff --git a/service/src/openapi-contract.test.ts b/service/src/openapi-contract.test.ts index 086b9ec1..0c855c6c 100644 --- a/service/src/openapi-contract.test.ts +++ b/service/src/openapi-contract.test.ts @@ -96,6 +96,9 @@ describe('OpenAPI contract boundaries', () => { const spec = loadSpec(publicSpecPath); expect(spec.info.title).toContain('Public'); + expect(spec.info.description).toContain( + 'execution and file-management routes', + ); expect(spec.servers?.[0]?.url.endsWith('/v1')).toBe(true); expect(Object.keys(spec.paths).sort()).toEqual([ '/download/{session_id}/{fileId}', @@ -193,6 +196,9 @@ describe('OpenAPI contract boundaries', () => { expect(spec['x-internal']).toBe(true); expect(spec.info.title).toContain('Internal'); expect(Object.keys(spec.paths)).toEqual(['/api/v2/execute']); + expect( + Object.keys(spec.paths['/api/v2/execute'].post.responses).sort(), + ).toEqual(['200', '400', '401', '403', '409', '413', '415', '500']); expect(Object.keys(schemas.ExecuteRequest.properties ?? {}).sort()).toEqual([ 'args', 'compile_cpu_time', From 3ac5e8fec1fb7d551ca903aab259be9c983bdd69 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:18:07 +0200 Subject: [PATCH 5/8] refactor(api): keep contract maintenance runtime-neutral --- service/src/service/router.ts | 7 +++++-- service/src/utils.test.ts | 19 +------------------ service/src/utils.ts | 5 ----- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/service/src/service/router.ts b/service/src/service/router.ts index a1f40e16..7854b958 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -11,7 +11,7 @@ import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from ' import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection, waitForJobFinished } from '../queue'; -import { sleep, getAxiosErrorDetails, PUBLIC_DOWNLOAD_FAILURE, publicExecutionFailure } from '../utils'; +import { sleep, getAxiosErrorDetails, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; @@ -333,7 +333,10 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Session ID: ${session_id} | File ID: ${fileId} | Error downloading file:`, errorDetails); - return res.status(PUBLIC_DOWNLOAD_FAILURE.status).json(PUBLIC_DOWNLOAD_FAILURE.body); + return res.status(500).json({ + error: 'Error downloading file', + details: (error as Error).message + }); } }); diff --git a/service/src/utils.test.ts b/service/src/utils.test.ts index 5fe069b6..f1952dfc 100644 --- a/service/src/utils.test.ts +++ b/service/src/utils.test.ts @@ -1,23 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { AxiosError } from 'axios'; -import { - isValidId, - isValidResourceId, - PUBLIC_DOWNLOAD_FAILURE, - publicExecutionFailure, - sandboxErrorMessageFromAxios, -} from './utils'; - -test('download failures return a generic 500 without internal details', () => { - const failure = PUBLIC_DOWNLOAD_FAILURE; - - expect(failure).toEqual({ - status: 500, - body: { error: 'Error downloading file' }, - }); - expect(JSON.stringify(failure)).not.toContain('private-file.internal'); - expect(failure.body).not.toHaveProperty('details'); -}); +import { isValidId, isValidResourceId, publicExecutionFailure, sandboxErrorMessageFromAxios } from './utils'; describe('isValidId (21-char nanoid for sandbox-generated ids)', () => { test('accepts a canonical 21-char nanoid', () => { diff --git a/service/src/utils.ts b/service/src/utils.ts index 7d11a56e..3078d6e8 100644 --- a/service/src/utils.ts +++ b/service/src/utils.ts @@ -106,11 +106,6 @@ export function sandboxErrorMessageFromAxios(error: AxiosError): string { return errorCode ? `[${errorCode}] ${message}` : message; } -export const PUBLIC_DOWNLOAD_FAILURE = { - status: 500, - body: { error: 'Error downloading file' }, -} as const; - export function publicExecutionFailure(error: unknown): { status: number; body: { error: string; message: string } } | null { const message = error instanceof Error ? error.message : ''; From 4b0bdde19f4ff17782a30fe8bb7fbf4fddf711b4 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:23:46 +0200 Subject: [PATCH 6/8] docs(fork): mark contract cleanup optional --- docs/fork/patches.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 63d9c293..dc07d342 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,7 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | -| Keep public and sandbox wire contracts distinct | Active | `0b66a3a` | Public service and internal sandbox APIs | +| Keep public and sandbox wire contracts distinct | Draft | `0b66a3a`, `3ac5e8f` | Optional upstream contract maintenance | ## Publish exact-SHA UZH images @@ -285,6 +285,8 @@ Replay and drop condition: Required behavior: +- Keep this package optional and runtime-neutral. No UZH feature, source gate, + image, or deployment depends on it. - Preserve the established exported `ExecuteResponse` sandbox transport while naming the flat `/v1/exec` result `PublicExecuteResponse` for service-owned producers and consumers. @@ -293,29 +295,26 @@ Required behavior: `/api/v2/execute` contract. - Keep the internal input filename optional and distinguish inline inputs from stored-file references in the schema. -- Return a fixed download failure body with HTTP 500 and never include the - upstream error message or a `details` field. Owned paths: - `api/openapi.yaml` - `service/openapi.yml` - `service/src/openapi-contract.test.ts` -- `service/src/utils.test.ts` -- `service/src/utils.ts` Shared paths: - `service/src/service/programmatic-router.ts` - `service/src/service/replay-state.ts` -- `service/src/service/router.ts` - `service/src/types/service.ts` - `service/src/workers.ts` Source and current-upstream evidence: - Commit `0b66a3a722bdafbcb48b8a32f91bb2ae0997a685` defines the separate public - type, corrected OpenAPI documents, contract tests, and generic download 500. + type, corrected OpenAPI documents, and contract tests. Commit + `3ac5e8fec1fb7d551ca903aab259be9c983bdd69` removes the runtime response + change so this package remains contract maintenance only. - The root, API, and service manifests at baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` have no package exports or `publishConfig`; these are deployed applications, not published libraries. @@ -335,8 +334,7 @@ Replay and drop condition: schema around the current sandbox request validator; do not rename the established sandbox transport for source consumers. - Drop when upstream publishes equivalent public and internal schemas, a - separately named flat public type, and a generic download failure contract - with matching executable tests. + separately named flat public type, and matching executable contract tests. ## Retired debris @@ -355,5 +353,5 @@ Replay and drop condition: module, and two routers are named shared seams in every contributing patch. - Fork-authored non-merge commits were collapsed into the seven historical logical behaviors above. This branch adds one public-contract behavior with - ten owned or shared paths. The only fork merge commit is classified as + eight owned or shared paths. The only fork merge commit is classified as history-only; no fork-authored final-tree path is left unowned. From 758fcf2e53f96644b03b9ceddcb6353105017f05 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:51:17 +0200 Subject: [PATCH 7/8] fix(api): make error response unions valid --- service/openapi.yml | 6 +++--- service/src/openapi-contract.test.ts | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/service/openapi.yml b/service/openapi.yml index 989147e2..bc18f60d 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -81,7 +81,7 @@ components: content: application/json: schema: - oneOf: + anyOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/ExecutionProfileError' Unauthorized: @@ -98,7 +98,7 @@ components: content: application/json: schema: - oneOf: + anyOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/ExecutionProfileError' GenericRateLimited: @@ -513,7 +513,7 @@ paths: content: application/json: schema: - oneOf: + anyOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/PublicExecutionError' '504': diff --git a/service/src/openapi-contract.test.ts b/service/src/openapi-contract.test.ts index 0c855c6c..4529bf51 100644 --- a/service/src/openapi-contract.test.ts +++ b/service/src/openapi-contract.test.ts @@ -96,9 +96,6 @@ describe('OpenAPI contract boundaries', () => { const spec = loadSpec(publicSpecPath); expect(spec.info.title).toContain('Public'); - expect(spec.info.description).toContain( - 'execution and file-management routes', - ); expect(spec.servers?.[0]?.url.endsWith('/v1')).toBe(true); expect(Object.keys(spec.paths).sort()).toEqual([ '/download/{session_id}/{fileId}', @@ -109,8 +106,6 @@ describe('OpenAPI contract boundaries', () => { '/upload', '/upload/batch', ]); - expect(spec.paths).not.toHaveProperty('/api/v2/execute'); - expect(spec.paths).not.toHaveProperty('/execute'); }); test('the public request and response schemas match the service types', () => { From 8e20bd97a921d79ae6574561fc13db295b1442ad Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 1 Sep 2026 00:03:04 +0200 Subject: [PATCH 8/8] fix(api): document file authorization contract --- service/openapi.yml | 52 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/service/openapi.yml b/service/openapi.yml index bc18f60d..01dabfee 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -44,6 +44,28 @@ components: required: true schema: type: string + SessionKind: + name: kind + in: query + required: true + description: Storage owner kind used when the session was created. + schema: + type: string + enum: [user, skill, agent] + SessionScopeId: + name: id + in: query + required: false + description: Required for skill and agent sessions; omitted user IDs use the authenticated user. + schema: + type: string + SessionVersion: + name: version + in: query + required: false + description: Required for skill sessions and rejected for other kinds. + schema: + type: number headers: ExecutionProfile: @@ -90,6 +112,12 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + Forbidden: + description: The authenticated principal does not own the requested session or file + content: + application/json: + schema: + $ref: '#/components/schemas/Error' Conflict: description: Request conflict or execution-profile mismatch headers: @@ -422,7 +450,7 @@ components: read_only: type: boolean FileListItem: - oneOf: + anyOf: - type: string - $ref: '#/components/schemas/SummaryFileObject' - $ref: '#/components/schemas/FullFileObject' @@ -484,6 +512,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '413': @@ -531,6 +561,9 @@ paths: - $ref: '#/components/parameters/ExpectedExecutionProfile' - $ref: '#/components/parameters/SessionId' - $ref: '#/components/parameters/FileId' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' responses: '200': description: File content @@ -546,6 +579,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': description: File not found content: @@ -650,6 +685,9 @@ paths: parameters: - $ref: '#/components/parameters/ExpectedExecutionProfile' - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' - name: detail in: query required: false @@ -673,6 +711,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '429': @@ -688,6 +728,9 @@ paths: - $ref: '#/components/parameters/ExpectedExecutionProfile' - $ref: '#/components/parameters/SessionId' - $ref: '#/components/parameters/FileId' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' responses: '200': description: File metadata @@ -702,6 +745,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': description: File not found content: @@ -723,6 +768,9 @@ paths: - $ref: '#/components/parameters/ExpectedExecutionProfile' - $ref: '#/components/parameters/SessionId' - $ref: '#/components/parameters/FileId' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' responses: '200': description: File deleted @@ -737,6 +785,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '429':