This commit is contained in:
seb
2026-10-04 02:14:03 +02:00
parent 9a7f345d8b
commit e5ff4ef307
15 changed files with 1178 additions and 62 deletions

View File

@@ -202,6 +202,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |

View File

@@ -708,6 +708,105 @@
}
}
},
"/vfs/{*path}": {
"get": {
"tags": [
"Vfs"
],
"summary": "Get a virtual filesystem file",
"description": "Returns a file the model produced in the `javascript_execute` sandbox\n(virtual filesystem), for the authenticated user's selected company.\n\nEmbed the returned URL in chat markdown to display a generated image:\n`![title](/api/vfs/example.png)`. *\nImages are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the\naspect ratio preserved (never stretched), and anything smaller than 100 px on its larger\nedge is upscaled to 100 px. The preview carries a translucent strip along the bottom\nedge listing the ORIGINAL width, height, file size, MIME type and filename. *\nOnly image MIME types the sandbox produces are rendered inline; every other file\ntype is sent with `Content-Disposition: attachment` so it downloads rather than\nbeing interpreted as a document in the app's origin.\n",
"security": [
{
"cookieAuth": []
}
],
"parameters": [
{
"in": "path",
"name": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "VFS file path (e.g. `example.png`, `labels/a.png`)"
},
{
"in": "query",
"name": "download",
"required": false,
"schema": {
"type": "boolean"
},
"description": "Return the original, unmodified file instead of the standard preview.\nWithout this parameter images are always served as a 250x250 aspect-preserving\npreview with a metadata strip. Ignored for non-image files, which are always\nserved as-is.\n"
}
],
"responses": {
"200": {
"description": "File bytes",
"headers": {
"Content-Disposition": {
"schema": {
"type": "string"
}
},
"Cache-Control": {
"schema": {
"type": "string"
}
}
},
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "Invalid path, or no company selected",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not authorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "File not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/unit": {
"get": {
"tags": [
@@ -13888,12 +13987,25 @@
"Article"
],
"summary": "List article search index",
"description": "Returns every article reachable via the company's category tree.\nDesigned for client-side search / autocomplete preload.\nAuth via company access.\n",
"description": "Returns every article reachable via the company's category tree.\nDesigned for client-side search / autocomplete preload.\nAuth via company access.\n\nREQUIRES `windowSize`. Without it the response is always an empty list — the full\nindex can be very large and is not useful to a caller that did not ask for a\nspecific window. Pass the number of entries the client actually intends to preload.\n",
"security": [
{
"cookieAuth": []
}
],
"parameters": [
{
"in": "query",
"name": "windowSize",
"required": true,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 1000
},
"description": "Number of articles to return (window size). Required; omitting it returns an\nempty `articles` array. `truncated` is true when more entries exist than were\nreturned, signalling the client should request a larger window or narrow its\nquery.\n"
}
],
"responses": {
"200": {
"description": "Search index",
@@ -13931,6 +14043,10 @@
}
}
}
},
"truncated": {
"type": "boolean",
"description": "True when the window was smaller than the total number of matching articles."
}
}
}

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 177 API endpoints across all modules.
Overview of all 178 API endpoints across all modules.
### Parameter Notation
@@ -46,7 +46,7 @@ Overview of all 177 API endpoints across all modules.
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
| `GET` | `/articleIndex` | — | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[] }` | List article search index |
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
@@ -182,6 +182,7 @@ Overview of all 177 API endpoints across all modules.
| `GET` | `/v1/client` | `query: authCode:string, name?:string` | `200 { authCode, authToken, certificateFingerprint, certificateSerialNumber, mandantId, mandantName, mandantDatabase, serverFingerprint, name, serverTimestamp }` | POS discovery and pairing |
| `GET` | `/v1/init` | `query: lastChangedProduct?:integer, lastChangedCategory?:integer, lastChangedCustomer?:integer, lastChangedCustomerGroup?:integer, lastChangedCompositeProduct?:integer, lastChangedDeletedEntity?:integer` | `200 { version, product_count, category_count, customer_count, customerGroup_count, compositeProduct_count, configurationGroup_count, configurationItem_count, deletedEntity_count, max_orderId_count }` | POS sync initialization counts |
| `GET` | `/v1/product` | `query: lastChangedProduct?:string, limit?:integer` | `200 object[]` | JTL-POS product catalog synchronization |
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 141 API endpoints across all modules.
Overview of all 142 API endpoints across all modules.
### Parameter Notation
@@ -44,7 +44,7 @@ Overview of all 141 API endpoints across all modules.
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
| `GET` | `/articleIndex` | — | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[] }` | List article search index |
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
@@ -146,6 +146,7 @@ Overview of all 141 API endpoints across all modules.
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
@@ -153,3 +154,26 @@ Overview of all 141 API endpoints across all modules.
| `GET` | `/warehouse/{id}` | `id:integer` | `200 { warehouse: { id, name, sort } }` | Get warehouse by ID |
| `PATCH` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse (partial) |
| `PUT` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse |
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:
| Method | Route | Returns | Notes |
|:---|:---|:---|:---|
| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. Use for inspecting or re-encoding an existing image.
| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. Takes the same `fullHash` as `/image/{fullHash}`.
| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. Body: `{ url }`.
Save the bytes to the VFS, then embed them in your reply as `![title](artifacts/thumb.avif)`:
```js
module.exports = (async () => {
const hash = "…64-char sha-256…";
const res = await fetch(`/thumb/${hash}`); // authenticated automatically
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync("artifacts/thumb.avif", buf);
return { bytes: buf.length };
})();
```
`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, not a content hash, and is not accepted here.

View File

@@ -8,6 +8,20 @@ Returns every article reachable via the company's category tree.
Designed for client-side search / autocomplete preload.
Auth via company access.
REQUIRES `windowSize`. Without it the response is always an empty list — the full
index can be very large and is not useful to a caller that did not ask for a
specific window. Pass the number of entries the client actually intends to preload.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `windowSize` | yes | integer | Number of articles to return (window size). Required; omitting it returns an
empty `articles` array. `truncated` is true when more entries exist than were
returned, signalling the client should request a larger window or narrow its
query.
|
## Response `200`
@@ -27,7 +41,8 @@ Search index
1
]
}
]
],
"truncated": true
}
```

View File

@@ -0,0 +1,52 @@
# `GET /vfs/{*path}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get a virtual filesystem file
Returns a file the model produced in the `javascript_execute` sandbox
(virtual filesystem), for the authenticated user's selected company.
Embed the returned URL in chat markdown to display a generated image:
`![title](/api/vfs/example.png)`. *
Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the
aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger
edge is upscaled to 100 px. The preview carries a translucent strip along the bottom
edge listing the ORIGINAL width, height, file size, MIME type and filename. *
Only image MIME types the sandbox produces are rendered inline; every other file
type is sent with `Content-Disposition: attachment` so it downloads rather than
being interpreted as a document in the app's origin.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `download` | no | boolean | Return the original, unmodified file instead of the standard preview.
Without this parameter images are always served as a 250x250 aspect-preserving
preview with a metadata strip. Ignored for non-image files, which are always
served as-is.
|
## Response `200`
File bytes
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid path, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | File not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

BIN
img2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

BIN
img3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -50,6 +50,24 @@ function isRouteOmitted(method, apiPath) {
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}
/**
* Routes omitted from the main overview because their payload is binary or streaming, which is
* useless to the `dashback_request` tool — but which ARE reachable with `fetch()` from inside
* `javascript_execute`, where the bytes can be decoded, transformed, written to the VFS and shown
* as an image. These are documented separately so the model knows about them without cluttering
* the request-shaped route table.
*
* Deliberately NOT listed: import/export (administrative bulk operations the model must not
* trigger), `/v1/*` (legacy POS compat), `/events` and `/serper/images` (long-lived SSE),
* `/modelconversation/*` and `/posterminal/*` (UI plumbing, recursion risk), `/public/*`, and every
* auth/company mutation.
*/
const JS_FETCH_ONLY_ROUTES = [
'GET /image/{fullHash}',
'GET /thumb/{fullHash}',
'POST /serper/image',
];
const spec = getOpenApiSpec(true);
function ensureDir(dir) {
@@ -681,6 +699,56 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
return lines.join('\n');
}
/**
* Extra section appended to routes.md only. `all: false` renders it into the model-facing
* overview; routes-all.md stays a complete listing with no behavioural advice.
*/
function renderJsFetchSection(allOperations) {
const lines = [];
lines.push('### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)');
lines.push('');
lines.push(
'These routes are NOT in the table above because their responses are binary or long-lived ' +
'streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from ' +
'inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write ' +
'them to the VFS and embed them in your reply:',
);
lines.push('');
lines.push('| Method | Route | Returns | Notes |');
lines.push('|:---|:---|:---|:---|');
lines.push(
'| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. ' +
'Use for inspecting or re-encoding an existing image.',
);
lines.push(
'| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. ' +
'Takes the same `fullHash` as `/image/{fullHash}`.',
);
lines.push(
'| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. ' +
'Body: `{ url }`.',
);
lines.push('');
lines.push('Save the bytes to the VFS, then embed them in your reply as `![title](artifacts/thumb.avif)`:');
lines.push('');
lines.push('```js');
lines.push('module.exports = (async () => {');
lines.push(' const hash = "…64-char sha-256…";');
lines.push(' const res = await fetch(`/thumb/${hash}`); // authenticated automatically');
lines.push(' const buf = Buffer.from(await res.arrayBuffer());');
lines.push(' fs.writeFileSync("artifacts/thumb.avif", buf);');
lines.push(' return { bytes: buf.length };');
lines.push('})();');
lines.push('```');
lines.push('');
lines.push(
'`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, ' +
'not a content hash, and is not accepted here.',
);
lines.push('');
return lines.join('\n');
}
// --- generate ---
ensureDir(docsDir);
rmDirContents(routesDocsDir);
@@ -712,7 +780,10 @@ for (const apiPath of paths) {
}
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
fs.writeFileSync(
path.join(docsDir, 'routes.md'),
renderRoutesOverview(overviewOperations) + renderJsFetchSection(operations)
);
fs.writeFileSync(path.join(docsDir, 'routes-all.md'), renderRoutesOverview(operations, 'Dash API Routes Overview (All)'));
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2));

View File

@@ -1542,8 +1542,25 @@ export function register(app, basePath = '') {
* Returns every article reachable via the company's category tree.
* Designed for client-side search / autocomplete preload.
* Auth via company access.
*
* REQUIRES `windowSize`. Without it the response is always an empty list — the full
* index can be very large and is not useful to a caller that did not ask for a
* specific window. Pass the number of entries the client actually intends to preload.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: windowSize
* required: true
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* description: |
* Number of articles to return (window size). Required; omitting it returns an
* empty `articles` array. `truncated` is true when more entries exist than were
* returned, signalling the client should request a larger window or narrow its
* query.
* responses:
* 200:
* description: Search index
@@ -1572,6 +1589,9 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: integer
* truncated:
* type: boolean
* description: True when the window was smaller than the total number of matching articles.
* 400:
* description: No company selected
* content:
@@ -1601,11 +1621,28 @@ export function register(app, basePath = '') {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
// A window size must be given explicitly. Without one the index can be arbitrarily large,
// and returning the entire result set to a caller that never asked for a window is
// exactly the "fetch everything" behaviour this guard exists to prevent.
const rawWindowSize = req.query.windowSize;
if (rawWindowSize === undefined || rawWindowSize === '') {
return res.status(200).json({ articles: [], truncated: false });
}
const windowSize = parseInt(rawWindowSize, 10);
if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1000) {
return res.status(400).json({ error: 'windowSize must be an integer between 1 and 1000' });
}
try {
const articles = await db.listSearchIndex(req.user.id);
res.status(200).json({ articles });
const all = await db.listSearchIndex(req.user.id);
// `length === windowSize` is the same truncation signal used by
// GET /article/{sku}/history: the window is exactly full, so more may exist.
const truncated = all.length > windowSize;
const articles = truncated ? all.slice(0, windowSize) : all;
res.status(200).json({ articles, truncated });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List article search index failed:', error);

503
src/api/routes/vfs.js Normal file
View File

@@ -0,0 +1,503 @@
import * as db from '../../db/modelvfs.js';
import sharp from 'sharp';
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
const THUMB_BOX = 250;
// Images whose larger edge is below this are UPSCALED to it, so tiny icons stay legible.
const THUMB_MIN_EDGE = 100;
// Output format for generated previews. WebP keeps the metadata strip visually lossless at
// small sizes without ballooning bytes the way PNG text overlays would.
const THUMB_FORMAT = 'webp';
const THUMB_QUALITY = 88;
// Monospace font available on the host (verified: DejaVu Sans Mono ships with the image).
const THUMB_FONT = 'DejaVu Sans Mono';
const THUMB_FONT_FALLBACK = 'sans-serif';
// Dark translucent band behind the metadata row so light text stays readable on any image.
const THUMB_BG = '#101317';
const THUMB_TEXT = '#e8eef5';
const THUMB_MUTED = '#a9bccf';
// Advance width of a monospace glyph as a fraction of font size. DejaVu Sans Mono is 0.6023em;
// rounded up so width estimates never underestimate the real glyph advance.
const MONO_ADVANCE = 0.61;
// Legibility limits for the overlay text. The floor matters most: it is what makes metadata
// readable on a 100px thumbnail instead of a smear of 8px glyphs.
const FONT_MIN_PX = 10;
const FONT_MAX_PX = 13;
// Fraction of the thumbnail's height the metadata band may occupy.
const BAND_HEIGHT_RATIO = 0.5;
// Files the sandbox produces that are safe to render inline in a browser document.
// Anything not listed here (`.html`, `.svg`, `.js`, …) is served as a download so a stored
// artifact can never be loaded as an executable document in the app's origin.
const INLINE_RENDERABLE_MIME = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
]);
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
/**
* Validate a VFS file reference and decide whether it may be rendered inline.
*
* Accepts either the plain VFS name (`example.png`, `labels/a.png`) or a URL pointing at this
* endpoint (`/api/vfs/example.png`), so a model can hand back the exact URL it was given.
* Returns `isInline: false` for anything that is not a known image type — the caller then
* sends `Content-Disposition: attachment` so it downloads instead of rendering.
*
* @param {string} raw
* @returns {{ name: string } | { error: string }}
*/
function parseVfsRef(raw) {
if (typeof raw !== 'string' || !raw.trim()) {
return { error: 'path is required' };
}
let name = raw.trim();
// Accept absolute URLs to this endpoint: strip origin and the /api/vfs prefix.
if (/^https?:\/\//i.test(name)) {
try {
name = new URL(name).pathname;
} catch {
return { error: 'path is not a valid URL' };
}
}
name = name.replace(/^\/+/, '');
// Strip a leading `api/` so both `/vfs/x.png` and `/api/vfs/x.png` resolve the same way.
name = name.replace(/^api\//i, '').replace(/^vfs\//i, '');
// Reject traversal outright. Never let a caller escape the VFS root via `..` segments,
// percent-encoding, or backslashes — this value keys a database lookup, not a disk read,
// but normalising here keeps stored names and requested names identical.
let decoded = name;
try {
decoded = decodeURIComponent(name);
} catch {
return { error: 'path is not valid URL encoding' };
}
if (decoded.includes('\0') || decoded.includes('\\')) {
return { error: 'path contains invalid characters' };
}
const segments = decoded.split('/').filter((s) => s && s !== '.');
if (segments.some((s) => s === '..')) {
return { error: 'path escapes virtual filesystem boundary' };
}
if (segments.length === 0) {
return { error: 'path is required' };
}
const normalized = segments.join('/');
const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase();
const isInline = INLINE_RENDERABLE_MIME.has(MIME_BY_EXTENSION[ext] || '');
return { name: normalized, isInline };
}
// Only the extensions the sandbox actually writes (see getMimeType in tools/vfs.js).
const MIME_BY_EXTENSION = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.svg': 'image/svg+xml',
};
function vfsFileErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'File not found') return 404;
if (error.message === 'File name is required') return 400;
return 500;
}
/**
* Human-readable byte size, e.g. `1.4 MB`.
* @param {number} bytes
*/
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return 'unknown size';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
/** XML-escape for text embedded in the SVG overlay. */
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut.
*
* SVG `<text>` cannot be measured server-side, so widths are estimated from the font's
* monospace advance ratio (slightly conservative, so we never under-estimate).
*
* @param {string} text
* @param {number} maxPx
* @param {number} fontSize
* @returns {string}
*/
function fitToWidth(text, maxPx, fontSize) {
const value = String(text);
const perChar = fontSize * MONO_ADVANCE;
if (perChar <= 0) return value;
if (value.length * perChar <= maxPx) return value;
const room = Math.max(1, Math.floor(maxPx / perChar) - 1);
return `${value.slice(0, room)}…`;
}
/**
* Build the SVG metadata overlay: a translucent "pill" strip along the bottom edge carrying the
* original width, height, file size, MIME type and filename.
*
* This describes the ORIGINAL file, not the thumbnail — that is the whole point of the strip.
*
* @param {{ width: number|null, height: number|null, size: number, mime: string, filename: string }} meta
* @param {number} outWidth rendered overlay width (image width after scaling)
* @param {number} outHeight rendered overlay height (image height after scaling)
* @returns {Buffer} SVG bytes
*/
function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, outHeight) {
const dims = width && height ? `${width}×${height} px` : 'unknown dimensions';
const fields = [dims, formatBytes(size), mime || 'unknown type'];
const SEP = ' · ';
// The band is drawn INSIDE the image, so it must fit within its height. Rather than
// shrinking the font until the metadata is unreadable (the earlier approach, which produced
// 5px text on a 100px-wide thumbnail), the text WRAPS onto as many rows as the image can
// carry, keeping the font at a legible size.
const maxBand = Math.max(0, Math.floor(outHeight * BAND_HEIGHT_RATIO));
const padX0 = Math.max(3, Math.round(FONT_MIN_PX * 0.55));
const usable0 = Math.max(1, outWidth - padX0 * 2);
const charsPerLine = Math.max(6, Math.floor(usable0 / (FONT_MIN_PX * MONO_ADVANCE)));
// Wrap the joined field list into at most `maxRows` lines.
const maxRows = Math.max(1, Math.floor((maxBand - FONT_MIN_PX * 0.7) / (FONT_MIN_PX * 1.25)) - 1);
const full = fields.join(SEP);
let lines = [];
if (full.length <= charsPerLine) {
lines = [full];
} else {
lines = [full];
while (lines.length < maxRows && lines[lines.length - 1].length > charsPerLine) {
const last = lines.pop();
lines.push(last.slice(0, charsPerLine - 1) + '…');
}
// If it still does not fit, break it across the available rows instead of truncating.
if (lines.length === 1 && full.length > charsPerLine) {
lines = [];
for (let i = 0; i < full.length && lines.length < maxRows; i += charsPerLine) {
lines.push(full.slice(i, i + charsPerLine));
}
}
}
const fontSize = FONT_MIN_PX;
const padX = padX0;
const lineHeight = Math.round(fontSize * 1.25);
const barHeight = lineHeight * (lines.length + 1) + Math.round(fontSize * 0.7);
if (barHeight > outHeight) return null;
const y = outHeight - barHeight;
const nameText = fitToWidth(String(filename), outWidth - padX * 2, fontSize);
const lineTexts = lines
.map(
(line, i) =>
`<text x="${padX}" y="${y + lineHeight * (i + 2) - Math.round(fontSize * 0.3)}" ` +
`font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" font-size="${fontSize}" ` +
`fill="${THUMB_MUTED}">${escapeXml(line)}</text>`
)
.join('');
return Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" height="${outHeight}">` +
`<rect x="0" y="${y}" width="${outWidth}" height="${barHeight}" fill="${THUMB_BG}" fill-opacity="0.88"/>` +
`<text x="${padX}" y="${y + lineHeight - Math.round(fontSize * 0.35)}" font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" ` +
`font-size="${fontSize}" fill="${THUMB_TEXT}">${escapeXml(nameText)}</text>` +
lineTexts +
`</svg>`
);
}
/**
* Produce a thumbnail of an image buffer.
*
* Sizing rule: fit inside a 250x250 box with the aspect ratio preserved (`fit: 'inside'`,
* `withoutEnlargement: false` so small images are upscaled), then explicitly upscale anything
* whose larger edge is still under 100 px. The metadata overlay reports the ORIGINAL dimensions.
*
* @param {Buffer} buffer original image bytes
* @param {{ mime: string, filename: string }} info
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
*/
async function renderThumbnail(buffer, { mime, filename }) {
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
let meta;
try {
meta = await pipeline.metadata();
} catch {
return null;
}
if (!meta?.width || !meta?.height) return null;
let scaled = await pipeline
.resize({
width: THUMB_BOX,
height: THUMB_BOX,
fit: 'inside',
// Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
// 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
withoutEnlargement: true,
})
.png()
.toBuffer();
let scaledMeta = await sharp(scaled).metadata();
// Upscale small images so they do not render as an unreadable speck.
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
if (largerEdge < THUMB_MIN_EDGE) {
const factor = THUMB_MIN_EDGE / largerEdge;
scaled = await sharp(scaled)
.resize({
width: Math.max(1, Math.round(scaledMeta.width * factor)),
height: Math.max(1, Math.round(scaledMeta.height * factor)),
fit: 'fill',
})
.png()
.toBuffer();
scaledMeta = await sharp(scaled).metadata();
}
const outWidth = scaledMeta.width;
const outHeight = scaledMeta.height;
const overlay = buildMetaOverlay(
{
width: meta.width,
height: meta.height,
size: buffer.length,
mime,
filename,
},
outWidth,
outHeight
);
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
if (!overlay) {
return {
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
contentType: `image/${THUMB_FORMAT}`,
};
}
const composite = await sharp(scaled)
.composite([{ input: overlay, top: 0, left: 0 }])
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
.toBuffer();
return {
buffer: composite,
contentType: `image/${THUMB_FORMAT}`,
};
}
/**
* Registers virtual filesystem file routes (requires auth).
*
* Serves artifacts the model produced in the `javascript_execute` sandbox so they can be
* embedded in chat markdown, e.g. `![title](/api/vfs/example.png)`.
*
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /vfs/{*path}:
* get:
* tags: [Vfs]
* summary: Get a virtual filesystem file
* description: |
* Returns a file the model produced in the `javascript_execute` sandbox
* (virtual filesystem), for the authenticated user's selected company.
*
* Embed the returned URL in chat markdown to display a generated image:
* `![title](/api/vfs/example.png)`. *
* Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the
* aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger
* edge is upscaled to 100 px. The preview carries a translucent strip along the bottom
* edge listing the ORIGINAL width, height, file size, MIME type and filename. *
* Only image MIME types the sandbox produces are rendered inline; every other file
* type is sent with `Content-Disposition: attachment` so it downloads rather than
* being interpreted as a document in the app's origin.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: path
* required: true
* schema:
* type: string
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
* - in: query
* name: download
* required: false
* schema:
* type: boolean
* description: |
* Return the original, unmodified file instead of the standard preview.
* Without this parameter images are always served as a 250x250 aspect-preserving
* preview with a metadata strip. Ignored for non-image files, which are always
* served as-is.
* responses:
* 200:
* description: File bytes
* headers:
* Content-Disposition:
* schema:
* type: string
* Cache-Control:
* schema:
* type: string
* content:
* application/octet-stream:
* schema:
* type: string
* format: binary
* 400:
* description: Invalid path, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: File not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/vfs/*path`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path;
const parsed = parseVfsRef(raw);
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
try {
const file = await db.read(req.user.id, parsed.name);
if (!file?.content?.length) {
return res.status(404).json({ error: 'File not found' });
}
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
const isInline = parsed.isInline && !wantsDownload;
const fallbackName = parsed.name.split('/').pop() || 'download';
// Images are ALWAYS served as a 250x250 aspect-preserving preview unless the caller
// explicitly asks for the original with `download=1`. The preview carries the original
// width/height/size/mime/filename in a pill strip, so no metadata is lost.
if (isInline) {
const rendered = await renderThumbnail(file.content, {
mime: file.mime,
filename: fallbackName,
}).catch((err) => {
console.error('vfs thumbnail render failed:', err.message);
return null;
});
if (rendered) {
res.setHeader('Content-Type', rendered.contentType);
res.setHeader('Content-Length', String(rendered.buffer.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
// Previews are derived data — never cache them immutably, the source file can change.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
return res.status(304).end();
}
return res.status(200).send(rendered.buffer);
}
// Rendering failed (unsupported/corrupt image) — fall through and serve the original.
}
res.setHeader('Content-Type', file.mime || 'application/octet-stream');
res.setHeader('Content-Length', String(file.content.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
// VFS artifacts are mutable (a re-run can overwrite a name), so they must NOT be
// cached immutably the way content-addressed /image/{hash} is.
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('ETag', `W/"${file.updated_at || file.name}"`);
res.setHeader(
'Content-Disposition',
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
return res.status(304).end();
}
return res.status(200).send(file.content);
} catch (error) {
const status = vfsFileErrorStatus(error);
if (status === 500) console.error('Get vfs file failed:', error);
res.status(status).json({ error: error.message });
}
});
}

View File

@@ -45,6 +45,7 @@ function buildSystemPrompt() {
'- GROSS vs NET PRICES: Users almost always mean GROSS (brutto, incl. VAT) prices when they specify a price (e.g. "1,60 €"), but the API stores and accepts NET prices only. Convert: net = gross / (1 + taxRate/100). Send net with high precision (at least 4–6 decimal places) so rounding back gives the exact gross. Calculate this in javascript_execute.',
'- DATA TYPES & WIRE FORMAT: Dashback strictly validates types (numeric values like weight, price, quantity, IDs must be numbers; flags must be booleans).',
'- VIRTUAL FILESYSTEM & ARTIFACT PERSISTENCE: Inside javascript_execute, you have access to a persistent virtual filesystem via globals fs and vfs. Artifacts persist across runs for the company.',
'- SHOWING GENERATED IMAGES: Markdown images ARE supported and render inline in the chat. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), reference the exact path you wrote with standard markdown `![title](artifacts/example.png)`, which renders inline. The title text is the alt text, so make it descriptive. Use this whenever you generate or modify an image so the user actually sees the result. Other file types (PDF, JSON, CSV) are NOT rendered inline — link to them instead and offer them as a download. NOTE: the `image/` folder is reserved for database images and is read-only; write generated images anywhere else.',
'- LABEL TEMPLATES (/labeltemplate): Label templates are NOT ZPL. content is Dashback\'s JSON label schema: { "objects": [ ... ] }. Do NOT write or suggest ZPL.',
'- Format responses clearly using markdown tables, bullet lists, or bold key-value pairs where appropriate.',
...(routesDoc ? ['', 'DASHBACK API ROUTES:', routesDoc] : []),

View File

@@ -3,6 +3,8 @@
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
import sharp from 'sharp';
import path from 'path';
import fsModule from 'fs';
import { validateObjects, validateObject } from 'react-canvas-label-editor';
import { createVfs } from './vfs.js';
@@ -71,8 +73,139 @@ function createSandboxFetch(apiUrl, token) {
};
}
// WebCrypto, bridged across the vm2 realm boundary.
//
// Handing the raw outer-realm `globalThis.crypto` to the sandbox does NOT work: WebCrypto
// validates its arguments with `instanceof` checks against the OUTER realm's typed arrays,
// so a `Uint8Array` constructed inside the sandbox is rejected ("The data argument must be
// an integer-type TypedArray"), and `crypto.subtle.digest(...)` silently resolves to an empty
// digest instead of throwing. `crypto.randomUUID()` happens to take no argument and works.
//
// So we wrap the object and copy arguments into outer-realm typed arrays before delegating.
// Result arrays are copied back into the sandbox realm so callers get working typed arrays.
function bridgeCrypto() {
if (!globalThis.crypto) return undefined;
const toOuter = (value) => {
if (value instanceof Uint8Array) return new Uint8Array(value);
if (value instanceof Uint16Array) return new Uint16Array(value);
if (value instanceof Uint32Array) return new Uint32Array(value);
if (value instanceof Int8Array) return new Int8Array(value);
if (value instanceof Int16Array) return new Int16Array(value);
if (value instanceof Int32Array) return new Int32Array(value);
if (value instanceof Float32Array) return new Float32Array(value);
if (value instanceof Float64Array) return new Float64Array(value);
if (value instanceof BigInt64Array) return new BigInt64Array(value);
if (value instanceof BigUint64Array) return new BigUint64Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (value instanceof ArrayBuffer) return new Uint8Array(value);
return value;
};
const toInner = (buf) => {
try {
return new Uint8Array(buf);
} catch {
return buf;
}
};
const bridged = {
randomUUID: () => globalThis.crypto.randomUUID(),
getRandomValues: (arr) => globalThis.crypto.getRandomValues(toOuter(arr)),
};
if (globalThis.crypto.subtle) {
bridged.subtle = {
digest: (algo, data) =>
globalThis.crypto.subtle.digest(algo, toOuter(data)).then(toInner),
importKey: (...args) => globalThis.crypto.subtle.importKey(...args),
exportKey: (format, key, algo) =>
globalThis.crypto.subtle.exportKey(format, key, algo).then(toInner),
generateKey: (...args) => globalThis.crypto.subtle.generateKey(...args),
sign: (...args) => globalThis.crypto.subtle.sign(...args).then(toInner),
verify: (...args) => globalThis.crypto.subtle.verify(...args),
encrypt: (...args) => globalThis.crypto.subtle.encrypt(...args).then(toInner),
decrypt: (...args) => globalThis.crypto.subtle.decrypt(...args).then(toInner),
deriveBits: (...args) => globalThis.crypto.subtle.deriveBits(...args).then(toInner),
deriveKey: (...args) => globalThis.crypto.subtle.deriveKey(...args),
};
}
return bridged;
}
const bridgedCrypto = bridgeCrypto();
// `sharp` writes files DIRECTLY via libvips, bypassing Node's `fs`. Injecting the raw library
// therefore lets a snippet write anywhere the server process can — a relative path lands in
// the server's CWD and an absolute/`../` path escapes the VFS entirely. We wrap the two file
// writing entry points (`toFile` and `toFile` on the constructor, plus `toBuffer` passthrough)
// so output paths are resolved through the VFS containment rules.
//
// `toFile` keeps sharp's real signature/return value; only the path is redirected.
function bridgeSharp(vfs) {
const resolveOut = (p) => {
if (typeof p !== 'string' && !(p && typeof p.toString === 'function')) {
throw new TypeError('sharp.toFile(): output path must be a string');
}
const diskPath = vfs.resolveWritePath(String(p));
// libvips creates missing parent directories itself, so `toFile('a/b/c.png')` would
// silently materialise the whole `a/b/` tree. Node's `fs.writeFileSync` instead fails
// with ENOENT. Enforce the fs semantics here so both write paths behave identically and
// a typo'd path reports an error instead of quietly succeeding.
const dir = path.dirname(diskPath);
if (!fsModule.existsSync(dir)) {
const err = new Error(
`ENOENT: no such file or directory, open '${String(p)}'`
);
err.code = 'ENOENT';
err.path = String(p);
err.syscall = 'open';
throw err;
}
return diskPath;
};
// Patch `toFile` on sharp's PROTOTYPE, not on individual instances.
//
// Snippets chain (`sharp(...).png().resize(...).toFile(...)`) and every chained call
// returns a NEW instance, so patching one instance only catches a direct
// `sharp(...).toFile(...)`. Patching the shared prototype catches every derived instance.
const proto = sharp.prototype;
if (proto && typeof proto.toFile === 'function' && !proto.toFile.__vfsPatched) {
const originalToFile = proto.toFile;
const patched = function toFile(file, ...rest) {
return originalToFile.call(this, resolveOut(file), ...rest);
};
patched.__vfsPatched = true;
proto.toFile = patched;
}
// Factory passthrough preserving statics + prototype.
const wrapped = function sharpFactory(...args) {
return sharp(...args);
};
// Preserve statics (sharp.cache, sharp.concurrency, sharp.simd, ...) and prototype.
Object.setPrototypeOf(wrapped, sharp);
for (const key of Object.keys(sharp)) {
try { wrapped[key] = sharp[key]; } catch { /* read-only static */ }
}
return wrapped;
}
const NETWORK_GLOBALS = {
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
// Node's `crypto` MODULE is deliberately not exposed.
...(bridgedCrypto ? { crypto: bridgedCrypto } : {}),
Crypto: globalThis.Crypto,
// Deterministic helpers occasionally needed alongside crypto.
performance: globalThis.performance,
Headers: globalThis.Headers,
Request: globalThis.Request,
Response: globalThis.Response,
@@ -168,12 +301,26 @@ function sanitize(value, depth = 0) {
return { type: t };
}
// Every genuine sandbox report is tagged with `__sandboxReport: true`.
//
// This is REQUIRED, not cosmetic: when the server runs under `node --watch` (see the `dev`
// script), Node installs a module-loader hook that posts `watch:import` notices over the SAME
// parentPort channel the worker reports on. Without this tag the parent cannot tell a real
// report apart from loader chatter and will resolve on the wrong message.
function report(payload) {
const message = { __sandboxReport: true, ...payload };
try {
parentPort.postMessage(payload);
parentPort.postMessage(message);
} catch {
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
parentPort.postMessage({
__sandboxReport: true,
result: null,
error: 'Sandbox produced a result that could not be serialized.',
errorName: 'SerializationError',
stdout: lines.join('\n'),
consoleLineCount: lines.length,
});
} catch { /* thread exiting */ }
}
}
@@ -188,7 +335,7 @@ try {
console: sandboxConsole,
fs: vfs,
vfs,
sharp,
sharp: bridgeSharp(vfs),
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
...NETWORK_GLOBALS,
@@ -217,28 +364,47 @@ try {
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
),
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
),
]);
}
} catch (err) {
const rawMsg = (err && err.message) || String(err);
failed = {
error: (err && err.message) || String(err),
error: rawMsg,
errorName: (err && err.name) || 'Error',
cause: err && err.cause ? ((err.cause && err.cause.message) || String(err.cause)) : undefined,
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
};
// Snippets run in a CommonJS wrapper, so bare top-level `await` is a syntax error.
// That is the single most common mistake, so say exactly how to fix it.
if (err instanceof SyntaxError && /await is only valid|Unexpected reserved word/i.test(rawMsg)) {
failed.error =
'Top-level `await` is not allowed: snippets run in a CommonJS (non-ESM) wrapper. ' +
'Wrap async code in an async IIFE and assign the promise to module.exports:\n' +
' module.exports = (async () => { const res = await fetch("/company"); return res.json(); })();';
}
}
if (failed) {
report({ ...done(), ...failed });
console.error('[javascript-worker] snippet threw:', failed.error, '\n', failed.stack || '(no stack)');
report({ result: null, ...done(), ...failed });
} else {
// `result` MUST always be present: if the key were absent, the parent's
// JSON.stringify() drops it and the tool response degenerates to a bare
// echo of the submitted source with no sign of execution.
report({ result: sanitize(value), ...done() });
}
} catch (err) {
// Setup-level failure (bad VFS dir, thrown import, etc.) — log it here too, because the
// parent only sees a message and the worker's stderr is otherwise swallowed.
console.error('[javascript-worker] fatal setup error:', (err && err.message) || String(err), '\n', (err && err.stack) || '(no stack)');
report({
result: null,
stdout: lines.join('\n'),
consoleLineCount: lines.length,
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack: err && typeof err.stack === 'string' ? err.stack : undefined,

View File

@@ -15,6 +15,11 @@ const MAX_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_OUTPUT = 20_000;
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
// Keys already logged as benign non-sandbox worker chatter. Under `node --watch` the loader
// emits `watch:import` / `watch:require` notices on the worker's parentPort for EVERY import,
// so warning per message floods the log. We warn once per distinct key instead.
const WATCH_NOISE_WARNED = new Set();
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
return new Promise((resolve) => {
let settled = false;
@@ -42,14 +47,57 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
};
const timer = setTimeout(() => {
finish({ timedOut: true, timeoutMs });
console.error(`[javascript_execute] sandbox worker timed out after ${timeoutMs} ms`);
finish({ result: null, stdout: '', consoleLineCount: 0, timedOut: true, timeoutMs });
}, timeoutMs);
worker.on('message', (msg) => finish(msg));
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
worker.on('message', (msg) => {
// Ignore anything that is not a genuine sandbox report. Running the server under
// `node --watch` (the `dev` script) installs a module-loader hook that posts
// `watch:import` notices on this same channel; treating the first message as the
// result made the sandbox appear to run for 0ms and return nothing.
if (!msg || msg.__sandboxReport !== true) {
// Those loader notices fire many times per run, so warn ONCE per key rather
// than on every message — expected, benign noise while running under --watch.
for (const key of Object.keys(msg || {})) {
if (!WATCH_NOISE_WARNED.has(key)) {
WATCH_NOISE_WARNED.add(key);
console.warn(
`[javascript_execute] ignoring non-sandbox worker message "${key}" ` +
'(expected under `node --watch`; logged once per key).'
);
}
}
return;
}
finish(msg);
});
// A worker 'error' fires BEFORE any message when the thread fails to boot (e.g. an
// import throws at startup). ALWAYS send a truthy `error` plus the full stack so the
// parent can throw with something actionable; an empty `err.message` must never
// degrade into a silent, result-less success payload.
worker.on('error', (err) => finish({
result: null,
stdout: '',
consoleLineCount: 0,
error: (err && err.message)
? err.message
: `Sandbox worker failed to start: ${err?.stack || err?.name || 'unknown error'}`,
errorName: (err && err.name) || 'Error',
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
}));
worker.on('exit', (code) => {
// `finish` is idempotent via `settled`, so a message that already resolved the
// promise is never overwritten. Reaching here means the worker died WITHOUT
// reporting — that is a hard failure, so log it and always carry a truthy error.
if (!settled) {
console.error(`[javascript_execute] sandbox worker exited without reporting (code ${code})`);
}
finish({
error: `Sandbox worker exited unexpectedly (code ${code}).`,
result: null,
stdout: '',
consoleLineCount: 0,
error: `Sandbox worker exited unexpectedly without reporting a result (code ${code}).`,
errorName: 'WorkerExit',
});
});
@@ -82,8 +130,18 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
'IMAGES WRITTEN TO THE VFS ARE DISPLAYED INLINE IN THE CHAT. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), ' +
'reference the exact path you wrote with standard markdown `![title](artifacts/example.png)` and it renders as an image inline. ' +
'Note the `image/` folder is reserved for database images and is read-only; write generated images anywhere else. ' +
'Always embed images you generate this way so the user sees them. Other file types do not render inline. ' +
'The `sharp` image library is a pre-injected GLOBAL. ' +
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor.',
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor. ' +
'CRYPTO: only the WebCrypto global `crypto` is available. Node\'s `crypto` module is NOT installed, so ' +
'`require("crypto")` will fail. Available: `crypto.randomUUID()`, `crypto.getRandomValues(typedArray)`, ' +
'and the async `crypto.subtle` API (digest/hash, sign/verify, encrypt/decrypt, importKey/exportKey, deriveBits). ' +
'`crypto.subtle` is async, so use it inside an async IIFE: ' +
'`module.exports = (async () => (await crypto.subtle.digest("SHA-256", data)).byteLength)();`. ' +
'Typed arrays work correctly across the sandbox boundary (verified against Node\'s real digests).',
strict: false,
parameters: {
type: 'object',
@@ -96,7 +154,12 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`.',
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`. ' +
'Bare top-level `await` is a syntax error here — always wrap it in that async IIFE. ' +
'Globals available: `fetch`, `console`, `fs`/`vfs`, `sharp`, `performance`, `URL`, `URLSearchParams`, `Headers`, ' +
'`Request`, `Response`, `FormData`, `Blob`, `File`, `TextEncoder`, `TextDecoder`, `AbortController`, ' +
'`AbortSignal`, `setTimeout`, `clearTimeout`, and WebCrypto `crypto` (`randomUUID`, `getRandomValues`, `subtle`). ' +
'No Node built-ins are installable — `require("crypto")`, `require("fs")`, `require("path")` etc. all fail.',
},
timeout: {
type: 'number',
@@ -124,18 +187,34 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
}
if (report.timedOut) {
return {
code,
timedOut: true,
timeoutMs: report.timeoutMs,
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`,
};
throw new ToolError(
`Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`
);
}
if (report.error) {
throw new ToolError(formatSandboxError(code, 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) {
@@ -143,14 +222,18 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
truncated = true;
}
const result = { code, result: report.result };
if (stdout) result.stdout = stdout;
const result = {
ok: true,
result: report.result,
// Always present, even when empty, so callers can rely on the shape.
stdout,
};
if (truncated) {
result.truncated = true;
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
}
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
result.consoleLines = report.consoleLineCount ?? 0;
result.durationMs = report.durationMs ?? 0;
return result;
},
});

View File

@@ -50,8 +50,8 @@ export function isReadOnlyDatabasePath(filePath) {
function assertNotReadOnlyDatabasePath(filePath) {
if (isReadOnlyDatabasePath(filePath)) {
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
);
}
}
@@ -134,15 +134,32 @@ export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
const sanitizeFsError = (err, virtualPath) => {
if (!err || typeof err.message !== 'string') return err;
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
const message = err.message
.split(vfsRoot)
.join('vfs://')
.split(path.sep)
.join('/')
.replace(/vfs:\/\/\/+/g, 'vfs:///');
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
message,
path: virtual,
});
};
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const resolved = path.resolve(vfsRoot, cleanVirtual);
const relative = path.relative(vfsRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return resolved;
};
@@ -161,32 +178,43 @@ export function createVfs(vfsRoot, options = {}) {
const vfs = {
readFileSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
}
const diskPath = safeResolve(p);
return fs.readFileSync(diskPath, opt);
}
const diskPath = safeResolve(p);
try {
return fs.readFileSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
try {
return fs.writeFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
try {
return fs.appendFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
existsSync: (p) => {
const hash = extractFullHash(p);
if (hash) {
@@ -233,11 +261,14 @@ export function createVfs(vfsRoot, options = {}) {
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.mkdirSync(diskPath, opt);
},
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
@@ -245,12 +276,12 @@ export function createVfs(vfsRoot, options = {}) {
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
@@ -262,9 +293,24 @@ export function createVfs(vfsRoot, options = {}) {
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
unlink: async (p) => vfs.unlinkSync(p),
rm: async (p, opt) => vfs.rmSync(p, opt),
rm: async (p, opt) => vfs.rmSync(p, opt),
},
/**
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
* boundary instead of writing wherever the process CWD happens to be.
*
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
return safeResolve(p);
},
};