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