From 44ea5bd9affcd88893a6807a0db8b48e3ff0a130 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 9 Jan 2026 01:35:22 +1000 Subject: [PATCH] refactor(executor): use Vercel Sandbox SDK for isolated VM execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- templates/vercel-executor/README.md | 62 ++--- templates/vercel-executor/api/execute-tool.ts | 256 ------------------ .../app/api/execute-tool/route.ts | 256 ++++++++++++++++++ .../health.ts => app/api/health/route.ts} | 23 +- templates/vercel-executor/app/layout.tsx | 12 + templates/vercel-executor/app/page.tsx | 31 +++ templates/vercel-executor/next.config.js | 6 + templates/vercel-executor/package.json | 13 +- templates/vercel-executor/tsconfig.json | 21 ++ templates/vercel-executor/vercel.json | 7 +- 10 files changed, 385 insertions(+), 302 deletions(-) delete mode 100644 templates/vercel-executor/api/execute-tool.ts create mode 100644 templates/vercel-executor/app/api/execute-tool/route.ts rename templates/vercel-executor/{api/health.ts => app/api/health/route.ts} (52%) create mode 100644 templates/vercel-executor/app/layout.tsx create mode 100644 templates/vercel-executor/app/page.tsx create mode 100644 templates/vercel-executor/next.config.js create mode 100644 templates/vercel-executor/tsconfig.json diff --git a/templates/vercel-executor/README.md b/templates/vercel-executor/README.md index 078a35f..50e629e 100644 --- a/templates/vercel-executor/README.md +++ b/templates/vercel-executor/README.md @@ -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) diff --git a/templates/vercel-executor/api/execute-tool.ts b/templates/vercel-executor/api/execute-tool.ts deleted file mode 100644 index e0d65d4..0000000 --- a/templates/vercel-executor/api/execute-tool.ts +++ /dev/null @@ -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; - env?: Record; -} - -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(); -const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - -export default async function handler(req: Request): Promise { - // 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': '*' }, - }); - } -} diff --git a/templates/vercel-executor/app/api/execute-tool/route.ts b/templates/vercel-executor/app/api/execute-tool/route.ts new file mode 100644 index 0000000..32fa95d --- /dev/null +++ b/templates/vercel-executor/app/api/execute-tool/route.ts @@ -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; + env?: Record; +} + +interface ExecuteToolResponse { + success: boolean; + output?: unknown; + error?: string; + executionTimeMs: number; +} + +export async function POST(req: NextRequest): Promise> { + 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 { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/templates/vercel-executor/api/health.ts b/templates/vercel-executor/app/api/health/route.ts similarity index 52% rename from templates/vercel-executor/api/health.ts rename to templates/vercel-executor/app/api/health/route.ts index 69afb55..4684001 100644 --- a/templates/vercel-executor/api/health.ts +++ b/templates/vercel-executor/app/api/health/route.ts @@ -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; } -export default function handler(_req: Request): Response { - const response: HealthResponse = { +export async function GET(): Promise> { + 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 { + return new NextResponse(null, { + status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', diff --git a/templates/vercel-executor/app/layout.tsx b/templates/vercel-executor/app/layout.tsx new file mode 100644 index 0000000..52b4af7 --- /dev/null +++ b/templates/vercel-executor/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/templates/vercel-executor/app/page.tsx b/templates/vercel-executor/app/page.tsx new file mode 100644 index 0000000..e8e3678 --- /dev/null +++ b/templates/vercel-executor/app/page.tsx @@ -0,0 +1,31 @@ +export default function Home() { + return ( +
+

TPMJS Executor

+

This is a custom TPMJS tool executor using Vercel Sandbox.

+ +

Endpoints

+
    +
  • + GET /api/health - Health check +
  • +
  • + POST /api/execute-tool - Execute a tool +
  • +
+ +

Documentation

+

+ See the{' '} + + Custom Executor Tutorial + {' '} + for setup instructions. +

+
+ ); +} diff --git a/templates/vercel-executor/next.config.js b/templates/vercel-executor/next.config.js new file mode 100644 index 0000000..e521e87 --- /dev/null +++ b/templates/vercel-executor/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Minimal config for API-only executor +}; + +module.exports = nextConfig; diff --git a/templates/vercel-executor/package.json b/templates/vercel-executor/package.json index a29cd20..0a01511 100644 --- a/templates/vercel-executor/package.json +++ b/templates/vercel-executor/package.json @@ -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" } } diff --git a/templates/vercel-executor/tsconfig.json b/templates/vercel-executor/tsconfig.json new file mode 100644 index 0000000..f809579 --- /dev/null +++ b/templates/vercel-executor/tsconfig.json @@ -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"] +} diff --git a/templates/vercel-executor/vercel.json b/templates/vercel-executor/vercel.json index f86a91b..abd8442 100644 --- a/templates/vercel-executor/vercel.json +++ b/templates/vercel-executor/vercel.json @@ -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/(.*)",