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:
Ajax Davis 2026-02-04 02:07:46 +10:00
parent 760cc4b77e
commit 32c6e097ed
24 changed files with 3084 additions and 258 deletions

View file

@ -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',
error: {
code: 'INVALID_REQUEST',
message: 'Missing required fields: packageName, name',
},
executionTimeMs: Date.now() - startTime,
},
{ status: 400 }
} 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({
success: false,
error: `npm install failed with exit code ${install.exitCode}`,
stderr: installStderr || installStdout,
executionTimeMs: Date.now() - startTime,
});
return NextResponse.json(
{
success: false,
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({
success: false,
error: errorObj.__tpmjs_error__,
executionTimeMs: Date.now() - startTime,
});
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: {
code,
message: errorMessage,
},
executionTimeMs: Date.now() - startTime,
} as ExecuteToolErrorResponse,
{ headers: corsHeaders }
);
}
} catch {}
return NextResponse.json({
success: false,
error: stderr || `Script exited with code ${run.exitCode}`,
executionTimeMs: Date.now() - startTime,
});
return NextResponse.json(
{
success: false,
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({
success: true,
output: parsed.__tpmjs_result__,
executionTimeMs: Date.now() - startTime,
});
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({
success: true,
output: stdout || null,
stderr: stderr || undefined,
executionTimeMs: Date.now() - startTime,
});
return NextResponse.json(
{
success: true,
output: stdout || null,
executionTimeMs: Date.now() - startTime,
} as ExecuteToolSuccessResponse,
{ headers: corsHeaders }
);
} catch (error) {
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : String(error),
executionTimeMs: Date.now() - startTime,
});
return NextResponse.json(
{
success: false,
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,
});
}

View file

@ -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({
status: 'ok',
version: '1.0.0',
info: {
runtime: 'vercel-sandbox',
region: 'iad1',
return NextResponse.json(
{
status: 'ok',
protocolVersion: PROTOCOL_VERSION,
implementationVersion: IMPLEMENTATION_VERSION,
runtime: 'node',
timestamp: new Date().toISOString(),
},
});
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-TPMJS-Protocol-Version',
},
}
);
}
// Handle OPTIONS for CORS preflight
@ -35,7 +47,7 @@ export async function OPTIONS(): Promise<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',
},
});
}

View 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',
},
});
}

View file

@ -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(),
},
});
}

View 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';