diff --git a/api/openapi.yaml b/api/openapi.yaml index e4c04c14..6d88c4b3 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,214 @@ 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' + '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: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: Request body exceeds the configured limit + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '415': + description: JSON content type required + 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 + error: + 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 + 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. 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/docs/fork/patches.md b/docs/fork/patches.md index d1c6802a..dc07d342 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 | Draft | `0b66a3a`, `3ac5e8f` | Optional upstream contract maintenance | ## Publish exact-SHA UZH images @@ -280,6 +281,61 @@ 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: + +- 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. +- 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. + +Owned paths: + +- `api/openapi.yaml` +- `service/openapi.yml` +- `service/src/openapi-contract.test.ts` + +Shared paths: + +- `service/src/service/programmatic-router.ts` +- `service/src/service/replay-state.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, 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. +- 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 matching executable contract tests. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; @@ -295,6 +351,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 + eight owned or shared paths. The only fork merge commit is classified as + history-only; no fork-authored final-tree path is left unowned. diff --git a/service/openapi.yml b/service/openapi.yml index 913e7809..01dabfee 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -1,14 +1,15 @@ -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 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: LibreChat API server + description: Public CodeAPI service security: - BearerAuth: [] @@ -27,10 +28,44 @@ 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 + 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: @@ -38,10 +73,30 @@ 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' @@ -51,6 +106,18 @@ components: anyOf: - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/ExecutionProfileError' + Unauthorized: + description: Missing or invalid authentication + content: + 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: @@ -62,81 +129,116 @@ components: anyOf: - $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 +250,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 +262,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: - type: string - content: + name: type: string - size: - type: number - lastModified: + storage_session_id: 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: + anyOf: + - 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 +497,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RequestBody' + $ref: '#/components/schemas/ExecuteRequest' responses: '200': description: Successful execution @@ -251,38 +508,62 @@ 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' + '403': + $ref: '#/components/responses/Forbidden' '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' + anyOf: + - $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' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' responses: '200': description: File content @@ -294,20 +575,29 @@ paths: schema: type: string format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '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 +605,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 +616,88 @@ 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' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' - 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 +706,90 @@ paths: schema: type: array items: - $ref: '#/components/schemas/FileObject' + $ref: '#/components/schemas/FileListItem' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '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' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' + 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' + '403': + $ref: '#/components/responses/Forbidden' + '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' + - $ref: '#/components/parameters/SessionKind' + - $ref: '#/components/parameters/SessionScopeId' + - $ref: '#/components/parameters/SessionVersion' 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' + '403': + $ref: '#/components/responses/Forbidden' '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..4529bf51 --- /dev/null +++ b/service/src/openapi-contract.test.ts @@ -0,0 +1,243 @@ +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', + ]); + }); + + 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(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', + '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(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', + '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/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/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. */