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