Files
dashback/src/api/routes/vfsExec.js
2026-10-12 14:18:54 +02:00

276 lines
12 KiB
JavaScript

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
}