feat(executor): formalize Executor Protocol v1.0 with compliance testing
- Add EXECUTOR_SPECIFICATION.md with formal v1.0 protocol spec - Add executor-openapi.yaml (OpenAPI 3.0 specification) - Create @tpmjs/executor-test compliance test package (15 tests) - Update Railway executor to v1.0 compliance (15/15 tests pass) - Update Unsandbox executor to v1.0 compliance (15/15 tests pass) - Update Vercel executor to v1.0 compliance - Add /info endpoint with capability advertisement to all executors - Add structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.) - Add protocolVersion and implementationVersion to /health responses - Add X-TPMJS-Protocol-Version header support - Add EXECUTOR_COMPLIANCE.md with test results documentation
This commit is contained in:
parent
760cc4b77e
commit
32c6e097ed
24 changed files with 3084 additions and 258 deletions
269
EXECUTOR_COMPLIANCE.md
Normal file
269
EXECUTOR_COMPLIANCE.md
Normal file
|
|
@ -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
|
||||
496
EXECUTOR_SPECIFICATION.md
Normal file
496
EXECUTOR_SPECIFICATION.md
Normal file
|
|
@ -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 <api-key> (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 <api-key>
|
||||
```
|
||||
|
||||
**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 <package>@<version>`
|
||||
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.
|
||||
591
executor-openapi.yaml
Normal file
591
executor-openapi.yaml
Normal file
|
|
@ -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 <package>@<version>`)
|
||||
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
|
||||
7
packages/executor-test/bin/run.js
Normal file
7
packages/executor-test/bin/run.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
58
packages/executor-test/package.json
Normal file
58
packages/executor-test/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
4
packages/executor-test/src/index.ts
Normal file
4
packages/executor-test/src/index.ts
Normal file
|
|
@ -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';
|
||||
14
packages/executor-test/src/runner.test.ts
Normal file
14
packages/executor-test/src/runner.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
201
packages/executor-test/src/runner.ts
Normal file
201
packages/executor-test/src/runner.ts
Normal file
|
|
@ -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 <executor-url> [options]
|
||||
|
||||
${pc.dim('Options:')}
|
||||
--api-key <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<void> {
|
||||
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);
|
||||
}
|
||||
417
packages/executor-test/src/tests/core.ts
Normal file
417
packages/executor-test/src/tests/core.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import type { ExecuteToolResponse, HealthResponse, TestResult, TestSuite } from '../types.js';
|
||||
|
||||
async function testHealthReturns200(baseUrl: string, _apiKey?: string): Promise<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
const start = Date.now();
|
||||
const name = 'POST /execute-tool accepts valid request';
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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<TestResult> {
|
||||
const start = Date.now();
|
||||
const name = 'POST /execute-tool returns structured response';
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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<TestResult> {
|
||||
const start = Date.now();
|
||||
const name = 'POST /execute-tool returns error for invalid tool';
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestSuite> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
434
packages/executor-test/src/tests/standard.ts
Normal file
434
packages/executor-test/src/tests/standard.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import type { InfoResponse, TestResult, TestSuite } from '../types.js';
|
||||
|
||||
async function testInfoReturns200(baseUrl: string, _apiKey?: string): Promise<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
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<TestResult> {
|
||||
const start = Date.now();
|
||||
const name = 'Structured error codes';
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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<TestSuite> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
72
packages/executor-test/src/types.ts
Normal file
72
packages/executor-test/src/types.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
11
packages/executor-test/tsconfig.json
Normal file
11
packages/executor-test/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "@tpmjs/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"incremental": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
packages/executor-test/tsup.config.ts
Normal file
9
packages/executor-test/tsup.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
implementationVersion: IMPLEMENTATION_VERSION,
|
||||
runtime: 'node',
|
||||
timestamp: new Date().toISOString(),
|
||||
region: process.env.RAILWAY_REGION || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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,
|
||||
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 }));
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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' });
|
||||
});
|
||||
|
|
@ -20,14 +20,29 @@ interface ExecuteToolRequest {
|
|||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<NextResponse<ExecuteToolResponse>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -37,8 +52,15 @@ export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolRe
|
|||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized', executionTimeMs: Date.now() - startTime },
|
||||
{ status: 401 }
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or missing API key',
|
||||
},
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ status: 401, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,10 +75,13 @@ export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolRe
|
|||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, name',
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
error: {
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'Missing required fields: packageName, name',
|
||||
},
|
||||
{ status: 400 }
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ status: 400, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -92,12 +117,17 @@ export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolRe
|
|||
stdout: installStdout?.slice(0, 500),
|
||||
stderr: installStderr?.slice(0, 500),
|
||||
});
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `npm install failed with exit code ${install.exitCode}`,
|
||||
stderr: installStderr || installStdout,
|
||||
error: {
|
||||
code: 'PACKAGE_NOT_FOUND',
|
||||
message: `npm install failed for ${packageSpec}: ${installStderr || installStdout}`,
|
||||
},
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Build environment setup for the script
|
||||
|
|
@ -188,46 +218,77 @@ ${envSetup}
|
|||
try {
|
||||
const errorObj = JSON.parse(stderr);
|
||||
if (errorObj.__tpmjs_error__) {
|
||||
return NextResponse.json({
|
||||
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 NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorObj.__tpmjs_error__,
|
||||
error: {
|
||||
code,
|
||||
message: errorMessage,
|
||||
},
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: stderr || `Script exited with code ${run.exitCode}`,
|
||||
error: {
|
||||
code: 'TOOL_EXECUTION_ERROR',
|
||||
message: stderr || `Script exited with code ${run.exitCode}`,
|
||||
},
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
// 5) Parse the result
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
if (parsed.__tpmjs_result__ !== undefined) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: parsed.__tpmjs_result__,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolSuccessResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// If we couldn't parse structured output, return raw
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: stdout || null,
|
||||
stderr: stderr || undefined,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolSuccessResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} as ExecuteToolErrorResponse,
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
} finally {
|
||||
if (sandbox) {
|
||||
try {
|
||||
|
|
@ -243,10 +304,6 @@ ${envSetup}
|
|||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
status: 'ok';
|
||||
protocolVersion: string;
|
||||
implementationVersion: string;
|
||||
runtime?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
info: {
|
||||
runtime: 'vercel-sandbox',
|
||||
region: 'iad1',
|
||||
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<NextResponse> {
|
|||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
77
templates/vercel-executor/app/api/info/route.ts
Normal file
77
templates/vercel-executor/app/api/info/route.ts
Normal file
|
|
@ -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<NextResponse<InfoResponse>> {
|
||||
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<NextResponse> {
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<NextResponse<HealthResponse>> {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
info: {
|
||||
runtime: 'vercel-sandbox',
|
||||
region: process.env.VERCEL_REGION || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
12
templates/vercel-executor/app/info/route.ts
Normal file
12
templates/vercel-executor/app/info/route.ts
Normal file
|
|
@ -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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue