#!/usr/bin/env node // sandbox-api.js — minimal HF-compatible sandbox API matching n8n-sandbox contract // n8n talks to http://127.0.0.1:8080 (N8N_SANDBOX_SERVICE_URL) with header x-api-key const http = require('http'); const { URL } = require('url'); const PORT = parseInt(process.env.SANDBOX_API_PORT || '8080', 10); const RUNNER_GRPC = process.env.SANDBOX_RUNNER_GRPC || '127.0.0.1:9091'; const API_KEYS = (process.env.SANDBOX_API_KEYS || process.env.N8N_SANDBOX_SERVICE_API_KEY || '').split(',').map(s=>s.trim()).filter(Boolean); const RUNNER_URL = `http://127.0.0.1:${(RUNNER_GRPC.split(':')[1]||'9091')}`; // http fallback for our JS runner function authorized(req) { if (API_KEYS.length===0) return true; const k = req.headers['x-api-key'] || req.headers['authorization']?.replace(/^Bearer\s+/,''); return API_KEYS.includes(k); } function json(res, code, obj){ res.writeHead(code, {'Content-Type':'application/json'}); res.end(JSON.stringify(obj)); } const server = http.createServer(async (req,res)=>{ const url = new URL(req.url, `http://localhost:${PORT}`); // health if (url.pathname === '/healthz' || url.pathname === '/health') { res.writeHead(200, {'Content-Type':'text/plain'}); res.end('ok\n'); return; } if (!authorized(req)) { json(res,401,{error:'unauthorized'}); return; } // n8n sandbox service expects POST /v1/sandbox or POST /execute — we support both // Docs: N8N_SANDBOX_SERVICE_URL=http://sandbox-api:8080 — n8n does POST /execute or /v1/... // Implement simple execute endpoint: if ((url.pathname === '/execute' || url.pathname === '/v1/execute' || url.pathname === '/v1/sandbox' || url.pathname === '/run') && req.method==='POST') { let body=''; req.on('data',c=>body+=c); req.on('end', async()=>{ try{ const data = body ? JSON.parse(body) : {}; // Support both {code, language} and n8n format {code, language, context?} const code = data.code || data.content || ''; const language = (data.language || data.lang || 'python').toLowerCase(); const timeoutMs = data.timeoutMs || 30000; if (!code) { json(res,400,{error:'missing code'}); return; } // Forward to sandbox-runner (our JS runner) via HTTP POST /run const runnerBody = JSON.stringify({code, language, timeoutMs}); const r = await new Promise((resolve,reject)=>{ const rq = http.request({hostname:'127.0.0.1', port: RUNNER_URL.split(':').pop(), path:'/run', method:'POST', headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(runnerBody)}}, (rr)=>{ let b=''; rr.on('data',c=>b+=c); rr.on('end',()=>resolve({status:rr.statusCode, body:b})); }); rq.on('error',reject); rq.write(runnerBody); rq.end(); }).catch(e=>({status:500, body: JSON.stringify({error: e.message})})); // Try parse runner response let out; try{ out = JSON.parse(r.body);}catch{ out={stdout:r.body, stderr:'', exitCode: r.status===200?0:1};} json(res,200, out); }catch(e){ json(res,500,{error:e.message}); } }); return; } // list/info if (url.pathname === '/' && req.method==='GET'){ json(res,200,{service:'sandbox-api', mode:'rootless-bwrap', runner: RUNNER_GRPC}); return; } json(res,404,{error:'not found', path:url.pathname}); }); server.listen(PORT, '127.0.0.1', ()=>console.log(`[sandbox-api] listening 127.0.0.1:${PORT} runner=${RUNNER_GRPC}`)); process.on('SIGTERM',()=>server.close(()=>process.exit(0)));