327 lines
16 KiB
JavaScript
327 lines
16 KiB
JavaScript
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: ``. 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;
|
|
},
|
|
});
|
|
}
|