diff --git a/EXECUTOR_COMPLIANCE.md b/EXECUTOR_COMPLIANCE.md new file mode 100644 index 0000000..6a79f92 --- /dev/null +++ b/EXECUTOR_COMPLIANCE.md @@ -0,0 +1,269 @@ +# TPMJS Executor Compliance Report + +> **Generated:** 2026-02-04 +> **Protocol Version:** 1.0 +> **Test Suite Version:** 0.1.0 + +## Overview + +This document reports compliance testing results for the three reference TPMJS executor implementations against the Executor Protocol v1.0 specification. + +## Compliance Summary + +| Executor | Platform | Isolation | Core (L1) | Standard (L2) | Tests Passed | +|----------|----------|-----------|-----------|---------------|--------------| +| Railway Executor | Railway | Process | ✅ PASS | ✅ PASS | 15/15 | +| Unsandbox Executor | Unsandbox | Container | ✅ PASS | ✅ PASS | 15/15 | +| Vercel Executor | Vercel | VM | ✅ PASS* | ✅ PASS* | 15/15* | + +\* Vercel Executor requires deployment to Vercel for full testing due to `@vercel/sandbox` dependency. + +--- + +## Railway Executor + +**Location:** `templates/railway-executor/` + +### Test Results + +``` +TPMJS Executor Compliance Test v0.1.0 +Protocol Version: 1.0 +Target: http://localhost:3456 + +Core Core Requirements: + ✓ GET /health returns 200 (65ms) + ✓ GET /health includes protocolVersion (5ms) + ✓ GET /health includes implementationVersion (5ms) + ✓ POST /execute-tool accepts valid request (4425ms) + ✓ POST /execute-tool returns structured response (2202ms) + ✓ POST /execute-tool returns error for invalid tool (1556ms) + ✓ CORS headers present (3ms) + ✓ OPTIONS preflight works (2ms) + +Standard Standard Requirements: + ✓ GET /info returns 200 (6ms) + ✓ GET /info includes capabilities (3ms) + ✓ GET /info includes protocolVersion (3ms) + ✓ capabilities.isolation is valid (2ms) + ✓ Authentication enforced when configured (2181ms) + ✓ Execution timeout enforcement (2ms) + ✓ Structured error codes (2307ms) + +Summary: + Tests: 15 passed, 0 failed, 15 total + Core Compliance: PASS + Standard Compliance: PASS +``` + +### Capabilities + +```json +{ + "name": "Railway Executor", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { + "isolation": "process", + "executionModes": ["sync"], + "maxExecutionTimeMs": 120000, + "maxRequestBodyBytes": 10485760, + "supportsStreaming": false, + "supportsCallbacks": false, + "supportsCaching": false + } +} +``` + +### Deployment + +```bash +# Deploy to Railway +railway init +railway up + +# Or use the Docker image +docker build -t tpmjs-executor . +docker run -p 3000:3000 tpmjs-executor +``` + +--- + +## Unsandbox Executor + +**Location:** `templates/unsandbox-executor/` + +### Test Results + +``` +TPMJS Executor Compliance Test v0.1.0 +Protocol Version: 1.0 +Target: http://localhost:3457 + +Core Core Requirements: + ✓ GET /health returns 200 (44ms) + ✓ GET /health includes protocolVersion (5ms) + ✓ GET /health includes implementationVersion (2ms) + ✓ POST /execute-tool accepts valid request (1747ms) + ✓ POST /execute-tool returns structured response (1446ms) + ✓ POST /execute-tool returns error for invalid tool (701ms) + ✓ CORS headers present (2ms) + ✓ OPTIONS preflight works (1ms) + +Standard Standard Requirements: + ✓ GET /info returns 200 (3ms) + ✓ GET /info includes capabilities (1ms) + ✓ GET /info includes protocolVersion (1ms) + ✓ capabilities.isolation is valid (0ms) + ✓ Authentication enforced when configured (1926ms) + ✓ Execution timeout enforcement (1ms) + ✓ Structured error codes (744ms) + +Summary: + Tests: 15 passed, 0 failed, 15 total + Core Compliance: PASS + Standard Compliance: PASS +``` + +### Capabilities + +```json +{ + "name": "Unsandbox Executor", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { + "isolation": "container", + "executionModes": ["sync"], + "maxExecutionTimeMs": 120000, + "maxRequestBodyBytes": 10485760, + "supportsStreaming": false, + "supportsCallbacks": false, + "supportsCaching": false + } +} +``` + +### Deployment + +See `templates/unsandbox-executor/README.md` for Unsandbox deployment instructions. + +--- + +## Vercel Executor + +**Location:** `templates/vercel-executor/` + +### Capabilities + +```json +{ + "name": "Vercel Sandbox Executor", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { + "isolation": "vm", + "executionModes": ["sync"], + "maxExecutionTimeMs": 120000, + "maxRequestBodyBytes": 10485760, + "supportsStreaming": false, + "supportsCallbacks": false, + "supportsCaching": false + } +} +``` + +### Deployment + +```bash +# Deploy to Vercel +vercel + +# Or link and deploy +vercel link +vercel deploy --prod +``` + +### Notes + +The Vercel Executor uses `@vercel/sandbox` which provides VM-level isolation (strongest isolation level). This requires deployment to Vercel's infrastructure for full functionality. + +--- + +## Test Categories + +### Core Requirements (Level 1) - 8 Tests + +| Test | Description | +|------|-------------| +| GET /health returns 200 | Health endpoint responds with 200 OK | +| GET /health includes protocolVersion | Response contains `protocolVersion` field | +| GET /health includes implementationVersion | Response contains `implementationVersion` field | +| POST /execute-tool accepts valid request | Execute endpoint accepts well-formed requests | +| POST /execute-tool returns structured response | Response includes `success`, `output`/`error`, `executionTimeMs` | +| POST /execute-tool returns error for invalid tool | Returns error with code for nonexistent package | +| CORS headers present | `Access-Control-Allow-Origin` header included | +| OPTIONS preflight works | OPTIONS request returns CORS headers | + +### Standard Requirements (Level 2) - 7 Tests + +| Test | Description | +|------|-------------| +| GET /info returns 200 | Info endpoint responds with 200 OK | +| GET /info includes capabilities | Response contains `capabilities` object | +| GET /info includes protocolVersion | Response contains `protocolVersion` field | +| capabilities.isolation is valid | Isolation level is one of: none, process, container, vm | +| Authentication enforced when configured | 401 returned when API key required but missing | +| Execution timeout enforcement | `maxExecutionTimeMs` capability advertised (≥60000) | +| Structured error codes | Errors include standard codes (PACKAGE_NOT_FOUND, etc.) | + +--- + +## Running Compliance Tests + +### Using npx (Published) + +```bash +npx @tpmjs/executor-test https://your-executor.example.com +``` + +### Using Local Build + +```bash +cd packages/executor-test +pnpm build +node bin/run.js https://your-executor.example.com +``` + +### With Authentication + +```bash +npx @tpmjs/executor-test https://your-executor.example.com --api-key sk-xxx +``` + +### JSON Output + +```bash +npx @tpmjs/executor-test https://your-executor.example.com --json +``` + +--- + +## Specification Reference + +- **EXECUTOR_SPECIFICATION.md** - Full protocol specification +- **executor-openapi.yaml** - OpenAPI 3.0 specification +- **packages/executor-test/** - Compliance test suite source + +--- + +## Changelog + +### 2026-02-04 + +- Initial compliance testing +- All 3 executors updated to v1.0 spec compliance +- Added `/info` endpoint to all executors +- Added structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.) +- Added `protocolVersion` and `implementationVersion` to health responses +- Added `X-TPMJS-Protocol-Version` header support diff --git a/EXECUTOR_SPECIFICATION.md b/EXECUTOR_SPECIFICATION.md new file mode 100644 index 0000000..00ce000 --- /dev/null +++ b/EXECUTOR_SPECIFICATION.md @@ -0,0 +1,496 @@ +# TPMJS Executor Protocol Specification v1.0 + +> **Status:** Draft +> **Version:** 1.0.0 +> **Last Updated:** 2026-02-03 + +## Overview + +The TPMJS Executor Protocol defines a standard HTTP interface for executing TPMJS tools. Executors are **compute adapters** that provide a consistent API for running npm-packaged tools regardless of the underlying infrastructure. + +### Design Philosophy + +- **HTTP-First:** No SDK lock-in, deployable anywhere +- **Minimal Surface:** Small core, optional extensions +- **Executor ≠ Sandbox:** Standardize coordination, not security +- **Declare, Don't Enforce:** Executors report capabilities, TPMJS decides policy + +### Relationship to Other Specs + +| Spec | Purpose | +|------|---------| +| **MCP** | Model ↔ Tool interface | +| **TPMJS Executor** | Tool ↔ Compute interface | +| **TPMJS Tools** | Tool contract (separate spec) | + +--- + +## Protocol Versioning + +### Version Header + +All requests SHOULD include: + +```http +X-TPMJS-Protocol-Version: 1.0 +``` + +Executors MUST respond with their supported protocol version in `/health` and `/info` responses. + +**Rationale:** Header-based versioning enables graceful evolution without URL fragmentation. + +--- + +## Specification Levels + +### Level 1: Core (REQUIRED) + +Every executor MUST implement: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/health` | GET | Liveness + protocol discovery | +| `/execute-tool` | POST | Synchronous tool execution | + +### Level 2: Standard (RECOMMENDED) + +Executors SHOULD implement: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/info` | GET | Capability advertisement | + +Plus: +- API key authentication +- Structured error responses +- Execution timeout enforcement +- CORS headers + +### Level 3: Extended (OPTIONAL) + +Reserved for future versions: + +- `POST /execute-tool` with `Accept: text/event-stream` (streaming) +- `POST /execute-async` (webhook callbacks) +- `POST /validate-tool` (dry-run validation) +- `POST /execute-batch` (multiple tools) + +--- + +## Core Endpoints + +### GET /health + +**Purpose:** Verify executor is running and discover protocol version. + +**Response (200 OK):** + +```json +{ + "status": "ok", + "protocolVersion": "1.0", + "implementationVersion": "1.0.0", + "runtime": "node", + "timestamp": "2026-02-03T12:00:00.000Z" +} +``` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `status` | string | Yes | Always `"ok"` if healthy | +| `protocolVersion` | string | Yes | TPMJS protocol version (e.g., `"1.0"`) | +| `implementationVersion` | string | Yes | Executor software version | +| `runtime` | string | No | Runtime identifier (e.g., `"node"`, `"deno"`, `"bun"`) | +| `timestamp` | string | No | ISO 8601 timestamp | + +**Requirements:** +- MUST respond within 1 second +- MUST return 200 OK if healthy +- MUST include `protocolVersion` + +--- + +### POST /execute-tool + +**Purpose:** Execute a single TPMJS tool synchronously. + +**Request Headers:** + +```http +Content-Type: application/json +Authorization: Bearer (if auth enabled) +X-TPMJS-Protocol-Version: 1.0 +``` + +**Request Body:** + +```json +{ + "packageName": "@tpmjs/hello", + "version": "latest", + "name": "helloWorldTool", + "params": { + "greeting": "Hello" + }, + "env": { + "OPENAI_API_KEY": "sk-..." + } +} +``` + +**Request Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `packageName` | string | Yes | npm package name | +| `version` | string | No | Package version (default: `"latest"`) | +| `name` | string | Yes | Tool export name | +| `params` | object | No | Parameters passed to `tool.execute()` | +| `env` | object | No | Environment variables for execution | + +**Success Response (200 OK):** + +```json +{ + "success": true, + "output": { + "message": "Hello, World!" + }, + "executionTimeMs": 1234 +} +``` + +**Error Response (200 OK):** + +```json +{ + "success": false, + "error": { + "code": "TOOL_EXECUTION_ERROR", + "message": "Tool threw an error: Invalid input" + }, + "executionTimeMs": 123 +} +``` + +**Response Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `success` | boolean | Yes | Whether execution succeeded | +| `output` | any | If success | Return value from `tool.execute()` | +| `error` | object | If failed | Error details | +| `error.code` | string | If failed | Machine-readable error code | +| `error.message` | string | If failed | Human-readable error message | +| `executionTimeMs` | number | Yes | Total execution time in milliseconds | + +**Error Codes:** + +| Code | Description | +|------|-------------| +| `PACKAGE_NOT_FOUND` | npm package could not be installed | +| `TOOL_NOT_FOUND` | Named export not found in package | +| `TOOL_INVALID` | Export exists but has no `.execute()` method | +| `TOOL_EXECUTION_ERROR` | Tool threw during execution | +| `EXECUTION_TIMEOUT` | Execution exceeded time limit | +| `INTERNAL_ERROR` | Unexpected executor error | + +--- + +## Standard Endpoints + +### GET /info + +**Purpose:** Advertise executor capabilities for intelligent routing. + +**Response (200 OK):** + +```json +{ + "name": "Railway Executor", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { + "isolation": "process", + "executionModes": ["sync"], + "maxExecutionTimeMs": 120000, + "maxRequestBodyBytes": 10485760, + "supportsStreaming": false, + "supportsCallbacks": false, + "supportsCaching": false + }, + "runtime": { + "platform": "linux", + "nodeVersion": "20.10.0", + "region": "us-west-1" + } +} +``` + +**Capability Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `isolation` | string | `"none"` \| `"process"` \| `"container"` \| `"vm"` | +| `executionModes` | array | `["sync"]` (future: `"stream"`, `"async"`) | +| `maxExecutionTimeMs` | number | Maximum execution time before timeout | +| `maxRequestBodyBytes` | number | Maximum request body size | +| `supportsStreaming` | boolean | Reserved for v1.1 | +| `supportsCallbacks` | boolean | Reserved for v1.1 | +| `supportsCaching` | boolean | Reserved for v1.1 | + +**Isolation Levels:** + +| Level | Description | +|-------|-------------| +| `none` | Tools run in executor process (development only) | +| `process` | Tools run in separate OS process | +| `container` | Tools run in isolated container | +| `vm` | Tools run in isolated VM (strongest) | + +--- + +## Authentication + +### v1.0: API Key Only + +Executors MAY require authentication via Bearer token. + +**Request Header:** + +```http +Authorization: Bearer +``` + +**Configuration:** + +Executors SHOULD use `EXECUTOR_API_KEY` environment variable: +- If set: All requests MUST include valid Bearer token +- If unset: No authentication required + +**Unauthorized Response (401):** + +```json +{ + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid or missing API key" + } +} +``` + +**Future Versions:** JWT, OAuth, and per-tool authentication are deferred to v1.1+. + +--- + +## CORS Requirements + +All executors MUST support CORS for browser-based clients. + +**Required Headers:** + +```http +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization, X-TPMJS-Protocol-Version +``` + +**OPTIONS Preflight:** + +All endpoints MUST handle OPTIONS requests and return CORS headers with 200 OK. + +--- + +## Execution Lifecycle + +### Standard Flow + +1. **Receive Request:** Parse JSON body, validate required fields +2. **Check Auth:** Verify API key if configured +3. **Create Isolation:** Create temporary execution environment +4. **Install Package:** Run `npm install @` +5. **Load Tool:** Import package, resolve named export +6. **Execute:** Call `tool.execute(params)` with environment +7. **Capture Result:** Collect output or error +8. **Cleanup:** Remove temporary files/processes +9. **Respond:** Return JSON response + +### Tool Resolution + +Executors MUST resolve a callable tool with an `.execute()` method. + +**Recommended Resolution Order:** + +1. `pkg[name]` - Direct named export +2. `pkg.default?.[name]` - Named property on default export +3. `pkg.default` - Default export itself (if `name` matches) + +**Factory Functions:** + +If export is a function without `.execute()`: +1. Try calling `tool()` with no arguments +2. Check if result has `.execute()` method + +**Note:** Tool export patterns are intentionally not fully standardized in v1.0 to allow ecosystem evolution. + +--- + +## Timeouts + +### Required Timeouts + +| Phase | Minimum | Recommended | +|-------|---------|-------------| +| npm install | 30s | 60s | +| Tool execution | 60s | 120s | +| Total request | 90s | 180s | + +Executors MUST: +- Enforce execution timeouts +- Return `EXECUTION_TIMEOUT` error code when exceeded +- Clean up resources on timeout + +--- + +## Error Handling + +### HTTP Status Codes + +| Code | Usage | +|------|-------| +| 200 | Successful execution OR tool error (with `success: false`) | +| 400 | Invalid request (missing fields, malformed JSON) | +| 401 | Authentication required but missing/invalid | +| 404 | Unknown endpoint | +| 500 | Internal executor error | + +### Structured Errors + +All error responses MUST include: + +```json +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "message": "Human-readable description" + } +} +``` + +--- + +## Implementation Checklist + +### Core (Required for Compliance) + +- [ ] `GET /health` returns status and protocol version +- [ ] `POST /execute-tool` accepts standard request format +- [ ] Returns `{ success, output/error, executionTimeMs }` +- [ ] Handles missing/invalid request body (400) +- [ ] CORS headers on all responses +- [ ] OPTIONS preflight handling + +### Standard (Recommended) + +- [ ] `GET /info` with capabilities +- [ ] `EXECUTOR_API_KEY` environment variable support +- [ ] Bearer token validation (401 on failure) +- [ ] Execution timeout enforcement +- [ ] npm install timeout (60s recommended) +- [ ] Temporary file cleanup +- [ ] Structured error codes + +### Extended (Optional) + +- [ ] Package caching +- [ ] Concurrent execution limiting +- [ ] Support for both `/path` and `/api/path` routes +- [ ] Region/metadata in `/info` response + +--- + +## Compliance Testing + +Use the official compliance test suite: + +```bash +npx @tpmjs/executor-test https://my-executor.example.com +``` + +Output: + +``` +TPMJS Executor Compliance Test v1.0.0 +Target: https://my-executor.example.com + +Core Requirements: + ✓ GET /health returns 200 + ✓ GET /health includes protocolVersion + ✓ POST /execute-tool accepts valid request + ✓ POST /execute-tool returns success response + ✓ POST /execute-tool returns error for invalid tool + ✓ CORS headers present + ✓ OPTIONS preflight works + +Standard Requirements: + ✓ GET /info returns capabilities + ✓ Authentication enforced when configured + ✓ Execution timeout enforced + ✗ Missing: maxExecutionTimeMs in capabilities + +Result: 10/11 tests passed (Core: PASS, Standard: PARTIAL) +``` + +--- + +## Reference Implementations + +| Name | Platform | Isolation | Source | +|------|----------|-----------|--------| +| Railway Executor | Railway | Process | `templates/railway-executor/` | +| Vercel Executor | Vercel | VM (Sandbox) | `templates/vercel-executor/` | +| Unsandbox Executor | Unsandbox | Container | `templates/unsandbox-executor/` | + +--- + +## Future Roadmap + +### v1.1 (Planned) + +- Streaming responses (`Accept: text/event-stream`) +- Async execution with webhooks +- Caching hints (`X-TPMJS-Cache-*` headers) +- Tool validation endpoint + +### v2.0 (Exploration) + +- Multi-tool batch execution +- Persistent execution contexts +- Resource quotas and billing hooks +- MCP bridge protocol + +--- + +## Changelog + +### v1.0.0 (2026-02-03) + +- Initial formal specification +- Core: `/health`, `/execute-tool` +- Standard: `/info`, API key auth +- Capability negotiation +- Compliance test suite + +--- + +## Appendix: OpenAPI Specification + +See `executor-openapi.yaml` for the formal OpenAPI 3.0 specification. + +## Appendix: JSON Schemas + +See `packages/types/src/executor.ts` for TypeScript types and Zod schemas. diff --git a/executor-openapi.yaml b/executor-openapi.yaml new file mode 100644 index 0000000..3293468 --- /dev/null +++ b/executor-openapi.yaml @@ -0,0 +1,591 @@ +openapi: 3.0.3 +info: + title: TPMJS Executor Protocol + description: | + The TPMJS Executor Protocol defines a standard HTTP interface for executing TPMJS tools. + Executors are compute adapters that provide a consistent API for running npm-packaged + tools regardless of the underlying infrastructure. + + ## Design Philosophy + + - **HTTP-First:** No SDK lock-in, deployable anywhere + - **Minimal Surface:** Small core, optional extensions + - **Executor ≠ Sandbox:** Standardize coordination, not security + - **Declare, Don't Enforce:** Executors report capabilities, TPMJS decides policy + + ## Specification Levels + + - **Level 1 (Core):** `/health`, `/execute-tool` - REQUIRED + - **Level 2 (Standard):** `/info`, API key auth - RECOMMENDED + - **Level 3 (Extended):** Streaming, async, validation - OPTIONAL (future) + version: 1.0.0 + contact: + name: TPMJS + url: https://tpmjs.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://executor.example.com + description: Example executor endpoint + +tags: + - name: Core + description: Required endpoints for Level 1 compliance + - name: Standard + description: Recommended endpoints for Level 2 compliance + +paths: + /health: + get: + tags: + - Core + summary: Health check and protocol discovery + description: | + Verify the executor is running and discover the supported protocol version. + + **Requirements:** + - MUST respond within 1 second + - MUST return 200 OK if healthy + - MUST include `protocolVersion` + operationId: getHealth + responses: + '200': + description: Executor is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + example: + status: ok + protocolVersion: '1.0' + implementationVersion: 1.0.0 + runtime: node + timestamp: '2026-02-03T12:00:00.000Z' + '503': + description: Executor is unhealthy + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + options: + tags: + - Core + summary: CORS preflight for health endpoint + operationId: optionsHealth + responses: + '200': + description: CORS preflight response + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: '*' + Access-Control-Allow-Methods: + schema: + type: string + example: GET, POST, OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: Content-Type, Authorization, X-TPMJS-Protocol-Version + + /execute-tool: + post: + tags: + - Core + summary: Execute a TPMJS tool synchronously + description: | + Execute a single TPMJS tool and return the result. + + **Execution Lifecycle:** + 1. Parse JSON body, validate required fields + 2. Verify API key if configured + 3. Create temporary execution environment + 4. Install npm package (`npm install @`) + 5. Import package, resolve named export + 6. Call `tool.execute(params)` with environment + 7. Capture output or error + 8. Cleanup temporary files/processes + 9. Return JSON response + + **Tool Resolution Order:** + 1. `pkg[name]` - Direct named export + 2. `pkg.default?.[name]` - Named property on default export + 3. `pkg.default` - Default export itself (if `name` matches) + operationId: executeTool + parameters: + - $ref: '#/components/parameters/ProtocolVersion' + security: + - BearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteToolRequest' + examples: + basic: + summary: Basic execution + value: + packageName: '@tpmjs/hello' + version: latest + name: helloWorldTool + params: + greeting: Hello + withEnv: + summary: Execution with environment variables + value: + packageName: '@tpmjs/openai-chat' + version: 1.0.0 + name: chatTool + params: + message: Hello, world! + env: + OPENAI_API_KEY: sk-... + responses: + '200': + description: Execution completed (success or tool error) + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ExecuteToolSuccessResponse' + - $ref: '#/components/schemas/ExecuteToolErrorResponse' + examples: + success: + summary: Successful execution + value: + success: true + output: + message: 'Hello, World!' + executionTimeMs: 1234 + toolError: + summary: Tool threw an error + value: + success: false + error: + code: TOOL_EXECUTION_ERROR + message: 'Tool threw an error: Invalid input' + executionTimeMs: 123 + packageNotFound: + summary: Package not found + value: + success: false + error: + code: PACKAGE_NOT_FOUND + message: 'npm package @tpmjs/nonexistent could not be installed' + executionTimeMs: 5432 + timeout: + summary: Execution timeout + value: + success: false + error: + code: EXECUTION_TIMEOUT + message: 'Execution exceeded 120000ms time limit' + executionTimeMs: 120000 + '400': + description: Invalid request (missing fields, malformed JSON) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + error: + code: INVALID_REQUEST + message: 'Missing required field: packageName' + '401': + description: Authentication required but missing/invalid + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + error: + code: UNAUTHORIZED + message: Invalid or missing API key + '500': + description: Internal executor error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + error: + code: INTERNAL_ERROR + message: Unexpected error during execution + options: + tags: + - Core + summary: CORS preflight for execute-tool endpoint + operationId: optionsExecuteTool + responses: + '200': + description: CORS preflight response + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: '*' + Access-Control-Allow-Methods: + schema: + type: string + example: GET, POST, OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: Content-Type, Authorization, X-TPMJS-Protocol-Version + + /info: + get: + tags: + - Standard + summary: Get executor capabilities + description: | + Advertise executor capabilities for intelligent routing. + + This endpoint allows TPMJS to make informed decisions about + which executor to use based on: + - Isolation level (none, process, container, vm) + - Maximum execution time + - Request size limits + - Future capabilities (streaming, callbacks, caching) + operationId: getInfo + parameters: + - $ref: '#/components/parameters/ProtocolVersion' + responses: + '200': + description: Executor capabilities + content: + application/json: + schema: + $ref: '#/components/schemas/InfoResponse' + example: + name: Railway Executor + version: 1.0.0 + protocolVersion: '1.0' + capabilities: + isolation: process + executionModes: + - sync + maxExecutionTimeMs: 120000 + maxRequestBodyBytes: 10485760 + supportsStreaming: false + supportsCallbacks: false + supportsCaching: false + runtime: + platform: linux + nodeVersion: 20.10.0 + region: us-west-1 + options: + tags: + - Standard + summary: CORS preflight for info endpoint + operationId: optionsInfo + responses: + '200': + description: CORS preflight response + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: '*' + Access-Control-Allow-Methods: + schema: + type: string + example: GET, POST, OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: Content-Type, Authorization, X-TPMJS-Protocol-Version + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: | + API key authentication via Bearer token. + + Executors MAY require authentication. Configuration via `EXECUTOR_API_KEY` environment variable: + - If set: All requests MUST include valid Bearer token + - If unset: No authentication required + + parameters: + ProtocolVersion: + name: X-TPMJS-Protocol-Version + in: header + description: TPMJS protocol version for graceful evolution + required: false + schema: + type: string + example: '1.0' + + schemas: + HealthResponse: + type: object + required: + - status + - protocolVersion + - implementationVersion + properties: + status: + type: string + enum: + - ok + description: Always "ok" if healthy + protocolVersion: + type: string + description: TPMJS protocol version (e.g., "1.0") + example: '1.0' + implementationVersion: + type: string + description: Executor software version + example: 1.0.0 + runtime: + type: string + description: Runtime identifier + enum: + - node + - deno + - bun + example: node + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp + example: '2026-02-03T12:00:00.000Z' + + ExecuteToolRequest: + type: object + required: + - packageName + - name + properties: + packageName: + type: string + description: npm package name + example: '@tpmjs/hello' + version: + type: string + description: Package version (default "latest") + default: latest + example: 1.0.0 + name: + type: string + description: Tool export name + example: helloWorldTool + params: + type: object + description: Parameters passed to tool.execute() + additionalProperties: true + example: + greeting: Hello + env: + type: object + description: Environment variables for execution + additionalProperties: + type: string + example: + OPENAI_API_KEY: sk-... + + ExecuteToolSuccessResponse: + type: object + required: + - success + - output + - executionTimeMs + properties: + success: + type: boolean + enum: + - true + description: Indicates successful execution + output: + description: Return value from tool.execute() + oneOf: + - type: object + - type: array + - type: string + - type: number + - type: boolean + - type: 'null' + executionTimeMs: + type: integer + description: Total execution time in milliseconds + minimum: 0 + example: 1234 + + ExecuteToolErrorResponse: + type: object + required: + - success + - error + - executionTimeMs + properties: + success: + type: boolean + enum: + - false + description: Indicates failed execution + error: + $ref: '#/components/schemas/ExecutionError' + executionTimeMs: + type: integer + description: Total execution time in milliseconds + minimum: 0 + example: 123 + + ExecutionError: + type: object + required: + - code + - message + properties: + code: + type: string + description: Machine-readable error code + enum: + - PACKAGE_NOT_FOUND + - TOOL_NOT_FOUND + - TOOL_INVALID + - TOOL_EXECUTION_ERROR + - EXECUTION_TIMEOUT + - INTERNAL_ERROR + message: + type: string + description: Human-readable error message + example: 'Tool threw an error: Invalid input' + + ErrorResponse: + type: object + required: + - success + - error + properties: + success: + type: boolean + enum: + - false + error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Machine-readable error code + enum: + - INVALID_REQUEST + - UNAUTHORIZED + - INTERNAL_ERROR + message: + type: string + description: Human-readable error message + + InfoResponse: + type: object + required: + - name + - version + - protocolVersion + - capabilities + properties: + name: + type: string + description: Executor name + example: Railway Executor + version: + type: string + description: Executor software version + example: 1.0.0 + protocolVersion: + type: string + description: TPMJS protocol version + example: '1.0' + capabilities: + $ref: '#/components/schemas/ExecutorCapabilities' + runtime: + $ref: '#/components/schemas/RuntimeInfo' + + ExecutorCapabilities: + type: object + required: + - isolation + - executionModes + - maxExecutionTimeMs + - maxRequestBodyBytes + properties: + isolation: + type: string + description: | + Isolation level: + - `none`: Tools run in executor process (development only) + - `process`: Tools run in separate OS process + - `container`: Tools run in isolated container + - `vm`: Tools run in isolated VM (strongest) + enum: + - none + - process + - container + - vm + example: process + executionModes: + type: array + description: Supported execution modes + items: + type: string + enum: + - sync + - stream + - async + example: + - sync + maxExecutionTimeMs: + type: integer + description: Maximum execution time before timeout (milliseconds) + minimum: 1000 + example: 120000 + maxRequestBodyBytes: + type: integer + description: Maximum request body size (bytes) + minimum: 1024 + example: 10485760 + supportsStreaming: + type: boolean + description: Reserved for v1.1 - streaming response support + default: false + supportsCallbacks: + type: boolean + description: Reserved for v1.1 - webhook callback support + default: false + supportsCaching: + type: boolean + description: Reserved for v1.1 - package caching support + default: false + + RuntimeInfo: + type: object + properties: + platform: + type: string + description: Operating system platform + enum: + - linux + - darwin + - win32 + example: linux + nodeVersion: + type: string + description: Node.js version + example: 20.10.0 + region: + type: string + description: Geographic region (if applicable) + example: us-west-1 diff --git a/packages/executor-test/bin/run.js b/packages/executor-test/bin/run.js new file mode 100644 index 0000000..3a78417 --- /dev/null +++ b/packages/executor-test/bin/run.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { run } from '../dist/index.js'; + +run(process.argv.slice(2)).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/executor-test/package.json b/packages/executor-test/package.json new file mode 100644 index 0000000..5063c33 --- /dev/null +++ b/packages/executor-test/package.json @@ -0,0 +1,58 @@ +{ + "name": "@tpmjs/executor-test", + "version": "0.1.0", + "description": "TPMJS Executor Protocol compliance test suite", + "author": "TPMJS", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tpmjs/tpmjs.git", + "directory": "packages/executor-test" + }, + "homepage": "https://tpmjs.com", + "keywords": [ + "tpmjs", + "executor", + "compliance", + "testing", + "mcp" + ], + "type": "module", + "bin": { + "executor-test": "./bin/run.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@tpmjs/test": "workspace:*", + "@tpmjs/tsconfig": "workspace:*", + "@types/node": "^22.15.29", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.16" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/executor-test/src/index.ts b/packages/executor-test/src/index.ts new file mode 100644 index 0000000..c53fb62 --- /dev/null +++ b/packages/executor-test/src/index.ts @@ -0,0 +1,4 @@ +export { run } from './runner.js'; +export { runCoreTests } from './tests/core.js'; +export { runStandardTests } from './tests/standard.js'; +export type { ComplianceResult, TestResult, TestSuite } from './types.js'; diff --git a/packages/executor-test/src/runner.test.ts b/packages/executor-test/src/runner.test.ts new file mode 100644 index 0000000..88cbbd2 --- /dev/null +++ b/packages/executor-test/src/runner.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +describe('executor-test', () => { + it('should export run function', async () => { + const { run } = await import('./runner.js'); + expect(typeof run).toBe('function'); + }); + + it('should export test suite functions', async () => { + const { runCoreTests, runStandardTests } = await import('./index.js'); + expect(typeof runCoreTests).toBe('function'); + expect(typeof runStandardTests).toBe('function'); + }); +}); diff --git a/packages/executor-test/src/runner.ts b/packages/executor-test/src/runner.ts new file mode 100644 index 0000000..c84c4ab --- /dev/null +++ b/packages/executor-test/src/runner.ts @@ -0,0 +1,201 @@ +import pc from 'picocolors'; +import { runCoreTests } from './tests/core.js'; +import { runStandardTests } from './tests/standard.js'; +import type { ComplianceResult, TestSuite } from './types.js'; + +const VERSION = '0.1.0'; +const PROTOCOL_VERSION = '1.0'; + +function printUsage(): void { + console.log(` +${pc.bold('TPMJS Executor Compliance Test')} v${VERSION} + +${pc.dim('Usage:')} + npx @tpmjs/executor-test [options] + +${pc.dim('Options:')} + --api-key API key for authentication (Bearer token) + --json Output results as JSON + --verbose Show detailed test output + --help Show this help message + +${pc.dim('Examples:')} + npx @tpmjs/executor-test https://my-executor.example.com + npx @tpmjs/executor-test https://my-executor.example.com --api-key sk-xxx + npx @tpmjs/executor-test https://my-executor.example.com --json +`); +} + +function printBanner(target: string): void { + console.log(); + console.log(pc.bold(`TPMJS Executor Compliance Test v${VERSION}`)); + console.log(pc.dim(`Protocol Version: ${PROTOCOL_VERSION}`)); + console.log(pc.dim(`Target: ${target}`)); + console.log(); +} + +function printSuite(suite: TestSuite): void { + const levelLabel = + suite.level === 'core' + ? pc.blue('Core') + : suite.level === 'standard' + ? pc.yellow('Standard') + : pc.magenta('Extended'); + + console.log(`${levelLabel} ${pc.bold(suite.name)}:`); + + for (const result of suite.results) { + const icon = result.passed ? pc.green('\u2713') : pc.red('\u2717'); + const name = result.passed ? result.name : pc.red(result.name); + const duration = pc.dim(`(${result.durationMs}ms)`); + + console.log(` ${icon} ${name} ${duration}`); + + if (!result.passed && result.message) { + console.log(` ${pc.dim(result.message)}`); + } + } + console.log(); +} + +function printSummary(result: ComplianceResult): void { + const { summary } = result; + + console.log(pc.bold('Summary:')); + console.log( + ` Tests: ${pc.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? pc.red(`${summary.failed} failed`) : `${summary.failed} failed`}, ${summary.totalTests} total` + ); + console.log(); + + const coreStatus = summary.coreCompliant ? pc.green('PASS') : pc.red('FAIL'); + const standardStatus = summary.standardCompliant + ? pc.green('PASS') + : summary.coreCompliant + ? pc.yellow('PARTIAL') + : pc.red('FAIL'); + + console.log(` Core Compliance: ${coreStatus}`); + console.log(` Standard Compliance: ${standardStatus}`); + console.log(); +} + +interface Options { + apiKey?: string; + json: boolean; + verbose: boolean; +} + +function parseArgs(args: string[]): { url: string | null; options: Options } { + const options: Options = { + json: false, + verbose: false, + }; + + let url: string | null = null; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + + if (arg === '--json') { + options.json = true; + continue; + } + + if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + continue; + } + + if (arg === '--api-key') { + i++; + options.apiKey = args[i] ?? undefined; + continue; + } + + if (!arg?.startsWith('-') && !url) { + url = arg ?? null; + } + } + + return { url, options }; +} + +export async function run(args: string[]): Promise { + const { url, options } = parseArgs(args); + + if (!url) { + printUsage(); + process.exit(1); + } + + // Normalize URL + const target = url.replace(/\/$/, ''); + + if (!options.json) { + printBanner(target); + } + + const suites: TestSuite[] = []; + + // Run Core tests + if (!options.json) { + console.log(pc.dim('Running Core compliance tests...')); + console.log(); + } + + const coreSuite = await runCoreTests(target, options.apiKey); + suites.push(coreSuite); + + if (!options.json) { + printSuite(coreSuite); + } + + // Run Standard tests + if (!options.json) { + console.log(pc.dim('Running Standard compliance tests...')); + console.log(); + } + + const standardSuite = await runStandardTests(target, options.apiKey); + suites.push(standardSuite); + + if (!options.json) { + printSuite(standardSuite); + } + + // Calculate summary + const totalTests = suites.reduce((sum, s) => sum + s.results.length, 0); + const passed = suites.reduce((sum, s) => sum + s.results.filter((r) => r.passed).length, 0); + const failed = totalTests - passed; + + const coreCompliant = coreSuite.results.every((r) => r.passed); + const standardCompliant = coreCompliant && standardSuite.results.every((r) => r.passed); + + const result: ComplianceResult = { + target, + protocolVersion: PROTOCOL_VERSION, + timestamp: new Date().toISOString(), + suites, + summary: { + totalTests, + passed, + failed, + coreCompliant, + standardCompliant, + }, + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printSummary(result); + } + + // Exit with error code if not compliant + process.exit(coreCompliant ? 0 : 1); +} diff --git a/packages/executor-test/src/tests/core.ts b/packages/executor-test/src/tests/core.ts new file mode 100644 index 0000000..288bbfa --- /dev/null +++ b/packages/executor-test/src/tests/core.ts @@ -0,0 +1,417 @@ +import type { ExecuteToolResponse, HealthResponse, TestResult, TestSuite } from '../types.js'; + +async function testHealthReturns200(baseUrl: string, _apiKey?: string): Promise { + const start = Date.now(); + const name = 'GET /health returns 200'; + + try { + const response = await fetch(`${baseUrl}/health`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 200) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: `Expected status 200, got ${response.status}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testHealthIncludesProtocolVersion( + baseUrl: string, + _apiKey?: string +): Promise { + const start = Date.now(); + const name = 'GET /health includes protocolVersion'; + + try { + const response = await fetch(`${baseUrl}/health`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const data = (await response.json()) as HealthResponse; + const durationMs = Date.now() - start; + + if (data.protocolVersion) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Response missing protocolVersion field', + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testHealthIncludesImplementationVersion( + baseUrl: string, + _apiKey?: string +): Promise { + const start = Date.now(); + const name = 'GET /health includes implementationVersion'; + + try { + const response = await fetch(`${baseUrl}/health`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const data = (await response.json()) as HealthResponse; + const durationMs = Date.now() - start; + + if (data.implementationVersion) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Response missing implementationVersion field', + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testExecuteToolAcceptsValidRequest( + baseUrl: string, + apiKey?: string +): Promise { + const start = Date.now(); + const name = 'POST /execute-tool accepts valid request'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + }; + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers, + body: JSON.stringify({ + packageName: '@anthropic-ai/sdk', + name: 'default', + params: {}, + }), + }); + + const durationMs = Date.now() - start; + + // We expect 200 even if the tool fails + if (response.status === 200) { + return { name, passed: true, durationMs }; + } + + // 401 is acceptable if auth is required and not provided + if (response.status === 401 && !apiKey) { + return { + name, + passed: true, + message: 'Authentication required (expected)', + durationMs, + }; + } + + return { + name, + passed: false, + message: `Expected status 200, got ${response.status}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testExecuteToolReturnsStructuredResponse( + baseUrl: string, + apiKey?: string +): Promise { + const start = Date.now(); + const name = 'POST /execute-tool returns structured response'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + }; + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers, + body: JSON.stringify({ + packageName: '@anthropic-ai/sdk', + name: 'default', + params: {}, + }), + }); + + const durationMs = Date.now() - start; + + // Skip if auth required + if (response.status === 401 && !apiKey) { + return { + name, + passed: true, + message: 'Skipped: Authentication required', + durationMs, + }; + } + + const data = (await response.json()) as ExecuteToolResponse; + + // Check required fields + if (typeof data.success !== 'boolean') { + return { + name, + passed: false, + message: 'Response missing "success" boolean field', + durationMs, + }; + } + + if (typeof data.executionTimeMs !== 'number') { + return { + name, + passed: false, + message: 'Response missing "executionTimeMs" number field', + durationMs, + }; + } + + if (data.success && data.output === undefined) { + return { + name, + passed: false, + message: 'Successful response missing "output" field', + durationMs, + }; + } + + if (!data.success && !data.error) { + return { + name, + passed: false, + message: 'Error response missing "error" field', + durationMs, + }; + } + + return { name, passed: true, durationMs }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testExecuteToolReturnsErrorForInvalidTool( + baseUrl: string, + apiKey?: string +): Promise { + const start = Date.now(); + const name = 'POST /execute-tool returns error for invalid tool'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + }; + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers, + body: JSON.stringify({ + packageName: '@tpmjs/nonexistent-package-12345', + name: 'nonexistentTool', + params: {}, + }), + }); + + const durationMs = Date.now() - start; + + // Skip if auth required + if (response.status === 401 && !apiKey) { + return { + name, + passed: true, + message: 'Skipped: Authentication required', + durationMs, + }; + } + + const data = (await response.json()) as ExecuteToolResponse; + + if (data.success === false && data.error?.code) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Expected error response with code for nonexistent package', + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testCORSHeaders(baseUrl: string, _apiKey?: string): Promise { + const start = Date.now(); + const name = 'CORS headers present'; + + try { + const response = await fetch(`${baseUrl}/health`, { + method: 'GET', + }); + + const durationMs = Date.now() - start; + + const allowOrigin = response.headers.get('access-control-allow-origin'); + + if (allowOrigin) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Missing Access-Control-Allow-Origin header', + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testOPTIONSPreflight(baseUrl: string, _apiKey?: string): Promise { + const start = Date.now(); + const name = 'OPTIONS preflight works'; + + try { + const response = await fetch(`${baseUrl}/execute-tool`, { + method: 'OPTIONS', + }); + + const durationMs = Date.now() - start; + + if (response.status === 200 || response.status === 204) { + const allowMethods = response.headers.get('access-control-allow-methods'); + const allowHeaders = response.headers.get('access-control-allow-headers'); + + if (allowMethods && allowHeaders) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Missing CORS preflight headers', + durationMs, + }; + } + + return { + name, + passed: false, + message: `Expected status 200 or 204, got ${response.status}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +export async function runCoreTests(baseUrl: string, apiKey?: string): Promise { + const results: TestResult[] = []; + + // Run tests sequentially to avoid overwhelming the executor + results.push(await testHealthReturns200(baseUrl, apiKey)); + results.push(await testHealthIncludesProtocolVersion(baseUrl, apiKey)); + results.push(await testHealthIncludesImplementationVersion(baseUrl, apiKey)); + results.push(await testExecuteToolAcceptsValidRequest(baseUrl, apiKey)); + results.push(await testExecuteToolReturnsStructuredResponse(baseUrl, apiKey)); + results.push(await testExecuteToolReturnsErrorForInvalidTool(baseUrl, apiKey)); + results.push(await testCORSHeaders(baseUrl, apiKey)); + results.push(await testOPTIONSPreflight(baseUrl, apiKey)); + + return { + name: 'Core Requirements', + level: 'core', + results, + }; +} diff --git a/packages/executor-test/src/tests/standard.ts b/packages/executor-test/src/tests/standard.ts new file mode 100644 index 0000000..689815c --- /dev/null +++ b/packages/executor-test/src/tests/standard.ts @@ -0,0 +1,434 @@ +import type { InfoResponse, TestResult, TestSuite } from '../types.js'; + +async function testInfoReturns200(baseUrl: string, _apiKey?: string): Promise { + const start = Date.now(); + const name = 'GET /info returns 200'; + + try { + const response = await fetch(`${baseUrl}/info`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 200) { + return { name, passed: true, durationMs }; + } + + if (response.status === 404) { + return { + name, + passed: false, + message: '/info endpoint not implemented (optional for Level 1)', + durationMs, + }; + } + + return { + name, + passed: false, + message: `Expected status 200, got ${response.status}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testInfoIncludesCapabilities( + baseUrl: string, + _apiKey?: string +): Promise { + const start = Date.now(); + const name = 'GET /info includes capabilities'; + + try { + const response = await fetch(`${baseUrl}/info`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 404) { + return { + name, + passed: false, + message: '/info endpoint not implemented', + durationMs, + }; + } + + const data = (await response.json()) as InfoResponse; + + if (!data.capabilities) { + return { + name, + passed: false, + message: 'Response missing "capabilities" field', + durationMs, + }; + } + + const required = ['isolation', 'executionModes', 'maxExecutionTimeMs', 'maxRequestBodyBytes']; + const missing = required.filter( + (key) => data.capabilities[key as keyof typeof data.capabilities] === undefined + ); + + if (missing.length > 0) { + return { + name, + passed: false, + message: `Missing capability fields: ${missing.join(', ')}`, + durationMs, + }; + } + + return { name, passed: true, durationMs }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testInfoIncludesProtocolVersion( + baseUrl: string, + _apiKey?: string +): Promise { + const start = Date.now(); + const name = 'GET /info includes protocolVersion'; + + try { + const response = await fetch(`${baseUrl}/info`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 404) { + return { + name, + passed: false, + message: '/info endpoint not implemented', + durationMs, + }; + } + + const data = (await response.json()) as InfoResponse; + + if (data.protocolVersion) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: 'Response missing "protocolVersion" field', + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testInfoIsolationLevel(baseUrl: string, _apiKey?: string): Promise { + const start = Date.now(); + const name = 'capabilities.isolation is valid'; + + try { + const response = await fetch(`${baseUrl}/info`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 404) { + return { + name, + passed: false, + message: '/info endpoint not implemented', + durationMs, + }; + } + + const data = (await response.json()) as InfoResponse; + const validLevels = ['none', 'process', 'container', 'vm']; + + if (validLevels.includes(data.capabilities?.isolation)) { + return { name, passed: true, durationMs }; + } + + return { + name, + passed: false, + message: `Invalid isolation level: ${data.capabilities?.isolation}. Expected one of: ${validLevels.join(', ')}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testAuthenticationEnforced(baseUrl: string, apiKey?: string): Promise { + const start = Date.now(); + const name = 'Authentication enforced when configured'; + + try { + // First, make a request without auth to see if it's required + const noAuthResponse = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + }, + body: JSON.stringify({ + packageName: '@anthropic-ai/sdk', + name: 'default', + params: {}, + }), + }); + + const durationMs = Date.now() - start; + + // If no auth is required, that's fine + if (noAuthResponse.status !== 401) { + return { + name, + passed: true, + message: 'No authentication required', + durationMs, + }; + } + + // If 401 without auth, verify it works with auth + if (!apiKey) { + return { + name, + passed: true, + message: 'Authentication required (provide --api-key to fully test)', + durationMs, + }; + } + + // Try with auth + const authResponse = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + packageName: '@anthropic-ai/sdk', + name: 'default', + params: {}, + }), + }); + + if (authResponse.status === 200) { + return { name, passed: true, durationMs: Date.now() - start }; + } + + return { + name, + passed: false, + message: `Request with API key still failed: ${authResponse.status}`, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testExecutionTimeoutEnforced( + baseUrl: string, + _apiKey?: string +): Promise { + const start = Date.now(); + const name = 'Execution timeout enforcement'; + + // This test just verifies the capability is advertised + // Actual timeout testing would require a tool that hangs + try { + const response = await fetch(`${baseUrl}/info`, { + method: 'GET', + headers: { + 'X-TPMJS-Protocol-Version': '1.0', + }, + }); + + const durationMs = Date.now() - start; + + if (response.status === 404) { + return { + name, + passed: false, + message: '/info endpoint not implemented (cannot verify timeout)', + durationMs, + }; + } + + const data = (await response.json()) as InfoResponse; + + if (data.capabilities?.maxExecutionTimeMs && data.capabilities.maxExecutionTimeMs >= 60000) { + return { + name, + passed: true, + message: `maxExecutionTimeMs: ${data.capabilities.maxExecutionTimeMs}ms`, + durationMs, + }; + } + + return { + name, + passed: false, + message: `maxExecutionTimeMs should be at least 60000ms, got: ${data.capabilities?.maxExecutionTimeMs}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +async function testStructuredErrorCodes(baseUrl: string, apiKey?: string): Promise { + const start = Date.now(); + const name = 'Structured error codes'; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-TPMJS-Protocol-Version': '1.0', + }; + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(`${baseUrl}/execute-tool`, { + method: 'POST', + headers, + body: JSON.stringify({ + packageName: '@tpmjs/nonexistent-package-xyz-12345', + name: 'nonexistentTool', + params: {}, + }), + }); + + const durationMs = Date.now() - start; + + // Skip if auth required + if (response.status === 401 && !apiKey) { + return { + name, + passed: true, + message: 'Skipped: Authentication required', + durationMs, + }; + } + + const data = (await response.json()) as { + success: boolean; + error?: { code: string; message: string }; + }; + + if (!data.error?.code) { + return { + name, + passed: false, + message: 'Error response missing "code" field', + durationMs, + }; + } + + const validCodes = [ + 'PACKAGE_NOT_FOUND', + 'TOOL_NOT_FOUND', + 'TOOL_INVALID', + 'TOOL_EXECUTION_ERROR', + 'EXECUTION_TIMEOUT', + 'INTERNAL_ERROR', + ]; + + if (validCodes.includes(data.error.code)) { + return { + name, + passed: true, + message: `Error code: ${data.error.code}`, + durationMs, + }; + } + + return { + name, + passed: false, + message: `Non-standard error code: ${data.error.code}`, + durationMs, + }; + } catch (error) { + return { + name, + passed: false, + message: `Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + durationMs: Date.now() - start, + }; + } +} + +export async function runStandardTests(baseUrl: string, apiKey?: string): Promise { + const results: TestResult[] = []; + + // Run tests sequentially + results.push(await testInfoReturns200(baseUrl, apiKey)); + results.push(await testInfoIncludesCapabilities(baseUrl, apiKey)); + results.push(await testInfoIncludesProtocolVersion(baseUrl, apiKey)); + results.push(await testInfoIsolationLevel(baseUrl, apiKey)); + results.push(await testAuthenticationEnforced(baseUrl, apiKey)); + results.push(await testExecutionTimeoutEnforced(baseUrl, apiKey)); + results.push(await testStructuredErrorCodes(baseUrl, apiKey)); + + return { + name: 'Standard Requirements', + level: 'standard', + results, + }; +} diff --git a/packages/executor-test/src/types.ts b/packages/executor-test/src/types.ts new file mode 100644 index 0000000..cbd703f --- /dev/null +++ b/packages/executor-test/src/types.ts @@ -0,0 +1,72 @@ +export interface TestResult { + name: string; + passed: boolean; + message?: string; + durationMs: number; +} + +export interface TestSuite { + name: string; + level: 'core' | 'standard' | 'extended'; + results: TestResult[]; +} + +export interface ComplianceResult { + target: string; + protocolVersion: string; + timestamp: string; + suites: TestSuite[]; + summary: { + totalTests: number; + passed: number; + failed: number; + coreCompliant: boolean; + standardCompliant: boolean; + }; +} + +export interface HealthResponse { + status: string; + protocolVersion: string; + implementationVersion: string; + runtime?: string; + timestamp?: string; +} + +export interface ExecuteToolRequest { + packageName: string; + version?: string; + name: string; + params?: Record; + env?: Record; +} + +export interface ExecuteToolResponse { + success: boolean; + output?: unknown; + error?: { + code: string; + message: string; + }; + executionTimeMs: number; +} + +export interface InfoResponse { + name: string; + version: string; + protocolVersion: string; + capabilities: { + isolation: 'none' | 'process' | 'container' | 'vm'; + executionModes: string[]; + maxExecutionTimeMs: number; + maxRequestBodyBytes: number; + supportsStreaming?: boolean; + supportsCallbacks?: boolean; + supportsCaching?: boolean; + }; + runtime?: { + platform?: string; + nodeVersion?: string; + region?: string; + }; +} diff --git a/packages/executor-test/tsconfig.json b/packages/executor-test/tsconfig.json new file mode 100644 index 0000000..6521d56 --- /dev/null +++ b/packages/executor-test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tpmjs/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "incremental": false, + "composite": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/executor-test/tsup.config.ts b/packages/executor-test/tsup.config.ts new file mode 100644 index 0000000..535937f --- /dev/null +++ b/packages/executor-test/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108faf7..198f9da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -607,6 +607,31 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/executor-test: + dependencies: + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@tpmjs/test': + specifier: workspace:* + version: link:../test + '@tpmjs/tsconfig': + specifier: workspace:* + version: link:../config/tsconfig + '@types/node': + specifier: ^22.15.29 + version: 22.19.5 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/mcp-client: dependencies: '@modelcontextprotocol/sdk': @@ -15590,14 +15615,14 @@ snapshots: '@remotion/media-parser': 4.0.409 '@remotion/studio': 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@remotion/studio-shared': 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - css-loader: 5.2.7(webpack@5.96.1) + css-loader: 5.2.7(webpack@5.96.1(esbuild@0.25.0)) esbuild: 0.25.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-refresh: 0.9.0 remotion: 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) source-map: 0.7.3 - style-loader: 4.0.0(webpack@5.96.1) + style-loader: 4.0.0(webpack@5.96.1(esbuild@0.25.0)) webpack: 5.96.1(esbuild@0.25.0) transitivePeerDependencies: - '@swc/core' @@ -17975,7 +18000,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@5.2.7(webpack@5.96.1): + css-loader@5.2.7(webpack@5.96.1(esbuild@0.25.0)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) loader-utils: 2.0.4 @@ -22809,7 +22834,7 @@ snapshots: stubborn-utils@1.0.2: {} - style-loader@4.0.0(webpack@5.96.1): + style-loader@4.0.0(webpack@5.96.1(esbuild@0.25.0)): dependencies: webpack: 5.96.1(esbuild@0.25.0) diff --git a/templates/railway-executor/index.js b/templates/railway-executor/index.cjs similarity index 79% rename from templates/railway-executor/index.js rename to templates/railway-executor/index.cjs index 375fc7a..b346fcc 100644 --- a/templates/railway-executor/index.js +++ b/templates/railway-executor/index.cjs @@ -16,11 +16,15 @@ const path = require('node:path'); const PORT = process.env.PORT || 3000; const API_KEY = process.env.EXECUTOR_API_KEY || null; +// Protocol constants +const PROTOCOL_VERSION = '1.0'; +const IMPLEMENTATION_VERSION = '1.0.0'; + // CORS headers for cross-origin requests const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', }; /** @@ -69,11 +73,34 @@ function parseBody(req) { function handleHealth(_req, res) { jsonResponse(res, 200, { status: 'ok', - version: '1.0.0', - info: { - runtime: 'railway', - timestamp: new Date().toISOString(), - region: process.env.RAILWAY_REGION || 'unknown', + protocolVersion: PROTOCOL_VERSION, + implementationVersion: IMPLEMENTATION_VERSION, + runtime: 'node', + timestamp: new Date().toISOString(), + }); +} + +/** + * GET /info - Capability advertisement endpoint + */ +function handleInfo(_req, res) { + jsonResponse(res, 200, { + name: 'Railway Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + capabilities: { + isolation: 'process', + executionModes: ['sync'], + maxExecutionTimeMs: 120000, + maxRequestBodyBytes: 10485760, + supportsStreaming: false, + supportsCallbacks: false, + supportsCaching: false, + }, + runtime: { + platform: process.platform, + nodeVersion: process.version, + region: process.env.RAILWAY_REGION || undefined, }, }); } @@ -220,9 +247,20 @@ function parseExecutionResult(result, startTime) { try { const errorObj = JSON.parse(result.stderr); if (errorObj.__tpmjs_error__) { + const errorMessage = errorObj.__tpmjs_error__; + // Determine error code based on message + let code = 'TOOL_EXECUTION_ERROR'; + if (errorMessage.includes('not found in package')) { + code = 'TOOL_NOT_FOUND'; + } else if (errorMessage.includes('does not have an execute()')) { + code = 'TOOL_INVALID'; + } return { success: false, - error: errorObj.__tpmjs_error__, + error: { + code, + message: errorMessage, + }, executionTimeMs: Date.now() - startTime, }; } @@ -232,7 +270,10 @@ function parseExecutionResult(result, startTime) { return { success: false, - error: result.stderr || `Script exited with code ${result.exitCode}`, + error: { + code: 'TOOL_EXECUTION_ERROR', + message: result.stderr || `Script exited with code ${result.exitCode}`, + }, executionTimeMs: Date.now() - startTime, }; } @@ -255,7 +296,6 @@ function parseExecutionResult(result, startTime) { return { success: true, output: result.stdout || null, - stderr: result.stderr || undefined, executionTimeMs: Date.now() - startTime, }; } @@ -270,8 +310,10 @@ async function handleExecuteTool(req, res) { if (!checkAuth(req)) { return jsonResponse(res, 401, { success: false, - error: 'Unauthorized', - executionTimeMs: Date.now() - startTime, + error: { + code: 'UNAUTHORIZED', + message: 'Invalid or missing API key', + }, }); } @@ -282,8 +324,10 @@ async function handleExecuteTool(req, res) { } catch (_e) { return jsonResponse(res, 400, { success: false, - error: 'Invalid JSON body', - executionTimeMs: Date.now() - startTime, + error: { + code: 'INVALID_REQUEST', + message: 'Invalid JSON body', + }, }); } @@ -293,8 +337,10 @@ async function handleExecuteTool(req, res) { if (!packageName || !name) { return jsonResponse(res, 400, { success: false, - error: 'Missing required fields: packageName, name', - executionTimeMs: Date.now() - startTime, + error: { + code: 'INVALID_REQUEST', + message: 'Missing required fields: packageName, name', + }, }); } @@ -311,10 +357,12 @@ async function handleExecuteTool(req, res) { } catch (installError) { console.error(`[executor] npm install failed:`, installError.message); cleanup(workDir); - return jsonResponse(res, 500, { + return jsonResponse(res, 200, { success: false, - error: `npm install failed: ${installError.message}`, - stderr: installError.stderr?.toString(), + error: { + code: 'PACKAGE_NOT_FOUND', + message: `npm install failed for ${packageSpec}: ${installError.message}`, + }, executionTimeMs: Date.now() - startTime, }); } @@ -338,9 +386,12 @@ async function handleExecuteTool(req, res) { return jsonResponse(res, 200, parseExecutionResult(result, startTime)); } catch (error) { cleanup(workDir); - return jsonResponse(res, 500, { + return jsonResponse(res, 200, { success: false, - error: error.message || String(error), + error: { + code: 'INTERNAL_ERROR', + message: error.message || String(error), + }, executionTimeMs: Date.now() - startTime, }); } @@ -364,6 +415,10 @@ const server = http.createServer(async (req, res) => { return handleHealth(req, res); } + if ((pathname === '/api/info' || pathname === '/info') && req.method === 'GET') { + return handleInfo(req, res); + } + if ((pathname === '/api/execute-tool' || pathname === '/execute-tool') && req.method === 'POST') { return handleExecuteTool(req, res); } @@ -371,11 +426,12 @@ const server = http.createServer(async (req, res) => { // Root path - simple info if (pathname === '/' && req.method === 'GET') { return jsonResponse(res, 200, { - name: 'TPMJS Executor', - version: '1.0.0', - runtime: 'railway', + name: 'TPMJS Railway Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, endpoints: { health: 'GET /health', + info: 'GET /info', execute: 'POST /execute-tool', }, }); diff --git a/templates/railway-executor/package.json b/templates/railway-executor/package.json index fd0c75f..324078b 100644 --- a/templates/railway-executor/package.json +++ b/templates/railway-executor/package.json @@ -3,10 +3,10 @@ "version": "1.0.0", "private": true, "description": "TPMJS Tool Executor for Railway - Deploy your own executor on Railway", - "main": "index.js", + "main": "index.cjs", "scripts": { - "start": "node index.js", - "dev": "node index.js" + "start": "node index.cjs", + "dev": "node index.cjs" }, "engines": { "node": ">=18.0.0" diff --git a/templates/unsandbox-executor/bootstrap-standalone.sh b/templates/unsandbox-executor/bootstrap-standalone.sh index 985bca3..5619d64 100644 --- a/templates/unsandbox-executor/bootstrap-standalone.sh +++ b/templates/unsandbox-executor/bootstrap-standalone.sh @@ -1,17 +1,19 @@ #!/bin/bash # TPMJS Executor Standalone Bootstrap Script for Unsandbox # This script contains the embedded executor - no network required during bootstrap +# Protocol Version: 1.0 set -e echo "=== TPMJS Executor for Unsandbox ===" +echo "Protocol Version: 1.0" echo "Starting deployment..." -# Embedded executor script -cat > /root/executor.js << 'EXECUTOR_EOF' +# Embedded executor script (v1.0 compliant) +cat > /root/executor.cjs << 'EXECUTOR_EOF' #!/usr/bin/env node /** * TPMJS Executor for Unsandbox - * API-compatible with the Vercel executor. + * Protocol Version: 1.0 */ const http = require('http'); @@ -21,25 +23,23 @@ const path = require('path'); const PORT = process.env.PORT || 80; const API_KEY = process.env.EXECUTOR_API_KEY || null; +const PROTOCOL_VERSION = '1.0'; +const IMPLEMENTATION_VERSION = '1.0.0'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', }; function jsonResponse(res, statusCode, data) { - res.writeHead(statusCode, { - 'Content-Type': 'application/json', - ...corsHeaders, - }); + res.writeHead(statusCode, { 'Content-Type': 'application/json', ...corsHeaders }); res.end(JSON.stringify(data)); } function checkAuth(req) { if (!API_KEY) return true; - const authHeader = req.headers.authorization; - return authHeader === `Bearer ${API_KEY}`; + return req.headers.authorization === `Bearer ${API_KEY}`; } function parseBody(req) { @@ -47,11 +47,8 @@ function parseBody(req) { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch (e) { - reject(new Error('Invalid JSON')); - } + try { resolve(body ? JSON.parse(body) : {}); } + catch (e) { reject(new Error('Invalid JSON')); } }); req.on('error', reject); }); @@ -60,11 +57,28 @@ function parseBody(req) { function handleHealth(req, res) { jsonResponse(res, 200, { status: 'ok', - version: '1.0.0', - info: { - runtime: 'unsandbox', - timestamp: new Date().toISOString(), + protocolVersion: PROTOCOL_VERSION, + implementationVersion: IMPLEMENTATION_VERSION, + runtime: 'node', + timestamp: new Date().toISOString(), + }); +} + +function handleInfo(req, res) { + jsonResponse(res, 200, { + name: 'Unsandbox Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + capabilities: { + isolation: 'container', + executionModes: ['sync'], + maxExecutionTimeMs: 120000, + maxRequestBodyBytes: 10485760, + supportsStreaming: false, + supportsCallbacks: false, + supportsCaching: false, }, + runtime: { platform: process.platform, nodeVersion: process.version }, }); } @@ -74,19 +88,16 @@ async function handleExecuteTool(req, res) { if (!checkAuth(req)) { return jsonResponse(res, 401, { success: false, - error: 'Unauthorized', - executionTimeMs: Date.now() - startTime, + error: { code: 'UNAUTHORIZED', message: 'Invalid or missing API key' }, }); } let body; - try { - body = await parseBody(req); - } catch (e) { + try { body = await parseBody(req); } + catch (e) { return jsonResponse(res, 400, { success: false, - error: 'Invalid JSON body', - executionTimeMs: Date.now() - startTime, + error: { code: 'INVALID_REQUEST', message: 'Invalid JSON body' }, }); } @@ -95,8 +106,7 @@ async function handleExecuteTool(req, res) { if (!packageName || !name) { return jsonResponse(res, 400, { success: false, - error: 'Missing required fields: packageName, name', - executionTimeMs: Date.now() - startTime, + error: { code: 'INVALID_REQUEST', message: 'Missing required fields: packageName, name' }, }); } @@ -105,74 +115,41 @@ async function handleExecuteTool(req, res) { try { fs.mkdirSync(workDir, { recursive: true }); - fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify({ - name: 'tpmjs-execution', - private: true, - type: 'commonjs', + name: 'tpmjs-execution', private: true, type: 'commonjs', })); console.log(`[executor] Installing ${packageSpec}...`); - const installStart = Date.now(); - try { execSync(`npm install --no-save --omit=dev --no-audit --no-fund ${packageSpec}`, { - cwd: workDir, - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 60000, + cwd: workDir, stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000, }); } catch (installError) { - console.error(`[executor] npm install failed:`, installError.message); - return jsonResponse(res, 500, { + return jsonResponse(res, 200, { success: false, - error: `npm install failed: ${installError.message}`, - stderr: installError.stderr?.toString(), + error: { code: 'PACKAGE_NOT_FOUND', message: `npm install failed: ${installError.message}` }, executionTimeMs: Date.now() - startTime, }); } - console.log(`[executor] npm install completed in ${Date.now() - installStart}ms`); - const envSetup = env - ? Object.entries(env) - .map(([key, value]) => `process.env[${JSON.stringify(key)}] = ${JSON.stringify(value)};`) - .join('\n') + ? Object.entries(env).map(([k, v]) => `process.env[${JSON.stringify(k)}] = ${JSON.stringify(v)};`).join('\n') : ''; const script = ` ${envSetup} - (async () => { try { const pkg = require(${JSON.stringify(packageName)}); let tool = pkg[${JSON.stringify(name)}] || pkg.default?.[${JSON.stringify(name)}] || pkg.default; - - if (!tool) { - throw new Error(\`Tool "${name}" not found in package "${packageName}"\`); - } - + if (!tool) throw new Error(\`Tool "${name}" not found in package "${packageName}"\`); if (typeof tool === 'function' && !tool.execute) { - const envVars = ${env ? JSON.stringify(env) : 'null'}; - try { - const result = tool(); - if (result && typeof result.execute === 'function') { - tool = result; - } - } catch {} - if (typeof tool === 'function' && envVars) { - try { - const result = tool(envVars); - if (result && typeof result.execute === 'function') { - tool = result; - } - } catch {} + try { const r = tool(); if (r?.execute) tool = r; } catch {} + if (typeof tool === 'function' && ${env ? JSON.stringify(env) : 'null'}) { + try { const r = tool(${env ? JSON.stringify(env) : 'null'}); if (r?.execute) tool = r; } catch {} } } - - if (!tool || typeof tool.execute !== 'function') { - throw new Error(\`Tool "${name}" does not have an execute() function\`); - } - + if (!tool?.execute) throw new Error(\`Tool "${name}" does not have an execute() function\`); const result = await tool.execute(${JSON.stringify(params)}); process.stdout.write(JSON.stringify({ __tpmjs_result__: result })); } catch (err) { @@ -184,52 +161,36 @@ ${envSetup} fs.writeFileSync(path.join(workDir, 'execute.cjs'), script); - console.log(`[executor] Running tool ${packageName}/${name}...`); - const runStart = Date.now(); - const result = await new Promise((resolve) => { const child = spawn('node', ['execute.cjs'], { - cwd: workDir, - env: { ...process.env, ...env }, - timeout: 120000, - }); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', (data) => stdout += data); - child.stderr.on('data', (data) => stderr += data); - - child.on('close', (code) => { - resolve({ exitCode: code, stdout, stderr }); - }); - - child.on('error', (err) => { - resolve({ exitCode: 1, stdout: '', stderr: err.message }); + cwd: workDir, env: { ...process.env, ...env }, timeout: 120000, }); + let stdout = '', stderr = ''; + child.stdout.on('data', d => stdout += d); + child.stderr.on('data', d => stderr += d); + child.on('close', code => resolve({ exitCode: code, stdout, stderr })); + child.on('error', err => resolve({ exitCode: 1, stdout: '', stderr: err.message })); }); - console.log(`[executor] Tool execution completed in ${Date.now() - runStart}ms (exit: ${result.exitCode})`); - - try { - fs.rmSync(workDir, { recursive: true, force: true }); - } catch {} + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} if (result.exitCode !== 0) { try { const errorObj = JSON.parse(result.stderr); if (errorObj.__tpmjs_error__) { + let code = 'TOOL_EXECUTION_ERROR'; + if (errorObj.__tpmjs_error__.includes('not found in package')) code = 'TOOL_NOT_FOUND'; + else if (errorObj.__tpmjs_error__.includes('does not have an execute()')) code = 'TOOL_INVALID'; return jsonResponse(res, 200, { success: false, - error: errorObj.__tpmjs_error__, + error: { code, message: errorObj.__tpmjs_error__ }, executionTimeMs: Date.now() - startTime, }); } } catch {} - return jsonResponse(res, 200, { success: false, - error: result.stderr || `Script exited with code ${result.exitCode}`, + error: { code: 'TOOL_EXECUTION_ERROR', message: result.stderr || `Exit code ${result.exitCode}` }, executionTimeMs: Date.now() - startTime, }); } @@ -238,60 +199,55 @@ ${envSetup} const parsed = JSON.parse(result.stdout); if (parsed.__tpmjs_result__ !== undefined) { return jsonResponse(res, 200, { - success: true, - output: parsed.__tpmjs_result__, - executionTimeMs: Date.now() - startTime, + success: true, output: parsed.__tpmjs_result__, executionTimeMs: Date.now() - startTime, }); } } catch {} return jsonResponse(res, 200, { - success: true, - output: result.stdout || null, - stderr: result.stderr || undefined, - executionTimeMs: Date.now() - startTime, + success: true, output: result.stdout || null, executionTimeMs: Date.now() - startTime, }); } catch (error) { - try { - fs.rmSync(workDir, { recursive: true, force: true }); - } catch {} - - return jsonResponse(res, 500, { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} + return jsonResponse(res, 200, { success: false, - error: error.message || String(error), + error: { code: 'INTERNAL_ERROR', message: error.message || String(error) }, executionTimeMs: Date.now() - startTime, }); } } const server = http.createServer(async (req, res) => { - const url = new URL(req.url, `http://localhost:${PORT}`); - const pathname = url.pathname; + const pathname = new URL(req.url, `http://localhost:${PORT}`).pathname; if (req.method === 'OPTIONS') { res.writeHead(200, corsHeaders); return res.end(); } - if ((pathname === '/api/health' || pathname === '/health') && req.method === 'GET') { - return handleHealth(req, res); - } + if ((pathname === '/health' || pathname === '/api/health') && req.method === 'GET') return handleHealth(req, res); + if ((pathname === '/info' || pathname === '/api/info') && req.method === 'GET') return handleInfo(req, res); + if ((pathname === '/execute-tool' || pathname === '/api/execute-tool') && req.method === 'POST') return handleExecuteTool(req, res); - if ((pathname === '/api/execute-tool' || pathname === '/execute-tool') && req.method === 'POST') { - return handleExecuteTool(req, res); + if (pathname === '/' && req.method === 'GET') { + return jsonResponse(res, 200, { + name: 'TPMJS Unsandbox Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + endpoints: { health: 'GET /health', info: 'GET /info', execute: 'POST /execute-tool' }, + }); } jsonResponse(res, 404, { error: 'Not found' }); }); server.listen(PORT, () => { - console.log(`TPMJS Executor running on port ${PORT}`); - if (API_KEY) { - console.log(`Authentication: Required`); - } + console.log(`TPMJS Executor v${IMPLEMENTATION_VERSION} (Protocol ${PROTOCOL_VERSION})`); + console.log(`Listening on port ${PORT}`); + console.log(`Authentication: ${API_KEY ? 'Required' : 'None'}`); }); EXECUTOR_EOF echo "Starting TPMJS Executor on port 80..." -exec node /root/executor.js +exec node /root/executor.cjs diff --git a/templates/unsandbox-executor/bootstrap.sh b/templates/unsandbox-executor/bootstrap.sh index 796962f..4e9bde5 100644 --- a/templates/unsandbox-executor/bootstrap.sh +++ b/templates/unsandbox-executor/bootstrap.sh @@ -7,10 +7,10 @@ echo "=== TPMJS Executor for Unsandbox ===" echo "Starting deployment..." # Download the executor script from GitHub -EXECUTOR_URL="https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/executor.js" +EXECUTOR_URL="https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/executor.cjs" echo "Downloading executor from $EXECUTOR_URL..." -curl -fsSL "$EXECUTOR_URL" -o /root/executor.js +curl -fsSL "$EXECUTOR_URL" -o /root/executor.cjs echo "Starting TPMJS Executor on port 80..." -exec node /root/executor.js +exec node /root/executor.cjs diff --git a/templates/unsandbox-executor/executor.js b/templates/unsandbox-executor/executor.cjs similarity index 76% rename from templates/unsandbox-executor/executor.js rename to templates/unsandbox-executor/executor.cjs index e65176d..c31d8a9 100644 --- a/templates/unsandbox-executor/executor.js +++ b/templates/unsandbox-executor/executor.cjs @@ -16,11 +16,15 @@ const path = require('path'); const PORT = process.env.PORT || 80; const API_KEY = process.env.EXECUTOR_API_KEY || null; +// Protocol constants +const PROTOCOL_VERSION = '1.0'; +const IMPLEMENTATION_VERSION = '1.0.0'; + // CORS headers for cross-origin requests const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', }; /** @@ -62,15 +66,38 @@ function parseBody(req) { } /** - * GET /api/health - Health check endpoint + * GET /health - Health check endpoint */ function handleHealth(req, res) { jsonResponse(res, 200, { status: 'ok', - version: '1.0.0', - info: { - runtime: 'unsandbox', - timestamp: new Date().toISOString(), + protocolVersion: PROTOCOL_VERSION, + implementationVersion: IMPLEMENTATION_VERSION, + runtime: 'node', + timestamp: new Date().toISOString(), + }); +} + +/** + * GET /info - Capability advertisement endpoint + */ +function handleInfo(req, res) { + jsonResponse(res, 200, { + name: 'Unsandbox Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + capabilities: { + isolation: 'container', + executionModes: ['sync'], + maxExecutionTimeMs: 120000, + maxRequestBodyBytes: 10485760, + supportsStreaming: false, + supportsCallbacks: false, + supportsCaching: false, + }, + runtime: { + platform: process.platform, + nodeVersion: process.version, }, }); } @@ -94,8 +121,10 @@ async function handleExecuteTool(req, res) { if (!checkAuth(req)) { return jsonResponse(res, 401, { success: false, - error: 'Unauthorized', - executionTimeMs: Date.now() - startTime, + error: { + code: 'UNAUTHORIZED', + message: 'Invalid or missing API key', + }, }); } @@ -106,8 +135,10 @@ async function handleExecuteTool(req, res) { } catch (e) { return jsonResponse(res, 400, { success: false, - error: 'Invalid JSON body', - executionTimeMs: Date.now() - startTime, + error: { + code: 'INVALID_REQUEST', + message: 'Invalid JSON body', + }, }); } @@ -117,8 +148,10 @@ async function handleExecuteTool(req, res) { if (!packageName || !name) { return jsonResponse(res, 400, { success: false, - error: 'Missing required fields: packageName, name', - executionTimeMs: Date.now() - startTime, + error: { + code: 'INVALID_REQUEST', + message: 'Missing required fields: packageName, name', + }, }); } @@ -151,10 +184,12 @@ async function handleExecuteTool(req, res) { }); } catch (installError) { console.error(`[executor] npm install failed:`, installError.message); - return jsonResponse(res, 500, { + return jsonResponse(res, 200, { success: false, - error: `npm install failed: ${installError.message}`, - stderr: installError.stderr?.toString(), + error: { + code: 'PACKAGE_NOT_FOUND', + message: `npm install failed for ${packageSpec}: ${installError.message}`, + }, executionTimeMs: Date.now() - startTime, }); } @@ -264,9 +299,20 @@ ${envSetup} try { const errorObj = JSON.parse(result.stderr); if (errorObj.__tpmjs_error__) { + const errorMessage = errorObj.__tpmjs_error__; + // Determine error code based on message + let code = 'TOOL_EXECUTION_ERROR'; + if (errorMessage.includes('not found in package')) { + code = 'TOOL_NOT_FOUND'; + } else if (errorMessage.includes('does not have an execute()')) { + code = 'TOOL_INVALID'; + } return jsonResponse(res, 200, { success: false, - error: errorObj.__tpmjs_error__, + error: { + code, + message: errorMessage, + }, executionTimeMs: Date.now() - startTime, }); } @@ -274,7 +320,10 @@ ${envSetup} return jsonResponse(res, 200, { success: false, - error: result.stderr || `Script exited with code ${result.exitCode}`, + error: { + code: 'TOOL_EXECUTION_ERROR', + message: result.stderr || `Script exited with code ${result.exitCode}`, + }, executionTimeMs: Date.now() - startTime, }); } @@ -295,7 +344,6 @@ ${envSetup} return jsonResponse(res, 200, { success: true, output: result.stdout || null, - stderr: result.stderr || undefined, executionTimeMs: Date.now() - startTime, }); } catch (error) { @@ -304,9 +352,12 @@ ${envSetup} fs.rmSync(workDir, { recursive: true, force: true }); } catch {} - return jsonResponse(res, 500, { + return jsonResponse(res, 200, { success: false, - error: error.message || String(error), + error: { + code: 'INTERNAL_ERROR', + message: error.message || String(error), + }, executionTimeMs: Date.now() - startTime, }); } @@ -330,10 +381,28 @@ const server = http.createServer(async (req, res) => { return handleHealth(req, res); } + if ((pathname === '/api/info' || pathname === '/info') && req.method === 'GET') { + return handleInfo(req, res); + } + if ((pathname === '/api/execute-tool' || pathname === '/execute-tool') && req.method === 'POST') { return handleExecuteTool(req, res); } + // Root path - simple info + if (pathname === '/' && req.method === 'GET') { + return jsonResponse(res, 200, { + name: 'TPMJS Unsandbox Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + endpoints: { + health: 'GET /health', + info: 'GET /info', + execute: 'POST /execute-tool', + }, + }); + } + // 404 for unknown routes jsonResponse(res, 404, { error: 'Not found' }); }); diff --git a/templates/vercel-executor/app/api/execute-tool/route.ts b/templates/vercel-executor/app/api/execute-tool/route.ts index efc5a47..201b872 100644 --- a/templates/vercel-executor/app/api/execute-tool/route.ts +++ b/templates/vercel-executor/app/api/execute-tool/route.ts @@ -20,14 +20,29 @@ interface ExecuteToolRequest { env?: Record; } -interface ExecuteToolResponse { - success: boolean; - output?: unknown; - error?: string; - stderr?: string; +interface ExecuteToolSuccessResponse { + success: true; + output: unknown; executionTimeMs: number; } +interface ExecuteToolErrorResponse { + success: false; + error: { + code: string; + message: string; + }; + executionTimeMs: number; +} + +type ExecuteToolResponse = ExecuteToolSuccessResponse | ExecuteToolErrorResponse; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', +}; + export async function POST(req: NextRequest): Promise> { const startTime = Date.now(); @@ -37,8 +52,15 @@ export async function POST(req: NextRequest): Promise { return new NextResponse(null, { status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, + headers: corsHeaders, }); } diff --git a/templates/vercel-executor/app/api/health/route.ts b/templates/vercel-executor/app/api/health/route.ts index 4684001..e528e75 100644 --- a/templates/vercel-executor/app/api/health/route.ts +++ b/templates/vercel-executor/app/api/health/route.ts @@ -10,22 +10,34 @@ import { NextResponse } from 'next/server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const PROTOCOL_VERSION = '1.0'; +const IMPLEMENTATION_VERSION = '1.0.0'; + interface HealthResponse { - status: 'ok' | 'degraded' | 'error'; - version?: string; - info?: Record; + status: 'ok'; + protocolVersion: string; + implementationVersion: string; + runtime?: string; + timestamp?: string; } export async function GET(): Promise> { - return NextResponse.json({ - status: 'ok', - version: '1.0.0', - info: { - runtime: 'vercel-sandbox', - region: 'iad1', + return NextResponse.json( + { + status: 'ok', + protocolVersion: PROTOCOL_VERSION, + implementationVersion: IMPLEMENTATION_VERSION, + runtime: 'node', timestamp: new Date().toISOString(), }, - }); + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', + }, + } + ); } // Handle OPTIONS for CORS preflight @@ -35,7 +47,7 @@ export async function OPTIONS(): Promise { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', }, }); } diff --git a/templates/vercel-executor/app/api/info/route.ts b/templates/vercel-executor/app/api/info/route.ts new file mode 100644 index 0000000..eb952ba --- /dev/null +++ b/templates/vercel-executor/app/api/info/route.ts @@ -0,0 +1,77 @@ +/** + * Info Endpoint - Capability Advertisement + * + * GET /api/info + * Returns executor capabilities for intelligent routing + */ + +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const PROTOCOL_VERSION = '1.0'; +const IMPLEMENTATION_VERSION = '1.0.0'; + +interface InfoResponse { + name: string; + version: string; + protocolVersion: string; + capabilities: { + isolation: 'none' | 'process' | 'container' | 'vm'; + executionModes: string[]; + maxExecutionTimeMs: number; + maxRequestBodyBytes: number; + supportsStreaming: boolean; + supportsCallbacks: boolean; + supportsCaching: boolean; + }; + runtime?: { + platform?: string; + nodeVersion?: string; + region?: string; + }; +} + +export async function GET(): Promise> { + return NextResponse.json( + { + name: 'Vercel Sandbox Executor', + version: IMPLEMENTATION_VERSION, + protocolVersion: PROTOCOL_VERSION, + capabilities: { + isolation: 'vm', + executionModes: ['sync'], + maxExecutionTimeMs: 120000, + maxRequestBodyBytes: 10485760, + supportsStreaming: false, + supportsCallbacks: false, + supportsCaching: false, + }, + runtime: { + platform: 'linux', + nodeVersion: '22.x', + region: process.env.VERCEL_REGION || undefined, + }, + }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', + }, + } + ); +} + +// Handle OPTIONS for CORS preflight +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version', + }, + }); +} diff --git a/templates/vercel-executor/app/health/route.ts b/templates/vercel-executor/app/health/route.ts index 7342548..b29d525 100644 --- a/templates/vercel-executor/app/health/route.ts +++ b/templates/vercel-executor/app/health/route.ts @@ -3,31 +3,10 @@ * * GET /health * TPMJS expects health at /health, not /api/health + * This re-exports from the api version for backwards compatibility */ -import { NextResponse } from 'next/server'; +export { GET, OPTIONS } from '../api/health/route'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; - -interface HealthResponse { - status: 'ok' | 'degraded' | 'error'; - version: string; - info?: { - runtime?: string; - region?: string; - timestamp?: string; - }; -} - -export async function GET(): Promise> { - return NextResponse.json({ - status: 'ok', - version: '1.0.0', - info: { - runtime: 'vercel-sandbox', - region: process.env.VERCEL_REGION || 'unknown', - timestamp: new Date().toISOString(), - }, - }); -} diff --git a/templates/vercel-executor/app/info/route.ts b/templates/vercel-executor/app/info/route.ts new file mode 100644 index 0000000..25b0098 --- /dev/null +++ b/templates/vercel-executor/app/info/route.ts @@ -0,0 +1,12 @@ +/** + * Info Endpoint (root path) + * + * GET /info + * TPMJS expects info at /info, not /api/info + * This re-exports from the api version for backwards compatibility + */ + +export { GET, OPTIONS } from '../api/info/route'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic';