From 7fb743577973eecac56d836fd4b517a10fb997b6 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 06:26:58 +0200 Subject: [PATCH] u --- docs/api/routes-all.md | 2 +- docs/api/routes/vfs/exec.md | 27 ++++- src/api/routes/vfsExec.js | 42 ++++++-- src/tools/javascript.js | 198 +++++++++++++++++++++++++++++++++--- 4 files changed, 244 insertions(+), 25 deletions(-) diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 7a58a12..9863867 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -684,7 +684,7 @@ _none_ - Returns: `200 str` #### Execute a virtual filesystem applet — `POST /vfs/{*path}` - Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` -- Returns: `200 { ok: bool, result: any|null, stdout: str, consoleLines: int, durationMs: int, truncated?: bool, truncationNote?: str }` +- Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, warnings?: str[], truncated?: bool, truncationNote?: str }` #### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}` - Params: `path: str, body: { content: str, mime?: str }` - Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }` diff --git a/docs/api/routes/vfs/exec.md b/docs/api/routes/vfs/exec.md index 62edeb5..3384288 100644 --- a/docs/api/routes/vfs/exec.md +++ b/docs/api/routes/vfs/exec.md @@ -9,10 +9,17 @@ and returns the exported function's result. The applet file must begin with two metadata lines: `// @applet-input-schema {JSON Schema}` followed by -`// @applet-output-schema {JSON Schema}`. The file exports a function that receives the posted `input` -as `applet_input`, and execution runs in the same isolated worker sandbox as the `javascript_execute` -tool — including authenticated `fetch()` against this API, `fs`/`vfs`, `sharp`, ExcelJS, PDFKit and -WebCrypto globals. +`// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as +`input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` +(e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, +`boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` +and returns a row object (or an array of row objects) keyed by those columns. + +Applets return typed CSV, not JSON: `result` is a `;`-separated CSV string with a header row, and the +response also carries `columns` (name/type) and `rows` (data row count). Only declared columns are +emitted, in header order; missing values become empty cells. Execution runs in the same isolated worker +sandbox as the `javascript_execute` tool — including authenticated `fetch()` against this API, +`fs`/`vfs`, `sharp`, ExcelJS, PDFKit and WebCrypto globals. The path is taken from the URL (like `DELETE /vfs/{*path}`); the run arguments are the JSON body. Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does @@ -52,7 +59,17 @@ Applet executed successfully ```json { "ok": true, - "result": null, + "result": "id;name\n1;Kaffee", + "columns": [ + { + "name": "…", + "type": "string" + } + ], + "rows": 1, + "warnings": [ + "…" + ], "stdout": "…", "consoleLines": 1, "durationMs": 1, diff --git a/src/api/routes/vfsExec.js b/src/api/routes/vfsExec.js index bcf8959..ecd0f28 100644 --- a/src/api/routes/vfsExec.js +++ b/src/api/routes/vfsExec.js @@ -72,10 +72,17 @@ export function register(app, basePath = '') { * * The applet file must begin with two metadata lines: * `// @applet-input-schema {JSON Schema}` followed by - * `// @applet-output-schema {JSON Schema}`. The file exports a function that receives the posted `input` - * as `applet_input`, and execution runs in the same isolated worker sandbox as the `javascript_execute` - * tool — including authenticated `fetch()` against this API, `fs`/`vfs`, `sharp`, ExcelJS, PDFKit and - * WebCrypto globals. + * `// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as + * `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` + * (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, + * `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` + * and returns a row object (or an array of row objects) keyed by those columns. + * + * Applets return typed CSV, not JSON: `result` is a `;`-separated CSV string with a header row, and the + * response also carries `columns` (name/type) and `rows` (data row count). Only declared columns are + * emitted, in header order; missing values become empty cells. Execution runs in the same isolated worker + * sandbox as the `javascript_execute` tool — including authenticated `fetch()` against this API, + * `fs`/`vfs`, `sharp`, ExcelJS, PDFKit and WebCrypto globals. * * The path is taken from the URL (like `DELETE /vfs/{*path}`); the run arguments are the JSON body. * Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does @@ -115,13 +122,34 @@ export function register(app, basePath = '') { * application/json: * schema: * type: object - * required: [ok, result, stdout, consoleLines, durationMs] + * required: [ok, result, columns, rows, stdout, consoleLines, durationMs] * properties: * ok: * type: boolean * result: - * description: The value returned by the applet's exported function - * nullable: true + * type: string + * description: Typed CSV table (`;`-separated, header row first) built from the returned rows and the declared output columns + * example: "id;name\n1;Kaffee" + * columns: + * type: array + * description: Declared output columns, in header order + * items: + * type: object + * required: [name, type] + * properties: + * name: + * type: string + * type: + * type: string + * enum: [string, integer, number, boolean, date] + * rows: + * type: integer + * description: Number of data rows in `result` (excludes the header row) + * warnings: + * type: array + * description: Present when cells were dropped for not matching their declared type + * items: + * type: string * stdout: * type: string * description: Captured console output diff --git a/src/tools/javascript.js b/src/tools/javascript.js index 2ac83a8..beeae91 100644 --- a/src/tools/javascript.js +++ b/src/tools/javascript.js @@ -10,6 +10,171 @@ 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. * @@ -69,27 +234,24 @@ function readApplet(vfsRoot, appletvfsfile) { throw new ToolError( 'Applet metadata must be on the first two lines: ' + '`// @applet-input-schema {JSON Schema}` followed by ' + - '`// @applet-output-schema {JSON Schema}`.' + '`// @applet-output-schema name:type;name:type` (typed CSV columns).' ); } 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}`); + 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".'); } - if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema)) { - throw new ToolError('Applet output schema must be a JSON Schema object.'); - } - return source; + const outputColumns = parseTypedCsvColumns(outputHeader[1]); + + return { source, outputColumns }; } function formatSandboxError(code, report) { @@ -112,7 +274,9 @@ export function createJavascriptExecuteTool({ googleAuthId }) { description: '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`. ' + + 'Applet files must start with `// @applet-input-schema {JSON Schema}` on line 1 and `// @applet-output-schema ` on line 2. ' + + 'The input schema describes the GUI form passed as `applet_input`. The output line declares the RESULT COLUMNS as typed CSV: `name:type` pairs separated by `;` (e.g. `id:integer;name:string;active:boolean`); a bare `name` defaults to string, and the allowed types are string, integer, number, boolean and date. ' + + 'Applets return typed CSV, NOT JSON: export a function that receives `applet_input` and returns a row object, or an array of row objects, whose keys are the declared columns (e.g. `return [{ id: 1, name: "Kaffee" }]`). Only declared columns are emitted, in header order; missing keys become empty cells. The tool result then carries `result` as a `;`-separated CSV string with a header row, plus `columns` (name/type) and `rows` (data row count). Returning a single object yields one data row. ' + '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.). ' + @@ -133,11 +297,11 @@ export function createJavascriptExecuteTool({ googleAuthId }) { }, 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.', + 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 metadata lines (`@applet-input-schema` JSON Schema, then `@applet-output-schema` typed CSV columns like `id:integer;name:string`).', }, applet_input: { type: 'object', - description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object.', + description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object. The applet returns typed CSV rows mapped onto its `@applet-output-schema` columns.', additionalProperties: true, }, timeout: { @@ -181,7 +345,8 @@ export function createJavascriptExecuteTool({ googleAuthId }) { const vfsRoot = await prepareVfs({ googleAuthId }); const isApplet = hasApplet; - const source = isApplet ? readApplet(vfsRoot, appletvfsfile) : code; + const applet = isApplet ? readApplet(vfsRoot, appletvfsfile) : null; + const source = isApplet ? applet.source : code; let report; try { report = await runInWorker({ @@ -238,6 +403,15 @@ export function createJavascriptExecuteTool({ googleAuthId }) { // 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; + if (table.warnings.length) result.warnings = table.warnings; + } if (truncated) { result.truncated = true; result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;