import fs from 'fs'; import path from 'path'; import { emit } from '../sse.js'; import { isReadOnlyDatabasePath, resolveVfsRoot } from '../../tools/vfs.js'; import { createJavascriptExecuteTool } from '../../tools/javascript.js'; import { parseVfsRef, vfsFileErrorStatus } from './vfs.js'; // #region Helpers // VFS paths that are owned by their own literal route in `vfs.js`. They are registered as // separate Express handlers, so this wildcard must defer to them instead of treating e.g. // `/vfs/upload` as an applet path. Deferring keeps the two files order-independent. const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename']); function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } /** * Map applet execution failures onto HTTP status codes, matching the per-route mapper * convention used across `src/api/routes`. * * The sandbox reports its own failures as `ToolError`s whose message text is the contract; keep * these patterns in sync with `src/tools/javascript.js` (`readApplet` validation, timeout, and * `formatSandboxError`). * * @param {Error} error * @returns {number} */ function appletExecErrorStatus(error) { const message = error?.message || ''; if (message === 'No company selected') return 400; if (message === 'Not authorized') return 403; if (message === 'File not found') return 404; if (message.includes('must refer to a .applet.js file')) return 400; if (message.includes('must be a VFS-relative file path')) return 400; if (message.includes('must be an object')) return 400; if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404; if (message.includes('is not a file')) return 400; if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema') || message.includes('Applet description')) return 400; if (message.includes('Sandbox exceeded')) return 504; if (message.includes('Sandbox worker did not report a result payload')) return 500; return 400; } // #endregion /** * Registers the virtual filesystem applet execution route (requires auth). * * Runs a `.applet.js` file that lives in the VFS, mirroring `DELETE /vfs/{*path}`: the path is * the URL, the run arguments are the body. Execution is delegated to the `javascript_execute` * tool's `invoke`, so the sandbox, authenticated `fetch()`, JWT scoping, VFS hydration, and * stdout/timeout limits behave exactly as they do for the model. * * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /vfs/{*path}: * post: * tags: [Vfs] * summary: Execute a virtual filesystem applet * description: | * Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company * and returns the exported function's result. * * The applet file must begin with three metadata lines: * `// @applet-input-schema {JSON Schema}` followed by * `// @applet-output-schema {name:type;name:type}` and * `// @applet-description "description"`. 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 * not end in `.applet.js`. Read-only database paths are rejected with HTTP 403. * security: * - cookieAuth: [] * x-signals: [vfs] * x-markdown: vfs/exec.md * parameters: * - in: path * name: path * required: true * schema: * type: string * description: VFS-relative path to the applet file (e.g. `demo.applet.js`, `apps/report.applet.js`) * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * input: * type: object * description: Input values passed to the exported applet function as `applet_input`. Defaults to an empty object. * additionalProperties: true * timeout: * type: integer * description: 'Execution timeout in milliseconds, clamped to 1000…60000. Default 20000.' * max_output: * type: integer * description: 'Maximum stdout characters before truncation. Default 20000.' * responses: * 200: * description: Applet executed successfully * content: * application/json: * schema: * type: object * required: [ok, result, columns, rows, stdout, consoleLines, durationMs] * properties: * ok: * type: boolean * description: * type: string * description: Applet description declared in the @applet-description header * result: * 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 * consoleLines: * type: integer * durationMs: * type: integer * truncated: * type: boolean * description: Present when stdout was capped at `max_output` * truncationNote: * type: string * 400: * description: No company selected, invalid path, not a .applet.js file, invalid input, invalid applet metadata, or applet runtime error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized, or cannot execute read-only database paths * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Applet not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Sandbox worker harness failure * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 504: * description: The applet exceeded the execution timeout and was terminated * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /vfs/*path app.post(`${basePath}/vfs/*path`, async (req, res, next) => { const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path; // Let the literal `/vfs/upload`, `/vfs/mkdir`, and `/vfs/rename` routes handle their // own POSTs regardless of which file Express loaded first. const firstSegment = String(raw || '').split('/').filter(Boolean)[0]?.toLowerCase(); if (firstSegment && RESERVED_VFS_SEGMENTS.has(firstSegment)) { return next(); } const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsed = parseVfsRef(raw); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } if (isReadOnlyDatabasePath(parsed.name)) { return res.status(403).json({ error: 'Cannot execute read-only database files' }); } if (!parsed.name.toLowerCase().endsWith('.applet.js')) { return res.status(400).json({ error: 'path must refer to a .applet.js file' }); } const input = req.body?.input ?? {}; if (!input || typeof input !== 'object' || Array.isArray(input)) { return res.status(400).json({ error: 'input must be an object' }); } const timeout = req.body?.timeout; const maxOutput = req.body?.max_output; // Fail fast with a clean 404 before the sandbox reports a wrapped read error. try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) { return res.status(404).json({ error: 'File not found' }); } } catch (error) { res.locals.error = error; const status = vfsFileErrorStatus(error); if (status === 500) console.error('Execute vfs applet failed:', error); return res.status(status).json({ error: error.message }); } try { const tool = createJavascriptExecuteTool({ googleAuthId: req.user.id }); const result = await tool.invoke({ appletvfsfile: parsed.name, applet_input: input, timeout, max_output: maxOutput, }); emit('vfs', { companyId: selected.companyId }); return res.json(result); } catch (error) { res.locals.error = error; const status = appletExecErrorStatus(error); if (status === 500) console.error('Execute vfs applet failed:', error); return res.status(status).json({ error: error.message }); } }); // #endregion }