Files
dashback/src/tools/javascript.js
2026-11-01 05:37:15 +01:00

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;
},
});
}