441 lines
18 KiB
JavaScript
441 lines
18 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import jwt from 'jsonwebtoken';
|
|
import { defineTool, ToolError } from 'libllmorbiter';
|
|
import env from '../config/env.js';
|
|
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
|
import { createVfs, prepareVfs } from './vfs.js';
|
|
import { runSandboxJob } from '../services/javascriptSandbox.js';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
const MAX_TIMEOUT_MS = 60_000;
|
|
const DEFAULT_MAX_OUTPUT = 20_000;
|
|
|
|
// Column types an applet may declare in its `@applet-output-schema` header. Kept deliberately
|
|
// small: each one maps to a JS-coercion rule the applet author can reason about, and the
|
|
// frontend can render without guessing.
|
|
const APPLET_COLUMN_TYPES = new Set(['string', 'integer', 'number', 'boolean', 'date']);
|
|
|
|
// Semicolon-separated columns, matching the spreadsheet exports in `src/util/*Csv.js`.
|
|
const APPLET_CSV_SEPARATOR = ';';
|
|
|
|
/**
|
|
* Parse the applet output header into typed CSV columns.
|
|
*
|
|
* The header carries the COLUMN NAMES AND TYPES, e.g. `id:integer;name:string;active:boolean`.
|
|
* A bare `name` (no `:type`) is accepted and defaults to `string`. Types are limited to
|
|
* {@link APPLET_COLUMN_TYPES} so a typo surfaces here instead of at render time.
|
|
*
|
|
* @param {string} header raw text after the `// @applet-output-schema ` marker
|
|
* @returns {Array<{ name: string, type: string }>}
|
|
*/
|
|
function parseTypedCsvColumns(header) {
|
|
const text = String(header || '').trim();
|
|
if (!text) {
|
|
throw new ToolError(
|
|
'Applet output schema must declare typed CSV columns, e.g. ' +
|
|
'`@applet-output-schema id:integer;name:string`.'
|
|
);
|
|
}
|
|
|
|
const columns = [];
|
|
const seen = new Set();
|
|
for (const raw of text.split(APPLET_CSV_SEPARATOR)) {
|
|
const field = raw.trim();
|
|
if (!field) continue;
|
|
|
|
const sep = field.lastIndexOf(':');
|
|
const name = (sep === -1 ? field : field.slice(0, sep)).trim();
|
|
const type = (sep === -1 ? 'string' : field.slice(sep + 1).trim().toLowerCase());
|
|
|
|
if (!name) {
|
|
throw new ToolError(`Applet output column name is empty in "${field}".`);
|
|
}
|
|
if (!APPLET_COLUMN_TYPES.has(type)) {
|
|
throw new ToolError(
|
|
`Applet output column "${name}" has unknown type "${type}". ` +
|
|
`Use one of: ${[...APPLET_COLUMN_TYPES].join(', ')}.`
|
|
);
|
|
}
|
|
if (seen.has(name)) {
|
|
throw new ToolError(`Applet output column "${name}" is declared twice.`);
|
|
}
|
|
seen.add(name);
|
|
columns.push({ name, type });
|
|
}
|
|
|
|
if (columns.length === 0) {
|
|
throw new ToolError('Applet output schema must declare at least one column.');
|
|
}
|
|
return columns;
|
|
}
|
|
|
|
/**
|
|
* Quote one CSV field (RFC 4180): wrap when it contains the separator, a quote, or a newline.
|
|
* @param {unknown} value
|
|
* @returns {string}
|
|
*/
|
|
function encodeCsvField(value) {
|
|
const text = value == null ? '' : String(value);
|
|
if (text.includes(APPLET_CSV_SEPARATOR) || text.includes('"') || /[\r\n]/.test(text)) {
|
|
return `"${text.replace(/"/g, '""')}"`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Coerce a returned cell to its declared type and report what happened.
|
|
*
|
|
* Returns `null` when the value cannot be coerced (`undefined`, `null`, `NaN`, unparseable
|
|
* date). The caller turns that into an empty CSV cell, so a missing value never aborts the run.
|
|
*
|
|
* @param {unknown} value
|
|
* @param {string} type
|
|
* @returns {{ ok: true, value: string | number | boolean } | { ok: false }}
|
|
*/
|
|
function coerceAppletCell(value, type) {
|
|
if (value === undefined || value === null) return { ok: false };
|
|
|
|
switch (type) {
|
|
case 'string':
|
|
return { ok: true, value: typeof value === 'object' ? JSON.stringify(value) : String(value) };
|
|
case 'boolean':
|
|
if (typeof value === 'boolean') return { ok: true, value };
|
|
if (value === 1 || value === 0) return { ok: true, value: value === 1 };
|
|
if (typeof value === 'string' && /^(true|false)$/i.test(value.trim())) {
|
|
return { ok: true, value: value.trim().toLowerCase() === 'true' };
|
|
}
|
|
return { ok: false };
|
|
case 'integer':
|
|
case 'number': {
|
|
const num = typeof value === 'number' ? value : Number(String(value).trim());
|
|
if (!Number.isFinite(num)) return { ok: false };
|
|
return { ok: true, value: type === 'integer' ? Math.trunc(num) : num };
|
|
}
|
|
case 'date': {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (Number.isNaN(date.getTime())) return { ok: false };
|
|
return { ok: true, value: date.toISOString() };
|
|
}
|
|
default:
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize an applet's return value into typed CSV keyed by its declared columns.
|
|
*
|
|
* The applet returns rows as objects (or arrays). Only declared columns are emitted, in header
|
|
* order; unknown keys are dropped and missing ones become empty cells. Scalars and key
|
|
* information that belong to the row itself are surfaced as `warnings` rather than failing.
|
|
*
|
|
* @param {unknown} result value returned by the applet's exported function
|
|
* @param {Array<{ name: string, type: string }>} columns
|
|
* @returns {{ csv: string, rows: number, columns: Array<{ name: string, type: string }>, warnings: string[] }}
|
|
*/
|
|
function toTypedCsv(result, columns) {
|
|
const warnings = [];
|
|
|
|
let rows;
|
|
if (result === undefined || result === null) {
|
|
rows = [];
|
|
} else if (Array.isArray(result)) {
|
|
rows = result;
|
|
} else if (typeof result === 'object') {
|
|
// A single object is one row — the common "return the totals" case.
|
|
rows = [result];
|
|
} else {
|
|
rows = [result];
|
|
warnings.push(
|
|
`Applet returned a ${typeof result}, which has no columns to map onto; ` +
|
|
'the value was placed in the first column.'
|
|
);
|
|
}
|
|
|
|
let coercions = 0;
|
|
const body = rows.map((row) => {
|
|
const isContainer = row && typeof row === 'object' && !Array.isArray(row);
|
|
return columns
|
|
.map((column, index) => {
|
|
const value = isContainer ? row[column.name] : index === 0 ? row : undefined;
|
|
const coerced = coerceAppletCell(value, column.type);
|
|
if (!coerced.ok) {
|
|
if (value !== undefined && value !== null) coercions += 1;
|
|
return '';
|
|
}
|
|
return encodeCsvField(coerced.value);
|
|
})
|
|
.join(APPLET_CSV_SEPARATOR);
|
|
});
|
|
|
|
if (coercions > 0) {
|
|
warnings.push(`${coercions} cell(s) did not match their declared column type and were left empty.`);
|
|
}
|
|
|
|
const csv = [columns.map((c) => encodeCsvField(c.name)).join(APPLET_CSV_SEPARATOR), ...body].join('\n');
|
|
return { csv, rows: body.length, columns, warnings };
|
|
}
|
|
|
|
/**
|
|
* Runs one snippet/applet on the shared sandbox pool.
|
|
*
|
|
* Threads are keyed by VFS root so a reused thread keeps the right company's `vfsDir`, and the
|
|
* per-run JWT is minted here because it is scoped to THIS `googleAuthId`.
|
|
*
|
|
* @param {{ code: string, timeoutMs: number, vfsRoot: string, googleAuthId: number, appletInput?: object, appletFile?: string }} ctx
|
|
*/
|
|
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) {
|
|
const dashbackToken = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
|
return runSandboxJob({
|
|
poolKey: vfsRoot,
|
|
googleAuthId,
|
|
code,
|
|
timeoutMs,
|
|
dashbackToken,
|
|
appletInput,
|
|
appletFile,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse the applet description header.
|
|
*
|
|
* Accepts either a quoted string (e.g. `"Lorem ipsum"` or `'Lorem ipsum'`) or bare text.
|
|
* Strips surrounding quotes if present and trims whitespace.
|
|
*
|
|
* @param {string} header raw text after the `// @applet-description ` marker
|
|
* @returns {string}
|
|
*/
|
|
function parseAppletDescription(header) {
|
|
const text = String(header || '').trim();
|
|
if (!text) {
|
|
throw new ToolError('Applet description cannot be empty.');
|
|
}
|
|
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim();
|
|
} catch {
|
|
const unquoted = text.slice(1, -1).trim();
|
|
if (unquoted) return unquoted;
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
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/, 4);
|
|
const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
|
|
const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
|
|
const descHeader = lines[2]?.match(/^\/\/ @applet-description (.+)$/);
|
|
if (!inputHeader || !outputHeader || !descHeader) {
|
|
throw new ToolError(
|
|
'Applet metadata must be on the first three lines: ' +
|
|
'`// @applet-input-schema {JSON Schema}` followed by ' +
|
|
'`// @applet-output-schema name:type;name:type` (typed CSV columns) and ' +
|
|
'`// @applet-description "description"`.'
|
|
);
|
|
}
|
|
|
|
let inputSchema;
|
|
try {
|
|
inputSchema = JSON.parse(inputHeader[1]);
|
|
} catch (err) {
|
|
throw new ToolError(`Applet input schema must be valid JSON: ${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".');
|
|
}
|
|
|
|
const outputColumns = parseTypedCsvColumns(outputHeader[1]);
|
|
const description = parseAppletDescription(descHeader[1]);
|
|
|
|
return { source, outputColumns, description, inputSchema };
|
|
}
|
|
|
|
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: TOOL_DESCRIPTIONS.javascript_execute,
|
|
strict: false,
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
note: {
|
|
type: ['string', 'null'],
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.note,
|
|
},
|
|
code: {
|
|
type: 'string',
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.code,
|
|
},
|
|
appletvfsfile: {
|
|
type: 'string',
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.appletvfsfile,
|
|
},
|
|
applet_input: {
|
|
type: 'object',
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.applet_input,
|
|
additionalProperties: true,
|
|
},
|
|
timeout: {
|
|
type: ['number', 'null'],
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.timeout,
|
|
},
|
|
max_output: {
|
|
type: ['number', 'null'],
|
|
description: TOOL_PARAMETER_DESCRIPTIONS.javascript_execute.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 applet = isApplet ? readApplet(vfsRoot, appletvfsfile) : null;
|
|
const source = isApplet ? applet.source : code;
|
|
const report = await runInWorker({
|
|
code: source,
|
|
timeoutMs: hardTimeout,
|
|
vfsRoot,
|
|
googleAuthId,
|
|
appletInput: isApplet ? (applet_input ?? {}) : undefined,
|
|
appletFile: isApplet ? appletvfsfile.trim() : undefined,
|
|
});
|
|
|
|
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,
|
|
};
|
|
// Applets return typed CSV, not JSON: the declared columns turn the returned rows
|
|
// into a `;`-separated table. Inline code keeps returning its raw value.
|
|
if (isApplet) {
|
|
const table = toTypedCsv(report.result, applet.outputColumns);
|
|
result.result = table.csv;
|
|
result.rows = table.rows;
|
|
result.columns = table.columns;
|
|
result.description = applet.description;
|
|
if (table.warnings.length) result.warnings = table.warnings;
|
|
}
|
|
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;
|
|
},
|
|
});
|
|
}
|