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:
Ajax Davis 2026-01-09 01:35:22 +10:00
parent e5d7fa2b10
commit 44ea5bd9af
10 changed files with 385 additions and 302 deletions

View file

@ -1,10 +1,10 @@
# TPMJS Executor for Vercel
Deploy your own TPMJS tool executor using the **Deno runtime** on Vercel for secure, isolated code execution.
Deploy your own TPMJS tool executor using **Vercel Sandbox** for secure, isolated code execution.
## Features
- **Native HTTP Imports**: Deno natively supports importing from esm.sh
- **Secure Execution**: Tools run in isolated Vercel Sandbox VMs
- **Full Control**: Your infrastructure, your environment variables
- **Privacy**: No data passes through TPMJS servers
- **One-Click Deploy**: Deploy to Vercel in minutes
@ -15,14 +15,14 @@ Deploy your own TPMJS tool executor using the **Deno runtime** on Vercel for sec
## How It Works
This executor uses the [Vercel Deno Runtime](https://github.com/vercel-community/deno) to:
This executor uses [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) to:
1. Receive tool execution requests via POST `/api/execute-tool`
2. Dynamically import the npm package from esm.sh (Deno natively supports HTTP imports!)
1. Create an isolated VM for each tool execution
2. Install the npm package in the sandbox
3. Execute the tool with your parameters
4. Return the result
4. Return the result and destroy the sandbox
This provides the same execution model as the Railway executor, but deployed to your own Vercel account.
This provides secure, isolated execution without the limitations of Node.js serverless functions.
## API Endpoints
@ -40,8 +40,8 @@ curl https://your-executor.vercel.app/api/health
"status": "ok",
"version": "1.0.0",
"info": {
"runtime": "deno",
"httpImports": true,
"runtime": "vercel-sandbox",
"region": "iad1",
"timestamp": "2024-01-01T00:00:00.000Z"
}
}
@ -56,7 +56,7 @@ curl -X POST https://your-executor.vercel.app/api/execute-tool \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key" \
-d '{
"packageName": "@anthropic-ai/tpmjs-hello",
"packageName": "@tpmjs/hello",
"name": "helloWorld",
"version": "latest",
"params": { "name": "World" }
@ -68,7 +68,7 @@ curl -X POST https://your-executor.vercel.app/api/execute-tool \
{
"success": true,
"output": "Hello, World!",
"executionTimeMs": 234
"executionTimeMs": 2345
}
```
@ -99,45 +99,45 @@ Add custom environment variables for your tools (e.g., `MY_API_KEY`, `DATABASE_U
## Local Development
```bash
# Install Vercel CLI
npm install -g vercel
# Install dependencies
npm install
# Login to Vercel
# Login to Vercel (required for sandbox access)
vercel login
# Link to your Vercel project
vercel link
# Pull environment variables
vercel env pull
# Run development server
vercel dev
npm run dev
# Test the health endpoint
curl http://localhost:3000/api/health
```
**Note:** Local development requires the Vercel CLI. Run `vercel login` first.
**Note:** Vercel Sandbox requires authentication even in development. Run `vercel login` and `vercel link` first.
## Security
- Set `EXECUTOR_API_KEY` to require authentication for all requests
- Tools are loaded dynamically from esm.sh
- Each request gets fresh environment variable injection
- CORS headers allow cross-origin requests (configurable)
## How It Compares to TPMJS Default Executor
| Feature | TPMJS Default (Railway) | Your Vercel Executor |
|---------|------------------------|---------------------|
| Runtime | Deno on Railway | Deno on Vercel |
| Cost | Free (TPMJS hosted) | Your Vercel usage |
| Env Vars | Stored in TPMJS | Stored in your Vercel project |
| Privacy | Requests go through TPMJS | Direct to your executor |
| Control | Managed by TPMJS | Fully yours |
- Tools run in isolated VMs with no access to your Vercel project
- Each execution gets a fresh sandbox instance
- Sandboxes are destroyed after execution completes
## Pricing
Vercel's free tier includes generous limits for serverless functions. See [Vercel Pricing](https://vercel.com/pricing) for details.
Vercel Sandbox usage is billed based on compute time. See [Vercel Sandbox Pricing](https://vercel.com/docs/vercel-sandbox/pricing) for details.
- **Hobby**: 45 min max runtime
- **Pro**: 5 hour max runtime
- **Region**: Currently only available in `iad1`
## Support
- [TPMJS Custom Executors Documentation](https://tpmjs.com/docs/executors)
- [TPMJS Custom Executor Tutorial](https://tpmjs.com/docs/tutorials/custom-executor)
- [Vercel Deno Runtime](https://github.com/vercel-community/deno)
- [Vercel Sandbox Documentation](https://vercel.com/docs/vercel-sandbox)
- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues)

View file

@ -1,256 +0,0 @@
/**
* Tool Execution Endpoint (Deno Runtime)
*
* POST /api/execute-tool
* Execute a TPMJS tool with parameters
*
* Uses Deno's native HTTP import support to load tools from esm.sh
*/
// @ts-ignore - Deno global
declare const Deno: {
env: {
get(key: string): string | undefined;
set(key: string, value: string): void;
};
};
interface ExecuteToolRequest {
packageName: string;
name: string;
version?: string;
importUrl?: string;
params: Record<string, unknown>;
env?: Record<string, string>;
}
interface ExecuteToolResponse {
success: boolean;
output?: unknown;
error?: string;
executionTimeMs: number;
}
// Simple in-memory cache for tool modules
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic
const moduleCache = new Map<string, { module: any; expiresAt: number }>();
const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes
export default async function handler(req: Request): Promise<Response> {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
// Check authorization if EXECUTOR_API_KEY is set
const apiKey = Deno.env.get('EXECUTOR_API_KEY');
if (apiKey) {
const authHeader = req.headers.get('Authorization');
if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
return Response.json(
{ success: false, error: 'Unauthorized' },
{
status: 401,
headers: { 'Access-Control-Allow-Origin': '*' },
}
);
}
}
const startTime = Date.now();
let packageName = 'unknown';
let toolName = 'unknown';
try {
const body: ExecuteToolRequest = await req.json();
packageName = body.packageName || 'unknown';
toolName = body.name || 'unknown';
const { version, importUrl, params, env } = body;
if (!body.packageName || !body.name) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, name',
executionTimeMs: Date.now() - startTime,
},
{
status: 400,
headers: { 'Access-Control-Allow-Origin': '*' },
}
);
}
const resolvedVersion = version || 'latest';
const cacheKey = `${packageName}@${resolvedVersion}::${toolName}`;
// Inject environment variables before loading/executing tool
if (env && typeof env === 'object') {
for (const [key, value] of Object.entries(env)) {
Deno.env.set(key, String(value));
}
}
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic
let toolModule: any;
const cachedEntry = moduleCache.get(cacheKey);
const now = Date.now();
if (cachedEntry && now < cachedEntry.expiresAt) {
toolModule = cachedEntry.module;
} else {
// Dynamic import from esm.sh - Deno supports this natively!
const url = importUrl || `https://esm.sh/${packageName}@${resolvedVersion}`;
const module = await import(url);
let rawExport = module[toolName];
if (!rawExport) {
return Response.json(
{
success: false,
error: `Export "${toolName}" not found in module`,
availableExports: Object.keys(module),
executionTimeMs: Date.now() - startTime,
},
{
status: 404,
headers: { 'Access-Control-Allow-Origin': '*' },
}
);
}
// Handle factory functions (tools that need to be called to create the tool instance)
if (typeof rawExport === 'function' && !rawExport.execute) {
let factoryResult = null;
// Strategy 1: Try calling with no arguments
try {
factoryResult = rawExport();
if (factoryResult?.execute) {
rawExport = factoryResult;
}
} catch {
// Try other strategies
}
// Strategy 2: Try with env config object
if (!factoryResult?.execute && env) {
const configVariations = [
{ ...env },
// Extract API key if present
(() => {
const apiKeyEntry = Object.entries(env).find(([key]) =>
key.toUpperCase().includes('API_KEY')
);
return apiKeyEntry ? { apiKey: apiKeyEntry[1] } : null;
})(),
].filter(Boolean);
for (const config of configVariations) {
try {
factoryResult = rawExport(config);
if (factoryResult?.execute) {
rawExport = factoryResult;
break;
}
} catch {
// Try next config
}
}
}
// Strategy 3: Try with first env value (single-arg pattern)
if (!factoryResult?.execute && env) {
const firstValue = Object.values(env)[0];
if (firstValue) {
try {
factoryResult = rawExport(firstValue);
if (factoryResult?.execute) {
rawExport = factoryResult;
}
} catch {
// Factory failed
}
}
}
if (!rawExport?.execute) {
return Response.json(
{
success: false,
error: `Tool "${toolName}" is a factory function but couldn't be initialized`,
hint: 'This tool may require specific configuration. Check package documentation.',
executionTimeMs: Date.now() - startTime,
},
{
status: 400,
headers: { 'Access-Control-Allow-Origin': '*' },
}
);
}
}
toolModule = rawExport;
if (!toolModule.execute) {
return Response.json(
{
success: false,
error: 'Tool missing execute function',
executionTimeMs: Date.now() - startTime,
},
{
status: 400,
headers: { 'Access-Control-Allow-Origin': '*' },
}
);
}
// Cache the module
moduleCache.set(cacheKey, {
module: toolModule,
expiresAt: now + CACHE_TTL_MS,
});
}
// Execute the tool with AI SDK execution context
const abortController = new AbortController();
const executionContext = {
abortSignal: abortController.signal,
messages: [],
toolCallId: `exec_${Date.now()}`,
};
const result = await toolModule.execute(params || {}, executionContext);
const response: ExecuteToolResponse = {
success: true,
output: result,
executionTimeMs: Date.now() - startTime,
};
return Response.json(response, {
headers: { 'Access-Control-Allow-Origin': '*' },
});
} catch (error) {
const response: ExecuteToolResponse = {
success: false,
error: error instanceof Error ? error.message : String(error),
executionTimeMs: Date.now() - startTime,
};
return Response.json(response, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
}

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

View file

@ -1,28 +1,37 @@
/**
* Health Check Endpoint (Deno Runtime)
* 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 default function handler(_req: Request): Response {
const response: HealthResponse = {
export async function GET(): Promise<NextResponse<HealthResponse>> {
return NextResponse.json({
status: 'ok',
version: '1.0.0',
info: {
runtime: 'deno',
httpImports: true,
runtime: 'vercel-sandbox',
region: 'iad1',
timestamp: new Date().toISOString(),
},
};
});
}
return Response.json(response, {
// 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',

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

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

View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Minimal config for API-only executor
};
module.exports = nextConfig;

View file

@ -2,11 +2,18 @@
"name": "tpmjs-executor",
"version": "1.0.0",
"private": true,
"description": "TPMJS Tool Executor - Deploy your own executor using Deno on Vercel",
"description": "TPMJS Tool Executor - Deploy your own executor using Vercel Sandbox",
"scripts": {
"dev": "vercel dev"
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@vercel/sandbox": "^0.1.0",
"next": "^15.0.0"
},
"devDependencies": {
"vercel": "^39.0.0"
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -1,10 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/**/*.ts": {
"runtime": "vercel-deno@3.0.0"
}
},
"framework": "nextjs",
"regions": ["iad1"],
"headers": [
{
"source": "/api/(.*)",