This commit is contained in:
seb
2026-10-09 19:02:15 +02:00
parent cd6879f3b7
commit ac10840fb6
42 changed files with 467 additions and 49 deletions

View File

@@ -10,7 +10,19 @@ import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { createVfs } from './vfs.js';
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
const {
code,
root,
vmTimeout,
bufferLimit,
network,
dashbackUrl,
dashbackToken,
vfsDir,
googleAuthId,
appletInput,
appletFile,
} = workerData;
function resolveDashbackUrl(inputUrl, baseApiUrl) {
const raw = String(inputUrl || '').trim();
@@ -582,7 +594,13 @@ try {
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
value = vm.run(code, appletFile || 'javascript_execute.js');
if (appletFile) {
if (typeof value !== 'function') {
throw new TypeError('Applet must assign an exported function to module.exports.');
}
value = value(appletInput);
}
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([

View File

@@ -1,10 +1,11 @@
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 { prepareVfs, syncVfsToDb } from './vfs.js';
import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '../..');
@@ -20,7 +21,7 @@ const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
// 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 }) {
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' });
@@ -35,6 +36,8 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
dashbackToken: token,
vfsDir: vfsRoot,
googleAuthId,
appletInput,
appletFile,
},
});
@@ -104,6 +107,67 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
});
}
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';
@@ -122,7 +186,10 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
return defineTool({
name: 'javascript_execute',
description:
'Execute JS in isolated worker sandbox. Assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' +
'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. ' +
@@ -131,7 +198,6 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
note: {
type: ['string', 'null'],
@@ -139,7 +205,16 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
code: {
type: 'string',
description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.',
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'],
@@ -151,17 +226,48 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
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, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
report = await runInWorker({
code: source,
timeoutMs: hardTimeout,
vfsRoot,
googleAuthId,
appletInput: isApplet ? (applet_input ?? {}) : undefined,
appletFile: isApplet ? appletvfsfile.trim() : undefined,
});
} finally {
await syncVfsToDb({ googleAuthId, vfsRoot });
}
@@ -173,7 +279,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
}
if (report.error) {
throw new ToolError(formatSandboxError(code, report));
throw new ToolError(formatSandboxError(source, report));
}
// The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence