refactor(executor): use Vercel Sandbox SDK for isolated VM execution
- Replace community Deno runtime with @vercel/sandbox SDK - Next.js API routes create ephemeral sandbox VMs per execution - Each tool execution: create VM → npm install → run → cleanup - node22 runtime in sandbox handles npm packages natively - Region pinned to iad1 (only region with Sandbox support) - Proper authentication support via EXECUTOR_API_KEY This provides true isolation - each tool runs in its own VM that's destroyed after execution. More secure than shared serverless.
This commit is contained in:
parent
300083aff9
commit
846676ba15
10 changed files with 385 additions and 302 deletions
256
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
256
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Tool Execution Endpoint using Vercel Sandbox
|
||||
*
|
||||
* POST /api/execute-tool
|
||||
* Execute a TPMJS tool in an isolated Vercel Sandbox VM
|
||||
*/
|
||||
|
||||
import { Sandbox } from '@vercel/sandbox';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes max
|
||||
|
||||
interface ExecuteToolRequest {
|
||||
packageName: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
params: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ExecuteToolResponse {
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolResponse>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check authorization if EXECUTOR_API_KEY is set
|
||||
const apiKey = process.env.EXECUTOR_API_KEY;
|
||||
if (apiKey) {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized', executionTimeMs: Date.now() - startTime },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox: Sandbox | null = null;
|
||||
|
||||
try {
|
||||
const body: ExecuteToolRequest = await req.json();
|
||||
const { packageName, name, version = 'latest', params, env } = body;
|
||||
|
||||
if (!packageName || !name) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, name',
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const packageSpec = `${packageName}@${version}`;
|
||||
|
||||
// Create sandbox with node22 runtime
|
||||
sandbox = await Sandbox.create({
|
||||
runtime: 'node22',
|
||||
timeout: 2 * 60 * 1000, // 2 minute timeout for the sandbox
|
||||
});
|
||||
|
||||
// Install the npm package
|
||||
await sandbox.runCommand({
|
||||
cmd: 'npm',
|
||||
args: ['install', '--no-save', packageSpec],
|
||||
cwd: '/vercel/sandbox',
|
||||
});
|
||||
|
||||
// Build environment setup code
|
||||
const envSetup = env
|
||||
? Object.entries(env)
|
||||
.map(([key, value]) => `process.env[${JSON.stringify(key)}] = ${JSON.stringify(value)};`)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
// Create the execution script
|
||||
const script = `
|
||||
${envSetup}
|
||||
|
||||
const pkg = require('${packageName}');
|
||||
|
||||
// Get the tool export
|
||||
let tool = pkg['${name}'] || pkg.default?.['${name}'] || pkg.default;
|
||||
|
||||
if (!tool) {
|
||||
console.log(JSON.stringify({ __tpmjs_error__: 'Tool "${name}" not found in package "${packageName}"' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Handle factory functions (tools that need to be called to create the tool instance)
|
||||
async function resolveFactory(rawTool, envVars) {
|
||||
if (typeof rawTool !== 'function' || rawTool.execute) {
|
||||
return rawTool;
|
||||
}
|
||||
|
||||
// Strategy 1: Try calling with no arguments
|
||||
try {
|
||||
const result = rawTool();
|
||||
if (result && typeof result.execute === 'function') {
|
||||
return result;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Strategy 2: Try with env config object
|
||||
if (envVars) {
|
||||
const configVariations = [
|
||||
envVars,
|
||||
// Extract API key if present
|
||||
(() => {
|
||||
const entry = Object.entries(envVars).find(([k]) => k.toUpperCase().includes('API_KEY'));
|
||||
return entry ? { apiKey: entry[1] } : null;
|
||||
})(),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const config of configVariations) {
|
||||
try {
|
||||
const result = rawTool(config);
|
||||
if (result && typeof result.execute === 'function') {
|
||||
return result;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return rawTool;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const envVars = ${env ? JSON.stringify(env) : 'null'};
|
||||
const resolvedTool = await resolveFactory(tool, envVars);
|
||||
|
||||
if (!resolvedTool || typeof resolvedTool.execute !== 'function') {
|
||||
console.log(JSON.stringify({ __tpmjs_error__: 'Tool "${name}" does not have an execute() function' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const params = ${JSON.stringify(params)};
|
||||
const result = await resolvedTool.execute(params);
|
||||
|
||||
console.log(JSON.stringify({ __tpmjs_result__: result }));
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ __tpmjs_error__: err.message || String(err) }));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
// Write the script to a file
|
||||
await sandbox.writeFiles([
|
||||
{ path: '/vercel/sandbox/execute.cjs', content: Buffer.from(script) },
|
||||
]);
|
||||
|
||||
// Run the script and capture output
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
await sandbox.runCommand({
|
||||
cmd: 'node',
|
||||
args: ['execute.cjs'],
|
||||
cwd: '/vercel/sandbox',
|
||||
stdout: {
|
||||
write(chunk: Buffer | string) {
|
||||
stdout += chunk.toString();
|
||||
return true;
|
||||
},
|
||||
} as NodeJS.WritableStream,
|
||||
stderr: {
|
||||
write(chunk: Buffer | string) {
|
||||
stderr += chunk.toString();
|
||||
return true;
|
||||
},
|
||||
} as NodeJS.WritableStream,
|
||||
});
|
||||
|
||||
// Parse the output to extract the result
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes('__tpmjs_result__')) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: parsed.__tpmjs_result__,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
if (line.includes('__tpmjs_error__')) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: parsed.__tpmjs_error__,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find structured output, check stderr
|
||||
if (stderr) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: stderr.trim(),
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Return raw stdout if no structured result
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: stdout.trim() || null,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
} finally {
|
||||
// Always stop the sandbox
|
||||
if (sandbox) {
|
||||
try {
|
||||
await sandbox.stop();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
41
templates/vercel-executor/app/api/health/route.ts
Normal file
41
templates/vercel-executor/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
* GET /api/health
|
||||
* Returns the health status of the executor
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
version?: string;
|
||||
info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
info: {
|
||||
runtime: 'vercel-sandbox',
|
||||
region: 'iad1',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
12
templates/vercel-executor/app/layout.tsx
Normal file
12
templates/vercel-executor/app/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const metadata = {
|
||||
title: 'TPMJS Executor',
|
||||
description: 'TPMJS Tool Executor using Vercel Sandbox',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
31
templates/vercel-executor/app/page.tsx
Normal file
31
templates/vercel-executor/app/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export default function Home() {
|
||||
return (
|
||||
<main style={{ fontFamily: 'system-ui', padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h1>TPMJS Executor</h1>
|
||||
<p>This is a custom TPMJS tool executor using Vercel Sandbox.</p>
|
||||
|
||||
<h2>Endpoints</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<code>GET /api/health</code> - Health check
|
||||
</li>
|
||||
<li>
|
||||
<code>POST /api/execute-tool</code> - Execute a tool
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
<p>
|
||||
See the{' '}
|
||||
<a
|
||||
href="https://tpmjs.com/docs/tutorials/custom-executor"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Custom Executor Tutorial
|
||||
</a>{' '}
|
||||
for setup instructions.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue