u
This commit is contained in:
157
src/tools/javascript.js
Normal file
157
src/tools/javascript.js
Normal file
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
|
||||
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(() => {
|
||||
finish({ timedOut: true, timeoutMs });
|
||||
}, timeoutMs);
|
||||
|
||||
worker.on('message', (msg) => finish(msg));
|
||||
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
|
||||
worker.on('exit', (code) => {
|
||||
finish({
|
||||
error: `Sandbox worker exited unexpectedly (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, companyId: number }} ctx
|
||||
*/
|
||||
export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
return defineTool({
|
||||
name: 'javascript_execute',
|
||||
description:
|
||||
'Run JavaScript in a sandboxed Node environment and return the result. ' +
|
||||
'Runs in an isolated worker thread with a hard wall-clock timeout. ' +
|
||||
'Return a value by assigning `module.exports` (sync value or a promise). ' +
|
||||
'For async code: `module.exports = (async () => { ... })()`. ' +
|
||||
'console.* is captured and returned as stdout. ' +
|
||||
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
|
||||
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
|
||||
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
|
||||
'The `sharp` image library is a pre-injected GLOBAL. ' +
|
||||
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
|
||||
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
|
||||
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
|
||||
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: `Hard wall-clock limit in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
|
||||
},
|
||||
max_output: {
|
||||
type: 'number',
|
||||
description: `Max chars of captured console output to return. 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({ companyId, googleAuthId });
|
||||
let report;
|
||||
try {
|
||||
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
|
||||
} finally {
|
||||
await syncVfsToDb({ googleAuthId, companyId, vfsRoot });
|
||||
}
|
||||
|
||||
if (report.timedOut) {
|
||||
return {
|
||||
code,
|
||||
timedOut: true,
|
||||
timeoutMs: report.timeoutMs,
|
||||
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (report.error) {
|
||||
throw new ToolError(formatSandboxError(code, report));
|
||||
}
|
||||
|
||||
let stdout = report.stdout ?? '';
|
||||
let truncated = false;
|
||||
if (stdout.length > maxOutput) {
|
||||
stdout = stdout.slice(0, maxOutput);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const result = { code, result: report.result };
|
||||
if (stdout) result.stdout = stdout;
|
||||
if (truncated) {
|
||||
result.truncated = true;
|
||||
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
|
||||
}
|
||||
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
|
||||
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user