import { Worker } from 'worker_threads'; import { fileURLToPath } from 'url'; import fs from 'fs'; import path from 'path'; import jwt from 'jsonwebtoken'; import { defineTool, ToolError } from 'libllmorbiter'; import env from '../config/env.js'; import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(__dirname, '../..'); const WORKER_PATH = fileURLToPath(new URL('./javascript-worker.js', import.meta.url)); const DEFAULT_TIMEOUT_MS = 20_000; const MAX_TIMEOUT_MS = 60_000; const DEFAULT_MAX_OUTPUT = 20_000; const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024; // Keys already logged as benign non-sandbox worker chatter. Under `node --watch` the loader // emits `watch:import` / `watch:require` notices on the worker's parentPort for EVERY import, // so warning per message floods the log. We warn once per distinct key instead. const WATCH_NOISE_WARNED = new Set(); function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) { return new Promise((resolve) => { let settled = false; const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); const worker = new Worker(WORKER_PATH, { workerData: { code, root: ROOT_DIR, vmTimeout: timeoutMs, bufferLimit: DEFAULT_BUFFER_LIMIT, dashbackUrl: `http://127.0.0.1:${env.port}/api`, dashbackToken: token, vfsDir: vfsRoot, googleAuthId, appletInput, appletFile, }, }); const finish = (payload) => { if (settled) return; settled = true; clearTimeout(timer); try { worker.terminate(); } catch { /* already closed */ } resolve(payload); }; const timer = setTimeout(() => { console.error(`[javascript_execute] sandbox worker timed out after ${timeoutMs} ms`); finish({ result: null, stdout: '', consoleLineCount: 0, timedOut: true, timeoutMs }); }, timeoutMs); worker.on('message', (msg) => { // Ignore anything that is not a genuine sandbox report. Running the server under // `node --watch` (the `dev` script) installs a module-loader hook that posts // `watch:import` notices on this same channel; treating the first message as the // result made the sandbox appear to run for 0ms and return nothing. if (!msg || msg.__sandboxReport !== true) { // Those loader notices fire many times per run, so warn ONCE per key rather // than on every message — expected, benign noise while running under --watch. for (const key of Object.keys(msg || {})) { if (!WATCH_NOISE_WARNED.has(key)) { WATCH_NOISE_WARNED.add(key); console.warn( `[javascript_execute] ignoring non-sandbox worker message "${key}" ` + '(expected under `node --watch`; logged once per key).' ); } } return; } finish(msg); }); // A worker 'error' fires BEFORE any message when the thread fails to boot (e.g. an // import throws at startup). ALWAYS send a truthy `error` plus the full stack so the // parent can throw with something actionable; an empty `err.message` must never // degrade into a silent, result-less success payload. worker.on('error', (err) => finish({ result: null, stdout: '', consoleLineCount: 0, error: (err && err.message) ? err.message : `Sandbox worker failed to start: ${err?.stack || err?.name || 'unknown error'}`, errorName: (err && err.name) || 'Error', stack: err && typeof err.stack === 'string' ? err.stack : undefined, })); worker.on('exit', (code) => { // `finish` is idempotent via `settled`, so a message that already resolved the // promise is never overwritten. Reaching here means the worker died WITHOUT // reporting — that is a hard failure, so log it and always carry a truthy error. if (!settled) { console.error(`[javascript_execute] sandbox worker exited without reporting (code ${code})`); } finish({ result: null, stdout: '', consoleLineCount: 0, error: `Sandbox worker exited unexpectedly without reporting a result (code ${code}).`, errorName: 'WorkerExit', }); }); }); } function readApplet(vfsRoot, appletvfsfile) { if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) { throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); } const appletPath = appletvfsfile.trim(); if (path.isAbsolute(appletPath) || /^[a-zA-Z]:/.test(appletPath) || appletPath.startsWith('vfs:')) { throw new ToolError('"appletvfsfile" must be a VFS-relative file path.'); } if (!appletPath.toLowerCase().endsWith('.applet.js')) { throw new ToolError('"appletvfsfile" must refer to a .applet.js file.'); } const vfs = createVfs(vfsRoot); let source; try { const diskPath = vfs.resolveReadPath(appletPath); const realRoot = fs.realpathSync(vfsRoot); const realPath = fs.realpathSync(diskPath); const relativePath = path.relative(realRoot, realPath); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { throw new ToolError(`Applet "${appletPath}" resolves outside the VFS.`); } if (!fs.statSync(realPath).isFile()) { throw new ToolError(`Applet "${appletPath}" is not a file.`); } source = fs.readFileSync(realPath, 'utf8'); } catch (err) { if (err instanceof ToolError) throw err; throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`); } const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3); const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/); const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/); if (!inputHeader || !outputHeader) { throw new ToolError( 'Applet metadata must be on the first two lines: ' + '`// @applet-input-schema {JSON Schema}` followed by ' + '`// @applet-output-schema {JSON Schema}`.' ); } let inputSchema; let outputSchema; try { inputSchema = JSON.parse(inputHeader[1]); outputSchema = JSON.parse(outputHeader[1]); } catch (err) { throw new ToolError(`Applet metadata must contain valid JSON schemas: ${err.message}`); } if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema) || inputSchema.type !== 'object') { throw new ToolError('Applet input schema must be a JSON Schema with type "object".'); } if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema)) { throw new ToolError('Applet output schema must be a JSON Schema object.'); } return source; } function formatSandboxError(code, report) { const errName = report.errorName || 'Error'; const msg = report.error || 'Unknown error'; const lines = [`${errName}: ${msg}`]; if (report.stdout) { lines.push('Console output before crash:\n' + report.stdout); } return lines.join('\n'); } /** * Creates the javascript_execute tool bound to the authenticated user and their company. * @param {{ googleAuthId: number }} ctx */ export function createJavascriptExecuteTool({ googleAuthId }) { return defineTool({ name: 'javascript_execute', description: 'Execute JS in an isolated worker sandbox using either inline `code` or a VFS applet. Provide exactly one of `code` and `appletvfsfile`. ' + 'Applets are the preferred method when the first execution may throw: save a `.applet.js` file in the VFS so it can be inspected and further edited with `patch`, then call it by VFS path. ' + 'Applet files must start with `// @applet-input-schema {JSON Schema}` and `// @applet-output-schema {JSON Schema}` on their first two lines. The input schema describes the GUI form passed as `applet_input`; the output schema describes the exported function result. Export a function that receives `applet_input`. ' + 'Inline code: assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' + 'REST API: `await fetch("/path")` (authenticated). ' + 'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' + 'Embed generated images/links in markdown: `![alt](relative/path)`. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' + 'All APIs are pre-injected globals: `fs`/`vfs` (virtual filesystem), `sharp` (image processing), `ExcelJS`/`exceljs` (spreadsheet processing with VFS support for readFile/writeFile/writeBuffer/load), `PDFDocument`/`PDFKit`/`pdfkit` (PDF generation with VFS support for toFile/toBuffer/pipe), WebCrypto `crypto` (`subtle`, `randomUUID`), and standard web globals. Do NOT use `require()` or `import` (disabled; all necessary APIs are already in global scope).', strict: false, parameters: { type: 'object', additionalProperties: false, properties: { note: { type: ['string', 'null'], description: 'A note explaining what this code is doing (or null). This note will be displayed to the user while the code is executing.', }, code: { type: 'string', description: 'Optional inline JavaScript code. Provide exactly one of this and appletvfsfile. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.', }, appletvfsfile: { type: 'string', description: 'Optional VFS-relative path to a .applet.js file. Provide exactly one of this and code. The file must begin with the two applet JSON Schema metadata lines described in the tool description.', }, applet_input: { type: 'object', description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object.', additionalProperties: true, }, timeout: { type: ['number', 'null'], description: `Execution timeout in ms or null. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`, }, max_output: { type: ['number', 'null'], description: `Max stdout chars or null. Default ${DEFAULT_MAX_OUTPUT}.`, }, }, }, invoke: async ({ code, appletvfsfile, applet_input, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note, }) => { const hasCode = typeof code === 'string' && Boolean(code.trim()); const hasApplet = typeof appletvfsfile === 'string' && Boolean(appletvfsfile.trim()); if (hasCode === hasApplet) { throw new ToolError('Provide exactly one of "code" or "appletvfsfile".'); } if (code != null && !hasCode) { throw new ToolError('"code" must be a non-empty string when provided.'); } if (appletvfsfile != null && !hasApplet) { throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); } if (hasCode && applet_input !== undefined) { throw new ToolError('"applet_input" can only be provided with "appletvfsfile".'); } if (applet_input !== undefined && (!applet_input || typeof applet_input !== 'object' || Array.isArray(applet_input))) { throw new ToolError('"applet_input" must be an object.'); } const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS); const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT); const vfsRoot = await prepareVfs({ googleAuthId }); const isApplet = hasApplet; const source = isApplet ? readApplet(vfsRoot, appletvfsfile) : code; let report; try { report = await runInWorker({ code: source, timeoutMs: hardTimeout, vfsRoot, googleAuthId, appletInput: isApplet ? (applet_input ?? {}) : undefined, appletFile: isApplet ? appletvfsfile.trim() : undefined, }); } finally { await syncVfsToDb({ googleAuthId, vfsRoot }); } if (report.timedOut) { throw new ToolError( `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.` ); } if (report.error) { throw new ToolError(formatSandboxError(source, report)); } // The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence // means the worker died before reporting — that is a hard failure, not a result. // Throwing (rather than returning a success-shaped object) is deliberate: a // fabricated `{ ok:false, result:null }` reads to the model as "ran, returned // nothing", which is indistinguishable from a snippet that genuinely returned // undefined. Throw so the failure is unambiguous and gets logged. if (report === null || typeof report !== 'object' || !('result' in report)) { const keys = report && typeof report === 'object' ? Object.keys(report) : []; console.error( '[javascript_execute] sandbox worker produced no result payload', { keys, report } ); throw new ToolError( 'Sandbox worker did not report a result payload. ' + `Report keys: [${keys.join(', ') || 'none'}]. This is a harness failure, ` + 'not an empty result — check the server logs for the worker error above.' ); } let stdout = report.stdout ?? ''; let truncated = false; if (stdout.length > maxOutput) { stdout = stdout.slice(0, maxOutput); truncated = true; } const result = { ok: true, result: report.result, // Always present, even when empty, so callers can rely on the shape. stdout, }; if (truncated) { result.truncated = true; result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`; } result.consoleLines = report.consoleLineCount ?? 0; result.durationMs = report.durationMs ?? 0; return result; }, }); }