Files
dashback/src/tools/javascript.js
2026-10-06 03:30:31 +02:00

217 lines
9.6 KiB
JavaScript

import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import path from 'path';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { 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 }) {
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,
},
});
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 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 isolated Node worker sandbox. Assign `module.exports` to return value (sync value or Promise; no bare top-level await — wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'REST API: `await fetch("/path")` (authenticated). ' +
'VFS: `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only). ' +
'Embed generated images in markdown: `![alt](path)`. Use find_files/read_file/patch for text. ' +
'Globals: `fs`/`vfs`, `sharp`, WebCrypto `crypto` (`subtle`, `randomUUID`), web standard globals. Node built-ins unavailable (`require` fails).',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
code: {
type: 'string',
description: 'CommonJS JS code. Assign result to `module.exports`. Async code must use IIFE.',
},
timeout: {
type: 'number',
description: `Execution timeout in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
},
max_output: {
type: 'number',
description: `Max stdout chars. Default ${DEFAULT_MAX_OUTPUT}.`,
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
}
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 });
let report;
try {
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
} 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(code, 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;
},
});
}