feat: integrate Railway sandbox microservice for secure package execution

**Replace VM2 with Railway Microservice:**
- Remove VM2 dependency (incompatible with Next.js Turbopack bundling)
- Create Express sandbox service at `/services/sandbox-executor/`
- Uses isolated-vm for V8-level isolation with 128MB memory limit
- 10-second execution timeout with proper error handling

**Package Executor Client:**
- Rewrite `@tpmjs/package-executor` to call remote sandbox via HTTP
- Add `executePackage()`, `clearCache()`, `checkHealth()` functions
- Use AbortController for timeout handling
- Proper TypeScript type assertions for API responses
- Reads `SANDBOX_EXECUTOR_URL` from environment (defaults to localhost:3000)

**Sandbox Service Features:**
- `/execute` - Execute npm packages in isolated environment
- `/health` - Health check endpoint with service info
- `/cache/clear` - Clear npm package cache
- Package caching in `/tmp/.tpmjs-cache` for faster subsequent runs
- CORS support for web app integration
- Automatic ESM/CommonJS package detection

**Deployment Configuration:**
- Dockerfile with isolated-vm native dependencies (python3, make, g++)
- Railway.json with health checks and restart policies
- Environment variables: PORT, PACKAGE_CACHE_DIR, ALLOWED_ORIGINS
- Production URL: https://tpmjs-production.up.railway.app

**Integration:**
- Add SANDBOX_EXECUTOR_URL to .env.local
- Update Next.js config to mark package-executor as external
- Maintain existing API routes at `/api/tools/execute/[...slug]`

This architectural change enables secure package execution on Vercel
by moving sandboxing to a dedicated microservice on Railway.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-11-30 01:30:47 +10:00
parent b176ae54df
commit 2ce37ae2aa

View file

@ -1,21 +1,15 @@
/**
* Package executor without sandboxing
* Executes npm packages directly
*
* TODO: Add proper sandboxing with isolated-vm or similar when Next.js compatible solution is found
* VM2 doesn't work with Next.js Turbopack due to runtime file access requirements
* Package executor client
* Calls the remote sandbox service for secure package execution
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import type { ExecutionResult, ExecutorOptions } from './types.js';
const DEFAULT_TIMEOUT = 5000; // 5 seconds
const CACHE_DIR = process.env.PACKAGE_CACHE_DIR || '/tmp/.tpmjs-cache';
const DEFAULT_TIMEOUT = 10000; // 10 seconds
const SANDBOX_URL = process.env.SANDBOX_EXECUTOR_URL || 'http://localhost:3000';
/**
* Execute a package function with parameters
* Execute a package function with parameters via remote sandbox
*/
export async function executePackage(
packageName: string,
@ -25,49 +19,64 @@ export async function executePackage(
): Promise<ExecutionResult> {
const startTime = Date.now();
const timeout = options.timeout || DEFAULT_TIMEOUT;
const cacheDir = options.cacheDir || CACHE_DIR;
try {
// Ensure package is installed
const packageDir = await ensurePackageInstalled(packageName, cacheDir);
// Call the remote sandbox service
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Set up timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Execution timeout')), timeout);
const response = await fetch(`${SANDBOX_URL}/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
packageName,
functionName,
params,
}),
signal: controller.signal,
});
// Execute the package with dynamic import
const executionPromise = (async () => {
// Dynamic require from the package directory
const packagePath = join(packageDir, 'node_modules', packageName);
clearTimeout(timeoutId);
// Use require to load the package
// biome-ignore lint/security/noGlobalEval: Required for dynamic package execution
const pkg = require(packagePath);
// Get the function to execute
const fn = typeof pkg === 'function' ? pkg : pkg[functionName || 'default'];
if (typeof fn !== 'function') {
throw new Error(`Package ${packageName} does not export a function named ${functionName || 'default'}`);
}
// Execute the function
const result = await Promise.resolve(fn(params));
return result;
})();
// Race between execution and timeout
const result = await Promise.race([executionPromise, timeoutPromise]);
const executionTimeMs = Date.now() - startTime;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
};
return {
success: false,
error: errorData.error || `Sandbox service error: ${response.status}`,
executionTimeMs,
};
}
const result = (await response.json()) as {
success: boolean;
output?: unknown;
error?: string;
executionTimeMs?: number;
};
return {
success: true,
output: result,
executionTimeMs,
success: result.success,
output: result.output,
error: result.error,
executionTimeMs: result.executionTimeMs || executionTimeMs,
};
} catch (error) {
const executionTimeMs = Date.now() - startTime;
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Execution timeout',
executionTimeMs,
};
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
@ -77,58 +86,34 @@ export async function executePackage(
}
/**
* Ensure a package is installed in the cache directory
* Clear the package cache on the remote sandbox
*/
async function ensurePackageInstalled(packageName: string, cacheDir: string): Promise<string> {
// Create cache directory if it doesn't exist
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
// Package-specific directory
const packageDir = join(cacheDir, packageName.replace(/[@/]/g, '_'));
// Check if already installed
if (existsSync(join(packageDir, 'node_modules', packageName))) {
return packageDir;
}
// Install the package
export async function clearCache(): Promise<void> {
try {
if (!existsSync(packageDir)) {
mkdirSync(packageDir, { recursive: true });
}
// Initialize package.json if not exists
const packageJsonPath = join(packageDir, 'package.json');
if (!existsSync(packageJsonPath)) {
execSync('npm init -y', {
cwd: packageDir,
stdio: 'ignore',
});
}
// Install the package
execSync(`npm install ${packageName} --no-save`, {
cwd: packageDir,
stdio: 'ignore',
timeout: 30000, // 30 second timeout for installation
const response = await fetch(`${SANDBOX_URL}/cache/clear`, {
method: 'POST',
});
return packageDir;
if (!response.ok) {
throw new Error(`Failed to clear cache: ${response.status}`);
}
} catch (error) {
throw new Error(
`Failed to install package ${packageName}: ${error instanceof Error ? error.message : String(error)}`
);
console.error('Failed to clear sandbox cache:', error);
throw error;
}
}
/**
* Clear the package cache
* Check if the sandbox service is healthy
*/
export function clearCache(cacheDir?: string): void {
const dir = cacheDir || CACHE_DIR;
if (existsSync(dir)) {
execSync(`rm -rf ${dir}`, { stdio: 'ignore' });
export async function checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${SANDBOX_URL}/health`, {
method: 'GET',
});
return response.ok;
} catch {
return false;
}
}