From a29ad50b74e9caf0d26e8b74ae74435157644f0e Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 7 Oct 2026 20:36:50 +0200 Subject: [PATCH] u --- docs/api/README.md | 7 + docs/api/routes-all.md | 20 +- docs/api/routes.md | 5 +- docs/api/routes/vfs/_*path.md | 58 ++- docs/api/routes/vfs/delete.md | 42 ++ docs/api/routes/vfs/list.md | 43 ++ docs/api/routes/vfs/mkdir.md | 48 ++ docs/api/routes/vfs/rename.md | 52 +++ docs/api/routes/vfs/upload.md | 53 +++ docs/api/routes/vfs/write.md | 66 +++ scripts/generateApiDocs.js | 3 + src/api/openapi.js | 30 ++ src/api/openapiSpec.js | 1 + src/api/routes/vfs.js | 683 ++++++++++++++++++++++++---- src/services/conversationManager.js | 10 - src/tools/reset-vfs.js | 18 - src/tools/rm.js | 9 +- src/tools/vfs.js | 96 +--- 18 files changed, 1024 insertions(+), 220 deletions(-) create mode 100644 docs/api/routes/vfs/delete.md create mode 100644 docs/api/routes/vfs/list.md create mode 100644 docs/api/routes/vfs/mkdir.md create mode 100644 docs/api/routes/vfs/rename.md create mode 100644 docs/api/routes/vfs/upload.md create mode 100644 docs/api/routes/vfs/write.md diff --git a/docs/api/README.md b/docs/api/README.md index 4ea7ede..13296cf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -207,7 +207,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` | | [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` | | [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | +| [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` | +| [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` | +| [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` | +| [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` | +| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` | | [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` | +| [`routes/vfs/write.md`](routes/vfs/write.md) | `PUT /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` | @@ -227,6 +233,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` | | `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | +| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` | ## DESIGN diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index a4b8c58..0e82162 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 183 API endpoints across all modules. +Overview of all 189 API endpoints across all modules. ### Notation @@ -575,9 +575,27 @@ _none_ #### JTL-POS product catalog synchronization — `GET /v1/product` - Params: `query: lastChangedProduct?: str, limit?: int` - Returns: `200 obj[]` +#### List virtual filesystem files — `GET /vfs` +_none_ +- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }` +#### Create directory in virtual filesystem — `POST /vfs/mkdir` +- Params: `body: { path: str }` +- Returns: `200 { success: bool, dir: str }` +#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename` +- Params: `body: { oldPath: str, newPath: str }` +- Returns: `200 { success: bool, oldPath: str, newPath: str }` +#### Upload files to virtual filesystem — `POST /vfs/upload` +- Params: `form: { files: str[], targetDir?: str }` +- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }` +#### Delete a virtual filesystem file — `DELETE /vfs/{*path}` +- Params: `path: str` +- Returns: `200 { success: bool, deleted: str }` #### Get a virtual filesystem file — `GET /vfs/{*path}` - Params: `path: str, query: download?: bool, raw?: bool` - Returns: `200 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 } }` #### List warehouses — `GET /warehouse` _none_ - Returns: `200 { warehouses: T37[] }` diff --git a/docs/api/routes.md b/docs/api/routes.md index 23b074e..12be455 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 146 API endpoints across all modules. +Overview of all 145 API endpoints across all modules. ### Notation @@ -460,9 +460,6 @@ _none_ #### Upsert unit — `PUT /unit` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }` -#### Get a virtual filesystem file — `GET /vfs/{*path}` -- Params: `path: str, query: download?: bool, raw?: bool` -- Returns: `200 str` #### List warehouses — `GET /warehouse` _none_ - Returns: `200 { warehouses: T34[] }` diff --git a/docs/api/routes/vfs/_*path.md b/docs/api/routes/vfs/_*path.md index a292a87..9273a13 100644 --- a/docs/api/routes/vfs/_*path.md +++ b/docs/api/routes/vfs/_*path.md @@ -4,42 +4,70 @@ 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. +Returns a file from the virtual filesystem for the authenticated user's selected company. +Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users. -Embed the returned URL in chat markdown to display a generated image: -`![title](/api/vfs/example.png)`. +### URL Format +Accepts: +- Relative VFS paths: `example.png`, `reports/summary.pdf` +- Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped) +- Absolute URLs: `https://.../api/vfs/example.png` +- Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400. -Images are returned as a 250x250 aspect-preserving preview unless requested with -`download=1` or `raw=1`. -Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served -inline so they open directly in the browser; other files are sent as downloads. +### Image Previews & Thumbnails +Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`) +are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88): +- Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13). +- Images whose larger edge is below 100px are upscaled to 100px for icon legibility. +- A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`) + is rendered along the bottom edge, displaying original dimensions and file size. +- Served with `Content-Security-Policy: default-src 'none'; sandbox`. +- Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline. + +### Inline vs Attachment +- Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM) + are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers. +- Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`. +- Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`. + +### Caching & Conditional Requests +- Generates weak ETags (`W/"thumb-..."` for previews, `W/"-"` for originals). +- Sets `Cache-Control: private, max-age=0, must-revalidate`. +- Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`. ## Path parameters | Param | Required | Type | Description | |-------|----------|------|-------------| -| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) | +| `path` | yes | string | VFS file path (e.g. `example.png`, `charts/graph.svg`) | ## Query parameters | Param | Required | Type | Description | |-------|----------|------|-------------| -| `download` | no | boolean | Force file download with Content-Disposition: attachment. - | -| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail. - | +| `download` | no | boolean | Force file download with Content-Disposition: attachment (1 or true). | +| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail (1 or true). | ## Response `200` -File bytes +File bytes. Served inline for browser-renderable formats or as a download. +Includes headers: +- `Content-Type`: MIME type of file, or `image/webp` for previews +- `Content-Length`: Size of served payload in bytes +- `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."` +- `Cache-Control`: `private, max-age=0, must-revalidate` +- `ETag`: Weak ETag identifier +- `X-Content-Type-Options`: `nosniff` +- `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails) + ## Errors | Status | Description | |--------|-------------| -| 400 | Invalid path, or no company selected — `{"error":"…"}` | +| 304 | Not modified (If-None-Match matched ETag) | +| 400 | Invalid path, path escapes VFS boundary, or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | File not found — `{"error":"…"}` | diff --git a/docs/api/routes/vfs/delete.md b/docs/api/routes/vfs/delete.md new file mode 100644 index 0000000..395a5cd --- /dev/null +++ b/docs/api/routes/vfs/delete.md @@ -0,0 +1,42 @@ +# `DELETE /vfs/{*path}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete a virtual filesystem file + +Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company. +Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | VFS file path to delete (e.g. `temp.png`, `notes.txt`) | + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +File successfully deleted + +```json +{ + "success": true, + "deleted": "…" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid path, path traversal, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized, or cannot delete read-only database files — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/list.md b/docs/api/routes/vfs/list.md new file mode 100644 index 0000000..3809f9a --- /dev/null +++ b/docs/api/routes/vfs/list.md @@ -0,0 +1,43 @@ +# `GET /vfs` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List virtual filesystem files + +Lists all virtual filesystem files for the authenticated user's selected company. +Scans directories recursively (ignoring hidden dotfiles) and returns file +metadata including path, byte size, detected MIME type, timestamps, and classification flags. + + +## Response `200` + +List of virtual filesystem files + +```json +{ + "files": [ + { + "name": "…", + "size": 1, + "mime": "…", + "updated_at": "2026-08-10T00:00:00.000Z", + "created_at": "2026-08-10T00:00:00.000Z", + "isText": true, + "isImage": true, + "isPdf": true + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/mkdir.md b/docs/api/routes/vfs/mkdir.md new file mode 100644 index 0000000..be081ae --- /dev/null +++ b/docs/api/routes/vfs/mkdir.md @@ -0,0 +1,48 @@ +# `POST /vfs/mkdir` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create directory in virtual filesystem + +Creates a directory (and any necessary parent directories) in the virtual filesystem +for the authenticated user's selected company. + + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | Directory path to create (e.g. `reports/2026/q1`) | + +```json +{ + "path": "…" +} +``` + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +Directory created successfully + +```json +{ + "success": true, + "dir": "…" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized, or cannot create directory inside read-only database paths — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/rename.md b/docs/api/routes/vfs/rename.md new file mode 100644 index 0000000..62a2701 --- /dev/null +++ b/docs/api/routes/vfs/rename.md @@ -0,0 +1,52 @@ +# `POST /vfs/rename` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Rename or move a virtual filesystem file or directory + +Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company. +Parent directories for the destination path are created automatically if needed. + + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `oldPath` | yes | string | Existing VFS file or directory path (e.g. `draft.txt`) | +| `newPath` | yes | string | New destination VFS path (e.g. `archive/final.txt`) | + +```json +{ + "oldPath": "…", + "newPath": "…" +} +``` + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +File or directory successfully renamed + +```json +{ + "success": true, + "oldPath": "…", + "newPath": "…" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized, or cannot rename read-only database paths — `{"error":"…"}` | +| 404 | Source file or directory not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/upload.md b/docs/api/routes/vfs/upload.md new file mode 100644 index 0000000..0a66654 --- /dev/null +++ b/docs/api/routes/vfs/upload.md @@ -0,0 +1,53 @@ +# `POST /vfs/upload` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Upload files to virtual filesystem + +Uploads one or more files to the virtual filesystem for the authenticated user's selected company. +Files are written to the company's VFS root directory and saved to the database. +An optional `targetDir` form field specifies a subfolder within the VFS. + +Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is +forbidden and automatically skipped during upload. + + +## Body (`multipart/form-data`) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `files` | yes | file | One or more files to upload (form field name `files`) | +| `targetDir` | no | string | Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) | + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +Successfully uploaded files + +```json +{ + "success": true, + "uploaded": [ + { + "name": "…", + "size": 1, + "mime": "…" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No files uploaded, invalid path, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/vfs/write.md b/docs/api/routes/vfs/write.md new file mode 100644 index 0000000..2643ccc --- /dev/null +++ b/docs/api/routes/vfs/write.md @@ -0,0 +1,66 @@ +# `PUT /vfs/{*path}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create or overwrite a virtual filesystem file + +Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company. +Supports multiple request payload formats: +- JSON body: `{"content": "text content", "mime": "optional/mime"}` +- Binary body: raw binary buffer with optional `Content-Type` header +- Plain text: raw UTF-8 string + +Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `content` | yes | string | File text content | +| `mime` | no | string | Optional MIME type override | + +```json +{ + "content": "…", + "mime": "…" +} +``` + +## Signals + +On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)). + +## Response `200` + +File successfully written + +```json +{ + "success": true, + "file": { + "name": "…", + "size": 1, + "mime": "…", + "updated_at": "2026-08-10T00:00:00.000Z" + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Path is required, invalid characters/traversal, no content provided, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized, or cannot modify read-only database files — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 57d24b4..ed4234a 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -50,6 +50,9 @@ function isRouteOmitted(method, apiPath) { if (apiPath === '/public' || apiPath.startsWith('/public/')) { return true; } + if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) { + return true; + } return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`); } diff --git a/src/api/openapi.js b/src/api/openapi.js index 4c3cd47..d0f3311 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -1271,6 +1271,36 @@ * type: string * sort: * type: integer + * VfsFile: + * type: object + * required: [name, size, mime, updated_at, created_at, isText, isImage, isPdf] + * properties: + * name: + * type: string + * description: Relative file path within the virtual filesystem + * size: + * type: integer + * description: File size in bytes + * mime: + * type: string + * description: Detected or declared MIME type + * updated_at: + * type: string + * format: date-time + * description: ISO timestamp of last modification + * created_at: + * type: string + * format: date-time + * description: ISO timestamp of creation + * isText: + * type: boolean + * description: True if file is text, code, JSON, Markdown, or tabular data + * isImage: + * type: boolean + * description: True if file is a supported raster image eligible for preview thumbnailing + * isPdf: + * type: boolean + * description: True if file is a PDF document */ export {}; diff --git a/src/api/openapiSpec.js b/src/api/openapiSpec.js index a88ea7e..1f47182 100644 --- a/src/api/openapiSpec.js +++ b/src/api/openapiSpec.js @@ -46,6 +46,7 @@ export const openApiOptions = { { name: 'Warehouse', description: 'Company-scoped warehouses' }, { name: 'Serper', description: 'Serper Google image search and thumbnail streaming' }, { name: 'PublicCatalog', description: 'Public product catalog API' }, + { name: 'Vfs', description: 'Virtual filesystem file management and preview' }, ], }, apis: [ diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 881fc40..a36a235 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -2,10 +2,8 @@ import fs from 'fs'; import path from 'path'; import multer from 'multer'; import { emit } from '../sse.js'; -import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js'; -import * as db from '../../db/modelvfs.js'; +import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js'; import sharp from 'sharp'; -import { getMimeType } from '../../tools/vfs.js'; // 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. @@ -379,61 +377,32 @@ export function register(app, basePath = '') { /** * @openapi - * /vfs/{*path}: + * /vfs: * get: * tags: [Vfs] - * summary: Get a virtual filesystem file + * summary: List virtual filesystem files * 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 returned as a 250x250 aspect-preserving preview unless requested with - * `download=1` or `raw=1`. - * Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served - * inline so they open directly in the browser; other files are sent as downloads. + * Lists all virtual filesystem files for the authenticated user's selected company. + * Scans directories recursively (ignoring hidden dotfiles) and returns file + * metadata including path, byte size, detected MIME type, timestamps, and classification flags. * 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: | - * Force file download with Content-Disposition: attachment. - * - in: query - * name: raw - * required: false - * schema: - * type: boolean - * description: | - * Return original unmodified image inline without generating a preview thumbnail. + * x-markdown: vfs/list.md * responses: * 200: - * description: File bytes - * headers: - * Content-Disposition: - * schema: - * type: string - * Cache-Control: - * schema: - * type: string + * description: List of virtual filesystem files * content: - * application/octet-stream: + * application/json: * schema: - * type: string - * format: binary + * type: object + * required: [files] + * properties: + * files: + * type: array + * items: + * $ref: '#/components/schemas/VfsFile' * 400: - * description: Invalid path, or no company selected + * description: No company selected * content: * application/json: * schema: @@ -450,14 +419,7 @@ export function register(app, basePath = '') { * application/json: * schema: * $ref: '#/components/schemas/Error' - * 404: - * description: File not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' */ - app.get(`${basePath}/vfs`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -506,6 +468,85 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /vfs/upload: + * post: + * tags: [Vfs] + * summary: Upload files to virtual filesystem + * description: | + * Uploads one or more files to the virtual filesystem for the authenticated user's selected company. + * Files are written to the company's VFS root directory and saved to the database. + * An optional `targetDir` form field specifies a subfolder within the VFS. + * + * Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is + * forbidden and automatically skipped during upload. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/upload.md + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: [files] + * properties: + * files: + * type: array + * items: + * type: string + * format: binary + * description: One or more files to upload (form field name `files`) + * targetDir: + * type: string + * description: Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) + * responses: + * 200: + * description: Successfully uploaded files + * content: + * application/json: + * schema: + * type: object + * required: [success, uploaded] + * properties: + * success: + * type: boolean + * uploaded: + * type: array + * items: + * type: object + * required: [name, size, mime] + * properties: + * name: + * type: string + * description: Stored relative path in VFS + * size: + * type: integer + * description: Size in bytes + * mime: + * type: string + * description: Effective MIME type + * 400: + * description: No files uploaded, 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' + */ app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -531,6 +572,7 @@ export function register(app, basePath = '') { if (isReadOnlyDatabasePath(parsed.name)) continue; const absPath = path.resolve(vfsRoot, parsed.name); + if (!absPath.startsWith(vfsRoot)) continue; fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, f.buffer); @@ -538,12 +580,6 @@ export function register(app, basePath = '') { ? f.mimetype : getMimeType(parsed.name); - await db.write(req.user.id, { - name: parsed.name, - mime: effectiveMime, - content: f.buffer, - }); - uploaded.push({ name: parsed.name, size: f.buffer.length, @@ -560,6 +596,305 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /vfs/mkdir: + * post: + * tags: [Vfs] + * summary: Create directory in virtual filesystem + * description: | + * Creates a directory (and any necessary parent directories) in the virtual filesystem + * for the authenticated user's selected company. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/mkdir.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [path] + * properties: + * path: + * type: string + * description: Directory path to create (e.g. `reports/2026/q1`) + * responses: + * 200: + * description: Directory created successfully + * content: + * application/json: + * schema: + * type: object + * required: [success, dir] + * properties: + * success: + * type: boolean + * dir: + * type: string + * description: Created directory path + * 400: + * description: Path is required, invalid characters, traversal, 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, or cannot create directory inside read-only database paths + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post(`${basePath}/vfs/mkdir`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const raw = req.body?.path || req.body?.dir || req.body?.name; + 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 create directories in read-only database paths' }); + } + + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absPath = path.resolve(vfsRoot, parsed.name); + if (!absPath.startsWith(vfsRoot)) { + return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); + } + fs.mkdirSync(absPath, { recursive: true }); + + emit('vfs', { companyId: selected.companyId }); + return res.json({ success: true, dir: parsed.name }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Create vfs directory failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /vfs/rename: + * post: + * tags: [Vfs] + * summary: Rename or move a virtual filesystem file or directory + * description: | + * Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company. + * Parent directories for the destination path are created automatically if needed. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/rename.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [oldPath, newPath] + * properties: + * oldPath: + * type: string + * description: Existing VFS file or directory path (e.g. `draft.txt`) + * newPath: + * type: string + * description: New destination VFS path (e.g. `archive/final.txt`) + * responses: + * 200: + * description: File or directory successfully renamed + * content: + * application/json: + * schema: + * type: object + * required: [success, oldPath, newPath] + * properties: + * success: + * type: boolean + * oldPath: + * type: string + * newPath: + * type: string + * 400: + * description: Path is required, invalid characters, traversal, 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, or cannot rename read-only database paths + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Source file or directory not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post(`${basePath}/vfs/rename`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + const rawOld = req.body?.oldPath || req.body?.from || req.body?.source; + const rawNew = req.body?.newPath || req.body?.to || req.body?.target; + const parsedOld = parseVfsRef(rawOld); + if (parsedOld.error) { + return res.status(400).json({ error: `oldPath: ${parsedOld.error}` }); + } + const parsedNew = parseVfsRef(rawNew); + if (parsedNew.error) { + return res.status(400).json({ error: `newPath: ${parsedNew.error}` }); + } + + if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) { + return res.status(403).json({ error: 'Cannot rename read-only database paths' }); + } + + try { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absOld = path.resolve(vfsRoot, parsedOld.name); + const absNew = path.resolve(vfsRoot, parsedNew.name); + + if (!absOld.startsWith(vfsRoot) || !absNew.startsWith(vfsRoot)) { + return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); + } + + if (!fs.existsSync(absOld)) { + return res.status(404).json({ error: 'Source file or directory not found' }); + } + + fs.mkdirSync(path.dirname(absNew), { recursive: true }); + fs.renameSync(absOld, absNew); + + emit('vfs', { companyId: selected.companyId }); + return res.json({ + success: true, + oldPath: parsedOld.name, + newPath: parsedNew.name, + }); + } catch (error) { + const status = vfsFileErrorStatus(error); + if (status === 500) console.error('Rename vfs file failed:', error); + return res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /vfs/{*path}: + * put: + * tags: [Vfs] + * summary: Create or overwrite a virtual filesystem file + * description: | + * Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company. + * Supports multiple request payload formats: + * - JSON body: `{"content": "text content", "mime": "optional/mime"}` + * - Binary body: raw binary buffer with optional `Content-Type` header + * - Plain text: raw UTF-8 string + * + * Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/write.md + * parameters: + * - in: path + * name: path + * required: true + * schema: + * type: string + * description: Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [content] + * properties: + * content: + * type: string + * description: File text content + * mime: + * type: string + * description: Optional MIME type override + * application/octet-stream: + * schema: + * type: string + * format: binary + * description: Raw binary payload + * text/plain: + * schema: + * type: string + * description: Raw text payload + * responses: + * 200: + * description: File successfully written + * content: + * application/json: + * schema: + * type: object + * required: [success, file] + * properties: + * success: + * type: boolean + * file: + * type: object + * required: [name, size, mime, updated_at] + * properties: + * name: + * type: string + * description: VFS file path + * size: + * type: integer + * description: Written byte length + * mime: + * type: string + * description: Stored MIME type + * updated_at: + * type: string + * format: date-time + * description: ISO timestamp of update + * 400: + * description: Path is required, invalid characters/traversal, no content provided, 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, or cannot modify read-only database files + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.put(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -593,15 +928,12 @@ export function register(app, basePath = '') { try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); + if (!absPath.startsWith(vfsRoot)) { + return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); + } fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, buffer); - await db.write(req.user.id, { - name: parsed.name, - mime, - content: buffer, - }); - emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, @@ -619,6 +951,59 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /vfs/{*path}: + * delete: + * tags: [Vfs] + * summary: Delete a virtual filesystem file + * description: | + * Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company. + * Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403. + * security: + * - cookieAuth: [] + * x-signals: [vfs] + * x-markdown: vfs/delete.md + * parameters: + * - in: path + * name: path + * required: true + * schema: + * type: string + * description: VFS file path to delete (e.g. `temp.png`, `notes.txt`) + * responses: + * 200: + * description: File successfully deleted + * content: + * application/json: + * schema: + * type: object + * required: [success, deleted] + * properties: + * success: + * type: boolean + * deleted: + * type: string + * description: Path of deleted file + * 400: + * description: Invalid path, path traversal, 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, or cannot delete read-only database files + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.delete(`${basePath}/vfs/*path`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -636,10 +1021,18 @@ export function register(app, basePath = '') { try { const vfsRoot = await resolveVfsRoot(req.user.id); const absPath = path.resolve(vfsRoot, parsed.name); - if (fs.existsSync(absPath)) { - try { fs.unlinkSync(absPath); } catch {} + if (!absPath.startsWith(vfsRoot)) { + return res.status(400).json({ error: 'path escapes virtual filesystem boundary' }); + } + if (!fs.existsSync(absPath)) { + return res.status(404).json({ error: 'File not found' }); + } + const stat = fs.statSync(absPath); + if (stat.isDirectory()) { + fs.rmSync(absPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(absPath); } - await db.remove(req.user.id, parsed.name); emit('vfs', { companyId: selected.companyId }); return res.json({ success: true, deleted: parsed.name }); @@ -650,6 +1043,131 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /vfs/{*path}: + * get: + * tags: [Vfs] + * summary: Get a virtual filesystem file + * description: | + * Returns a file from the virtual filesystem for the authenticated user's selected company. + * Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users. + * + * ### URL Format + * Accepts: + * - Relative VFS paths: `example.png`, `reports/summary.pdf` + * - Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped) + * - Absolute URLs: `https://.../api/vfs/example.png` + * - Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400. + * + * ### Image Previews & Thumbnails + * Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`) + * are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88): + * - Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13). + * - Images whose larger edge is below 100px are upscaled to 100px for icon legibility. + * - A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`) + * is rendered along the bottom edge, displaying original dimensions and file size. + * - Served with `Content-Security-Policy: default-src 'none'; sandbox`. + * - Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline. + * + * ### Inline vs Attachment + * - Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM) + * are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers. + * - Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`. + * - Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`. + * + * ### Caching & Conditional Requests + * - Generates weak ETags (`W/"thumb-..."` for previews, `W/"-"` for originals). + * - Sets `Cache-Control: private, max-age=0, must-revalidate`. + * - Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`. + * security: + * - cookieAuth: [] + * x-markdown: vfs/_*path.md + * parameters: + * - in: path + * name: path + * required: true + * schema: + * type: string + * description: VFS file path (e.g. `example.png`, `charts/graph.svg`) + * - in: query + * name: download + * required: false + * schema: + * type: boolean + * description: 'Force file download with Content-Disposition: attachment (1 or true).' + * - in: query + * name: raw + * required: false + * schema: + * type: boolean + * description: 'Return original unmodified image inline without generating a preview thumbnail (1 or true).' + * responses: + * 200: + * description: | + * File bytes. Served inline for browser-renderable formats or as a download. + * Includes headers: + * - `Content-Type`: MIME type of file, or `image/webp` for previews + * - `Content-Length`: Size of served payload in bytes + * - `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."` + * - `Cache-Control`: `private, max-age=0, must-revalidate` + * - `ETag`: Weak ETag identifier + * - `X-Content-Type-Options`: `nosniff` + * - `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails) + * headers: + * Content-Type: + * schema: + * type: string + * Content-Length: + * schema: + * type: string + * Content-Disposition: + * schema: + * type: string + * Cache-Control: + * schema: + * type: string + * ETag: + * schema: + * type: string + * X-Content-Type-Options: + * schema: + * type: string + * Content-Security-Policy: + * schema: + * type: string + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * 304: + * description: Not modified (If-None-Match matched ETag) + * 400: + * description: Invalid path, path escapes VFS boundary, 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) { @@ -663,15 +1181,20 @@ export function register(app, basePath = '') { } try { - const file = await db.read(req.user.id, parsed.name); - if (!file?.content?.length) { + const vfsRoot = await resolveVfsRoot(req.user.id); + const absPath = path.resolve(vfsRoot, parsed.name); + if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) { return res.status(404).json({ error: 'File not found' }); } + const stat = fs.statSync(absPath); + if (stat.isDirectory()) { + return res.status(400).json({ error: 'Cannot download a directory directly' }); + } + const content = fs.readFileSync(absPath); + const updatedAt = stat.mtime.toISOString(); const fallbackName = parsed.name.split('/').pop() || 'download'; - const effectiveMime = (file.mime && file.mime !== 'application/octet-stream') - ? file.mime - : getMimeType(parsed.name); + const effectiveMime = getMimeType(parsed.name); const wantsDownload = req.query.download === '1' || req.query.download === 'true'; const wantsRaw = req.query.raw === '1' || req.query.raw === 'true'; @@ -681,7 +1204,7 @@ export function register(app, basePath = '') { // unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1). const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw; if (shouldThumbnail) { - const rendered = await renderThumbnail(file.content, { + const rendered = await renderThumbnail(content, { mime: effectiveMime, filename: fallbackName, }).catch((err) => { @@ -696,7 +1219,7 @@ export function register(app, basePath = '') { 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}"`); + res.setHeader('ETag', `W/"thumb-${updatedAt || parsed.name}"`); const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) { return res.status(304).end(); @@ -707,11 +1230,11 @@ export function register(app, basePath = '') { } res.setHeader('Content-Type', effectiveMime); - res.setHeader('Content-Length', String(file.content.length)); + res.setHeader('Content-Length', String(content.length)); res.setHeader('X-Content-Type-Options', 'nosniff'); // Mutable VFS artifacts are cached with revalidation res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); - res.setHeader('ETag', `W/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`); + res.setHeader('ETag', `W/"${updatedAt || parsed.name}-${isInline ? 'i' : 'a'}"`); res.setHeader( 'Content-Disposition', `${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"` @@ -722,7 +1245,7 @@ export function register(app, basePath = '') { return res.status(304).end(); } - return res.status(200).send(file.content); + return res.status(200).send(content); } catch (error) { const status = vfsFileErrorStatus(error); if (status === 500) console.error('Get vfs file failed:', error); diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index a229304..54fabd8 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -535,16 +535,6 @@ export class ConversationManager { } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err); } - - try { - await store.modelvfs.write(googleAuthId, { - name: vfsRelPath, - mime, - content: fileBuffer, - }); - } catch (err) { - console.error(`[ConversationManager ${this.conversationId}] failed to write attachment to modelvfs:`, err); - } } const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val); diff --git a/src/tools/reset-vfs.js b/src/tools/reset-vfs.js index be7b364..90b61de 100644 --- a/src/tools/reset-vfs.js +++ b/src/tools/reset-vfs.js @@ -1,7 +1,6 @@ import fs from 'fs'; import path from 'path'; import { defineTool } from 'libllmorbiter'; -import { getStore } from '../db/store.js'; import { isSkillPath } from './skill.js'; import { resolveVfsRoot } from './vfs.js'; @@ -40,25 +39,8 @@ export function createResetVfsTool({ googleAuthId }) { }, }, invoke: async ({ include_skills = false } = {}) => { - const store = await getStore(); const keepSkills = include_skills !== true; - const keptSkills = []; - const existing = await store.modelvfs.list(googleAuthId); - - // Phase 1 — clear the database, but only for the names we may delete. Done before - // touching disk so a store failure cannot leave the disk wiped while the database - // still claims the files exist. - for (const file of existing) { - if (keepSkills && isSkillPath(file.name)) { - keptSkills.push(file.name); - continue; - } - await store.modelvfs.delete(googleAuthId, file.name); - } - - // Phase 2 — mirror the same decision onto the on-disk cache. The root comes from - // the same lookup the store used above, so the two phases cannot drift apart. const vfsRoot = await resolveVfsRoot(googleAuthId); let deletedCount = 0; if (fs.existsSync(vfsRoot)) { diff --git a/src/tools/rm.js b/src/tools/rm.js index 82ddb6f..d5b37b6 100644 --- a/src/tools/rm.js +++ b/src/tools/rm.js @@ -1,7 +1,6 @@ import fs from 'fs'; import path from 'path'; import { defineTool, ToolError } from 'libllmorbiter'; -import { getStore } from '../db/store.js'; import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js'; import { isSkillPath } from './skill.js'; @@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) { const vfsRoot = await prepareVfs({ googleAuthId }); const { abs, size } = deleteVfsFile(vfsRoot, clean); - // Database first. If this throws, the file is still on disk — recoverable, and the row - // still exists to describe it. Doing it the other way round would leave a blob on - // disk that the next prepareVfs hydration would restore, silently undoing the delete. - const store = await getStore(); - const res = await store.modelvfs.delete(googleAuthId, clean); - fs.unlinkSync(abs); const wasSkill = isSkillPath(clean); const result = { success: true, - deleted: res?.deleted !== false, + deleted: true, filename: clean, size, message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`, diff --git a/src/tools/vfs.js b/src/tools/vfs.js index 4ba36d8..3f363fe 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -126,7 +126,7 @@ async function vfsRootFor(store, googleAuthId) { } /** - * Loads default SKILL files into the selected company's VFS (both modelvfs table and disk cache). + * Loads default SKILL files into the selected company's on-disk VFS. * * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx * @returns {Promise>} List of loaded skill file names @@ -140,19 +140,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR const loaded = []; for (const skill of defaultSkills) { - try { - await store.modelvfs.write(googleAuthId, { - name: skill.name, - mime: 'text/markdown', - content: Buffer.from(skill.content, 'utf8'), - }); - } catch (err) { - console.error(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message); - } - try { const diskPath = path.resolve(vfsRoot, skill.name); - fs.writeFileSync(diskPath, skill.content, 'utf8'); + if (!fs.existsSync(diskPath)) { + fs.mkdirSync(path.dirname(diskPath), { recursive: true }); + fs.writeFileSync(diskPath, skill.content, 'utf8'); + } loaded.push(skill.name); } catch (err) { console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message); @@ -178,7 +171,7 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot } /** - * Ensure the local directory for this company's VFS exists and is hydrated from modelvfs. + * Ensure the local directory for this company's VFS exists and default skills are loaded. * * @param {{ googleAuthId: number }} ctx * @returns {Promise} vfsRoot directory path @@ -189,86 +182,21 @@ export async function prepareVfs({ googleAuthId }) { fs.mkdirSync(vfsRoot, { recursive: true }); try { - const files = await store.modelvfs.list(googleAuthId); - const existingNames = new Set(files.map((f) => f.name)); - const defaultSkills = getDefaultSkills(); - for (const skill of defaultSkills) { - if (!existingNames.has(skill.name)) { - try { - await store.modelvfs.write(googleAuthId, { - name: skill.name, - mime: 'text/markdown', - content: Buffer.from(skill.content, 'utf8'), - }); - files.push({ name: skill.name }); - existingNames.add(skill.name); - } catch (err) { - console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message); - } - } - } - for (const file of files) { - const diskPath = path.resolve(vfsRoot, file.name); - if (!diskPath.startsWith(vfsRoot)) continue; - if (!fs.existsSync(diskPath)) { - fs.mkdirSync(path.dirname(diskPath), { recursive: true }); - const fullRecord = await store.modelvfs.read(googleAuthId, file.name); - if (fullRecord?.content) { - fs.writeFileSync(diskPath, fullRecord.content); - } - } - } + await loadDefaultSkills({ googleAuthId, store, vfsRoot }); } catch (err) { - console.warn('[vfs] hydration warning:', err.message); + console.warn('[vfs] prepare warning:', err.message); } return vfsRoot; } - - /** - * Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table. + * Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility. * - * The store re-scopes by the currently selected company on every call, so this only stays - * correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn. - * - * @param {{ googleAuthId: number, vfsRoot: string }} ctx + * @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx] */ -export async function syncVfsToDb({ googleAuthId, vfsRoot }) { - if (!fs.existsSync(vfsRoot)) return; - const store = await getStore(); - - function walk(dir, rel = '') { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const list = []; - for (const e of entries) { - const relPath = rel ? `${rel}/${e.name}` : e.name; - const fullPath = path.join(dir, e.name); - if (e.isDirectory()) { - list.push(...walk(fullPath, relPath)); - } else if (e.isFile()) { - list.push({ relPath, fullPath }); - } - } - return list; - } - - const currentFiles = walk(vfsRoot); - for (const f of currentFiles) { - if (isReadOnlyDatabasePath(f.relPath)) continue; - try { - const content = fs.readFileSync(f.fullPath); - const mime = getMimeType(f.relPath); - await store.modelvfs.write(googleAuthId, { - name: f.relPath, - mime, - content, - }); - } catch (err) { - console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message); - } - } +export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) { + // Purely disk-based VFS; no database syncing. } /**