Compare commits
7 Commits
d0a2ede749
...
5e4cb5a3f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4cb5a3f8 | ||
|
|
0ec993a0b6 | ||
|
|
69a9781cb6 | ||
|
|
7445a573ab | ||
|
|
9c50d9efc3 | ||
|
|
36d62138d4 | ||
|
|
0463003681 |
@@ -48,7 +48,7 @@ Applets run in an isolated worker thread pool sandbox.
|
||||
- **No `require()` or `import`**: Attempting to import modules throws an error.
|
||||
- **Pre-injected Globals**:
|
||||
- `fetch(path, options)`: Authenticated `fetch` pre-configured with a JWT scoped to the current user and selected company. Use root-relative API routes, e.g. `fetch('/article/42000009')`.
|
||||
- `fs` / `vfs`: Sandboxed VFS filesystem interface for reading/writing local company files.
|
||||
- `fs`: Sandboxed filesystem interface for reading/writing local company files.
|
||||
- `ExcelJS`: Pre-injected spreadsheet library.
|
||||
- `PDFDocument`: Pre-injected PDFKit library.
|
||||
- `sharp`: Pre-injected image manipulation library.
|
||||
|
||||
@@ -17,25 +17,23 @@ const log = createLogger('import');
|
||||
* @returns {Promise<{base62: string, width: number, height: number, hashLength: number}|null>}
|
||||
*/
|
||||
export async function computeSkeleHash(buffer) {
|
||||
try {
|
||||
const { data, info } = await sharp(buffer)
|
||||
.rotate()
|
||||
.resize({ width: 200, height: 200, fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = await sharp(buffer)
|
||||
.rotate()
|
||||
.resize({ width: 200, height: 200, fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const result = await encodeRGBA(info.width, info.height, data);
|
||||
return {
|
||||
base62: result.base62,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
hashLength: result.hash?.length ?? 0,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn('Failed to compute skelehash for image:', err.message);
|
||||
return null;
|
||||
const result = await encodeRGBA(info.width, info.height, data);
|
||||
if (!result?.base62) {
|
||||
throw new Error('encodeRGBA failed to produce a base62 skelehash');
|
||||
}
|
||||
return {
|
||||
base62: result.base62,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
hashLength: result.hash?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
import { getImageByHash } from '../db/pic.js';
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
* - 1 image → resized to fit 400×400 and sent as a single WebP.
|
||||
* - 2-9 → composited into a labelled grid (2x1/3x1/2x2/3x2/3x3) with the file name
|
||||
* printed under each cell, so one cheap call can compare many candidates.
|
||||
* - Database images are addressed as `image/<fullHash>` and fetched from `/image/:hash`.
|
||||
* - Database images are addressed as `image/<fullHash>`.
|
||||
*
|
||||
* The prompt is assembled from the caller's `prompt` plus a layout summary, so the model
|
||||
* knows which cell is which file.
|
||||
@@ -41,7 +40,6 @@ const GRID_CONFIGS = {
|
||||
const MAX_IMAGES = 9;
|
||||
const SINGLE_BOX = 400;
|
||||
const WEBP_QUALITY = 80;
|
||||
const IMAGE_TIMEOUT_MS = 10_000;
|
||||
const LABEL_HEIGHT = 26;
|
||||
const LABEL_PAD = 5;
|
||||
|
||||
@@ -241,34 +239,17 @@ function extractImageInputs(args = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a database image by its content hash using store or authenticated local endpoint.
|
||||
* Fetches a database image by its content hash using the database store.
|
||||
* @param {string} hash 8-char SHA-256 Base62
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function fetchDatabaseImage(hash, googleAuthId) {
|
||||
try {
|
||||
const image = await getImageByHash(googleAuthId, hash);
|
||||
if (image?.buffer) {
|
||||
return image.buffer;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
try {
|
||||
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const res = await fetch(url, {
|
||||
headers: { Cookie: `token=${token}` },
|
||||
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
} catch {
|
||||
// fallback error ignored
|
||||
}
|
||||
throw dbErr;
|
||||
const image = await getImageByHash(googleAuthId, hash);
|
||||
if (!image?.buffer) {
|
||||
throw new Error('Image not found');
|
||||
}
|
||||
throw new Error('Image not found');
|
||||
return image.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
import { getImageByHash } from '../db/pic.js';
|
||||
@@ -135,34 +134,17 @@ function resolveImagesApiUrl() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a database image by its 8-char content hash using store or authenticated local endpoint.
|
||||
* Fetches a database image by its content hash using the database store.
|
||||
* @param {string} hash
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function fetchDatabaseImage(hash, googleAuthId) {
|
||||
try {
|
||||
const image = await getImageByHash(googleAuthId, hash);
|
||||
if (image?.buffer) {
|
||||
return image.buffer;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
try {
|
||||
const url = `http://127.0.0.1:${env.port}/api/image/${hash}`;
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const res = await fetch(url, {
|
||||
headers: { Cookie: `token=${token}` },
|
||||
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
} catch {
|
||||
// fallback error ignored
|
||||
}
|
||||
throw dbErr;
|
||||
const image = await getImageByHash(googleAuthId, hash);
|
||||
if (!image?.buffer) {
|
||||
throw new Error('Image not found');
|
||||
}
|
||||
throw new Error('Image not found');
|
||||
return image.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -600,7 +600,6 @@ async function runJob(job) {
|
||||
sandbox: {
|
||||
console: sandboxConsole,
|
||||
fs: vfs,
|
||||
vfs,
|
||||
sharp: bridgeSharp(vfs),
|
||||
ExcelJS: bridgedExcel,
|
||||
exceljs: bridgedExcel,
|
||||
|
||||
@@ -49,7 +49,7 @@ export const TOOL_DESCRIPTIONS = {
|
||||
javascript_execute:
|
||||
'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' +
|
||||
'(.applet.js returning typed CSV per @applet-input-schema, @applet-output-schema, and @applet-description; details in applet.SKILL). ' +
|
||||
'Pre-injected globals: fs/vfs, sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.',
|
||||
'Pre-injected globals: fs (use only root-relative paths for all file operations, absolute paths will fail), sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.',
|
||||
|
||||
find_files:
|
||||
'List VFS files (root-relative paths). Set skills=true to list root-level "<slug>.SKILL" files.',
|
||||
@@ -69,6 +69,9 @@ export const TOOL_DESCRIPTIONS = {
|
||||
web_search:
|
||||
'Search Google via Serper.dev. Use timePeriod "day" or "week" for news/recent events. 10 results/page.',
|
||||
|
||||
search_web_price:
|
||||
'Search Google Shopping for product prices.',
|
||||
|
||||
url_get:
|
||||
'Fetch and scrape web page content (text, markdown, metadata) via Serper Scrape.',
|
||||
|
||||
@@ -190,6 +193,15 @@ export const TOOL_PARAMETER_DESCRIPTIONS = {
|
||||
page: 'Result page number (10/page, default 1).',
|
||||
},
|
||||
|
||||
search_web_price: {
|
||||
note: NOTE_DESC,
|
||||
query: 'Product name or search keywords.',
|
||||
num: 'Number of results to return (default 40).',
|
||||
gl: 'Country code (default "de").',
|
||||
hl: 'Language code (default "de").',
|
||||
page: 'Result page number (default 1).',
|
||||
},
|
||||
|
||||
url_get: {
|
||||
url: 'Target URL.',
|
||||
},
|
||||
|
||||
153
src/tools/search-web-price.js
Normal file
153
src/tools/search-web-price.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import * as dbCompanySetting from '../db/companysetting.js';
|
||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||
|
||||
const SERPER_SHOPPING_ENDPOINT = 'https://google.serper.dev/shopping';
|
||||
const SEARCH_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_NUM = 40;
|
||||
|
||||
/**
|
||||
* search_web_price — Google Shopping search via Serper.dev.
|
||||
*
|
||||
* Queries Google Shopping via Serper and returns the full `shopping` results array
|
||||
* containing product offers, vendor sources, prices, links, ratings, and image URLs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Queries Serper for Google Shopping results.
|
||||
*
|
||||
* @param {string} query
|
||||
* @param {string} apiKey
|
||||
* @param {{ gl?: string, hl?: string, num?: number, page?: number, signal?: AbortSignal }} [options]
|
||||
* @returns {Promise<Array<object>>} raw `shopping` array from Serper
|
||||
*/
|
||||
async function serperShoppingSearch(query, apiKey, { gl = 'de', hl = 'de', num = DEFAULT_NUM, page = 1, signal } = {}) {
|
||||
const headers = {
|
||||
'X-API-KEY': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
|
||||
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
|
||||
const payload = {
|
||||
q: query,
|
||||
gl,
|
||||
hl,
|
||||
num,
|
||||
};
|
||||
if (page && page > 1) payload.page = page;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(SERPER_SHOPPING_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
redirect: 'follow',
|
||||
signal: combined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ToolError(`search_web_price: network failure contacting Serper (${query}): ${err.message}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.text();
|
||||
if (body) detail += ` - ${body.slice(0, 300)}`;
|
||||
} catch { /* body already consumed or unreadable */ }
|
||||
throw new ToolError(`search_web_price: Serper API error: ${detail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return Array.isArray(data?.shopping) ? data.shopping : (data?.shopping || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the search_web_price tool.
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
*/
|
||||
export function createSearchWebPriceTool({ googleAuthId }) {
|
||||
return defineTool({
|
||||
name: 'search_web_price',
|
||||
description: TOOL_DESCRIPTIONS.search_web_price,
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['query'],
|
||||
properties: {
|
||||
note: {
|
||||
type: ['string', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.note,
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.query,
|
||||
},
|
||||
num: {
|
||||
type: ['number', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.num,
|
||||
},
|
||||
gl: {
|
||||
type: ['string', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.gl,
|
||||
},
|
||||
hl: {
|
||||
type: ['string', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.hl,
|
||||
},
|
||||
page: {
|
||||
type: ['number', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.search_web_price?.page,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoke: async ({ query, num = DEFAULT_NUM, count, gl = 'de', hl = 'de', page = 1, note } = {}, { signal } = {}) => {
|
||||
const term = typeof query === 'string' ? query.trim() : '';
|
||||
if (!term) {
|
||||
throw new ToolError('search_web_price requires a non-empty "query".');
|
||||
}
|
||||
|
||||
const rawNum = num ?? count;
|
||||
const resolvedNum = rawNum != null ? Number(rawNum) : DEFAULT_NUM;
|
||||
if (!Number.isInteger(resolvedNum) || resolvedNum < 1) {
|
||||
throw new ToolError(`"num" must be a positive integer (received ${rawNum}).`);
|
||||
}
|
||||
|
||||
const parsedPage = page != null ? Number(page) : 1;
|
||||
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
|
||||
throw new ToolError(`"page" must be a positive integer (received ${page}).`);
|
||||
}
|
||||
|
||||
const resolvedGl = gl || 'de';
|
||||
const resolvedHl = hl || 'de';
|
||||
|
||||
let apiKey = null;
|
||||
try {
|
||||
const setting = await dbCompanySetting.getByKey(googleAuthId, 'keys.serper', { maskWriteonly: false });
|
||||
apiKey = setting?.value?.trim() || null;
|
||||
} catch (err) {
|
||||
console.warn('search_web_price: failed to read company setting keys.serper:', err?.message || err);
|
||||
}
|
||||
if (!apiKey && process.env.SERPER_API_KEY) {
|
||||
apiKey = process.env.SERPER_API_KEY.trim();
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new ToolError(
|
||||
'search_web_price is unavailable: Serper API key not configured (companysetting: keys.serper).'
|
||||
);
|
||||
}
|
||||
|
||||
return await serperShoppingSearch(term, apiKey, {
|
||||
gl: resolvedGl,
|
||||
hl: resolvedHl,
|
||||
num: resolvedNum,
|
||||
page: parsedPage,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { createSearchImagesTool } from './search-images.js';
|
||||
import { createAnalyzeImageTool } from './analyze-image.js';
|
||||
import { createEditImageTool } from './edit-image.js';
|
||||
import { createWebSearchTool } from './web-search.js';
|
||||
import { createSearchWebPriceTool } from './search-web-price.js';
|
||||
import { createUrlGetTool } from './url-get.js';
|
||||
import { createPdfReadTool } from './pdf-read.js';
|
||||
import { createResetVfsTool } from './reset-vfs.js';
|
||||
@@ -174,6 +175,7 @@ function createSubagentTools({ googleAuthId, companyId, model }) {
|
||||
createAnalyzeImageTool({ googleAuthId, model }),
|
||||
createEditImageTool({ googleAuthId, model }),
|
||||
createWebSearchTool({ googleAuthId }),
|
||||
createSearchWebPriceTool({ googleAuthId }),
|
||||
createUrlGetTool({ googleAuthId }),
|
||||
createPdfReadTool({ googleAuthId }),
|
||||
createResetVfsTool({ googleAuthId }),
|
||||
|
||||
595
src/tools/vfs.js
595
src/tools/vfs.js
@@ -1,62 +1,75 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getStore } from '../db/store.js';
|
||||
import env from '../config/env.js';
|
||||
import { getDefaultSkills } from './skill.js';
|
||||
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
|
||||
import { getDefaultApplets, DEFAULT_APPLETS_VERSION } from './applet.js';
|
||||
|
||||
// #region Constants & Configuration
|
||||
// MARK: - Constants & Configuration
|
||||
|
||||
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
|
||||
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
||||
|
||||
const MIME_TYPES = Object.freeze({
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.avif': 'image/avif',
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.bmp': 'image/bmp',
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.text': 'text/plain; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.tsv': 'text/tab-separated-values; charset=utf-8',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'.xltx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.skill': 'text/markdown; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region MIME & Path Helpers
|
||||
// MARK: - MIME & Path Helpers
|
||||
|
||||
export function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.html':
|
||||
case '.htm': return 'text/html; charset=utf-8';
|
||||
case '.css': return 'text/css; charset=utf-8';
|
||||
case '.js':
|
||||
case '.mjs': return 'text/javascript; charset=utf-8';
|
||||
case '.json':
|
||||
case '.map': return 'application/json; charset=utf-8';
|
||||
case '.avif': return 'image/avif';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.svg': return 'image/svg+xml';
|
||||
case '.ico': return 'image/x-icon';
|
||||
case '.bmp': return 'image/bmp';
|
||||
case '.pdf': return 'application/pdf';
|
||||
case '.txt':
|
||||
case '.text':
|
||||
case '.log': return 'text/plain; charset=utf-8';
|
||||
case '.csv': return 'text/csv; charset=utf-8';
|
||||
case '.tsv': return 'text/tab-separated-values; charset=utf-8';
|
||||
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case '.xls': return 'application/vnd.ms-excel';
|
||||
case '.xlsm': return 'application/vnd.ms-excel.sheet.macroEnabled.12';
|
||||
case '.xltx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.template';
|
||||
case '.md':
|
||||
case '.skill': return 'text/markdown; charset=utf-8';
|
||||
case '.xml': return 'application/xml; charset=utf-8';
|
||||
case '.mp3': return 'audio/mpeg';
|
||||
case '.wav': return 'audio/wav';
|
||||
case '.ogg': return 'audio/ogg';
|
||||
case '.mp4': return 'video/mp4';
|
||||
case '.webm': return 'video/webm';
|
||||
case '.woff': return 'font/woff';
|
||||
case '.woff2': return 'font/woff2';
|
||||
case '.ttf': return 'font/ttf';
|
||||
case '.otf': return 'font/otf';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
return MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function cleanVirtualPath(p, stripDotSlash = false) {
|
||||
let clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
if (stripDotSlash) clean = clean.replace(/^\.\/+/, '');
|
||||
return clean;
|
||||
}
|
||||
|
||||
export function extractFullHash(p) {
|
||||
if (typeof p !== 'string' || !p.trim()) return null;
|
||||
const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const parts = clean.split('/').filter(Boolean);
|
||||
const parts = cleanVirtualPath(p).split('/').filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const first = parts[0].toLowerCase();
|
||||
@@ -65,10 +78,8 @@ export function extractFullHash(p) {
|
||||
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
|
||||
|
||||
// Under virtual image folder (e.g. image/aB1cD2eF, images/aB1cD2eF.avif)
|
||||
if (VIRTUAL_IMAGE_FOLDERS.has(first)) {
|
||||
if (parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
|
||||
return withoutExt;
|
||||
}
|
||||
if (VIRTUAL_IMAGE_FOLDERS.has(first) && parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
|
||||
return withoutExt;
|
||||
}
|
||||
|
||||
// Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif")
|
||||
@@ -81,33 +92,27 @@ export function extractFullHash(p) {
|
||||
|
||||
export function isReadOnlyDatabasePath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
||||
const clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
const clean = cleanVirtualPath(filePath, true);
|
||||
const lower = clean.toLowerCase();
|
||||
if (lower === 'image' || lower.startsWith('image/')) return true;
|
||||
if (extractFullHash(clean)) return true;
|
||||
return false;
|
||||
return lower === 'image' || lower.startsWith('image/') || Boolean(extractFullHash(clean));
|
||||
}
|
||||
|
||||
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").`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region VFS Root Resolution
|
||||
// MARK: - VFS Root Resolution
|
||||
|
||||
/**
|
||||
* Resolve the on-disk VFS root for the user's CURRENTLY SELECTED company.
|
||||
*
|
||||
* This must stay in lockstep with the store, which scopes every `modelvfs` call by
|
||||
* `google_auth.selected_company` read at call time — see `resolveSelectedCompanyAccess` in
|
||||
* libdashstore. Callers therefore must NOT pass a `companyId`: a value captured earlier (at
|
||||
* tool-construction time, or from a request made before a company switch) can disagree with
|
||||
* what the store will act on, which would hydrate one company's directory from another
|
||||
* company's rows and delete files from a directory the database never touched.
|
||||
*
|
||||
* One authority, read fresh on every call, for both the disk and the database halves.
|
||||
*
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<string>} Absolute `data/vfs/<companyId>` path
|
||||
* @throws {Error} 'No company selected' when the user has no company selected
|
||||
@@ -117,9 +122,7 @@ export async function resolveVfsRoot(googleAuthId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared implementation of {@link resolveVfsRoot}. Takes an already-resolved store so callers
|
||||
* that need it anyway (e.g. `prepareVfs`, which hydrates from the same store) do not pay for a
|
||||
* second lookup.
|
||||
* Shared implementation of {@link resolveVfsRoot}.
|
||||
*
|
||||
* @param {object} store
|
||||
* @param {number} googleAuthId
|
||||
@@ -134,125 +137,87 @@ async function vfsRootFor(store, googleAuthId) {
|
||||
return path.resolve(process.cwd(), 'data/vfs', String(companyId));
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Default Skills & Applets Seeding
|
||||
// MARK: - Default Skills & Applets Seeding
|
||||
|
||||
/**
|
||||
* Loads default SKILL files into the selected company's on-disk VFS.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of loaded skill file names
|
||||
* Internal helper to write or overwrite default files into the company's VFS.
|
||||
*/
|
||||
export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
|
||||
async function seedVfsFiles({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot }, items, { overwrite, label }) {
|
||||
const store = passedStore || await getStore();
|
||||
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
const defaultSkills = getDefaultSkills();
|
||||
const loaded = [];
|
||||
|
||||
for (const skill of defaultSkills) {
|
||||
const written = [];
|
||||
for (const { name, content } of items) {
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, skill.name);
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
const diskPath = path.resolve(vfsRoot, name);
|
||||
if (overwrite || !fs.existsSync(diskPath)) {
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
fs.writeFileSync(diskPath, skill.content, 'utf8');
|
||||
loaded.push(skill.name);
|
||||
fs.writeFileSync(diskPath, content, 'utf8');
|
||||
written.push(name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
|
||||
const action = overwrite ? `restore default ${label}` : `write default ${label}`;
|
||||
const suffix = overwrite ? '' : ' to disk';
|
||||
console.error(`[vfs] failed to ${action} "${name}"${suffix}:`, err.message);
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
return loaded;
|
||||
function getVersionedApplets() {
|
||||
return getDefaultApplets().map((a) => ({
|
||||
name: `${DEFAULT_APPLETS_VERSION}-${a.name}`,
|
||||
content: a.content,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default SKILL files into the selected company's on-disk VFS.
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function loadDefaultSkills(ctx = {}) {
|
||||
return seedVfsFiles(ctx, getDefaultSkills(), { overwrite: false, label: 'skill' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all shipped system default SKILL files into the company's VFS.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of restored skill file names
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function restoreDefaultSkills({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
|
||||
const store = passedStore || await getStore();
|
||||
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
const defaultSkills = getDefaultSkills();
|
||||
const restored = [];
|
||||
|
||||
for (const skill of defaultSkills) {
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, skill.name);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
fs.writeFileSync(diskPath, skill.content, 'utf8');
|
||||
restored.push(skill.name);
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to restore default skill "${skill.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return restored;
|
||||
export async function restoreDefaultSkills(ctx = {}) {
|
||||
return seedVfsFiles(ctx, getDefaultSkills(), { overwrite: true, label: 'skill' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the shipped example `.applet.js` files into the selected company's VFS.
|
||||
*
|
||||
* Version-gated: each example is written under a `v1-` prefixed name and only when that exact
|
||||
* file is absent, so an operator's edits survive and a new example revision ships as a new file.
|
||||
* Must stay in step with {@link loadDefaultSkills}, which runs alongside it.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of seeded applet file names
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
|
||||
const store = passedStore || await getStore();
|
||||
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
const seeded = [];
|
||||
for (const applet of getDefaultApplets()) {
|
||||
const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`;
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, name);
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
fs.writeFileSync(diskPath, applet.content, 'utf8');
|
||||
seeded.push(name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to write default applet "${name}" to disk:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return seeded;
|
||||
export async function loadDefaultApplets(ctx = {}) {
|
||||
return seedVfsFiles(ctx, getVersionedApplets(), { overwrite: false, label: 'applet' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all shipped system default example applets into the company's VFS.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of restored applet file names
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function restoreDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
|
||||
const store = passedStore || await getStore();
|
||||
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
const restored = [];
|
||||
for (const applet of getDefaultApplets()) {
|
||||
const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`;
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, name);
|
||||
fs.writeFileSync(diskPath, applet.content, 'utf8');
|
||||
restored.push(name);
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to restore default applet "${name}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return restored;
|
||||
export async function restoreDefaultApplets(ctx = {}) {
|
||||
return seedVfsFiles(ctx, getVersionedApplets(), { overwrite: true, label: 'applet' });
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Lifecycle & Initialization
|
||||
// MARK: - Lifecycle & Initialization
|
||||
|
||||
/**
|
||||
* Initializes the VFS for the selected company with default SKILL files and example applets.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
*/
|
||||
@@ -269,7 +234,6 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot
|
||||
/**
|
||||
* Ensure the local directory for this company's VFS exists and the default skills and example
|
||||
* applets are loaded.
|
||||
*
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
*/
|
||||
@@ -295,13 +259,42 @@ export async function prepareVfs({ googleAuthId }) {
|
||||
|
||||
/**
|
||||
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
|
||||
*
|
||||
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
|
||||
*/
|
||||
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
|
||||
export async function syncVfsToDb() {
|
||||
// Purely disk-based VFS; no database syncing.
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Sandboxed VFS Factory
|
||||
// MARK: - Sandboxed VFS Factory
|
||||
|
||||
/**
|
||||
* Wraps a synchronous function into a Node-style callback function (`process.nextTick`).
|
||||
* Automatically handles optional middle arguments (e.g. `(p, opt, cb)` vs `(p, cb)`).
|
||||
*/
|
||||
function cbWrap(syncFn, arityWithOpt = 3) {
|
||||
return (...args) => {
|
||||
let cb;
|
||||
let callArgs = args;
|
||||
if (typeof args[args.length - 1] === 'function') {
|
||||
cb = args[args.length - 1];
|
||||
callArgs = args.slice(0, -1);
|
||||
while (callArgs.length < arityWithOpt - 1) {
|
||||
callArgs.push(undefined);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = syncFn(...callArgs);
|
||||
if (cb) process.nextTick(cb, null, res);
|
||||
} catch (err) {
|
||||
if (cb) process.nextTick(cb, err);
|
||||
else throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
|
||||
* @param {string} vfsRoot Absolute root, as returned by `prepareVfs`/`resolveVfsRoot`
|
||||
@@ -310,8 +303,9 @@ 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.
|
||||
// #region Internal Sandbox Helpers
|
||||
// MARK: - Internal Sandbox Helpers
|
||||
|
||||
const sanitizeFsError = (err, virtualPath) => {
|
||||
if (!err || typeof err.message !== 'string') return err;
|
||||
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
|
||||
@@ -322,7 +316,7 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
.join('/')
|
||||
.replace(/vfs:\/\/\/+/g, 'vfs:///');
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
|
||||
message,
|
||||
message,
|
||||
path: virtual,
|
||||
});
|
||||
};
|
||||
@@ -331,11 +325,10 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
}
|
||||
const cleanVirtual = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const resolved = path.resolve(vfsRoot, cleanVirtual);
|
||||
const resolved = path.resolve(vfsRoot, cleanVirtualPath(p));
|
||||
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;
|
||||
};
|
||||
@@ -343,24 +336,47 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
const syncFetchImage = (hash) => {
|
||||
if (!hash) return null;
|
||||
try {
|
||||
// Read from storage or cache if available
|
||||
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
|
||||
if (fs.existsSync(cachePath)) return fs.readFileSync(cachePath);
|
||||
return null;
|
||||
return fs.existsSync(cachePath) ? fs.readFileSync(cachePath) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBuffer = (buf, opt) => {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
};
|
||||
|
||||
const writeCopyBuffer = (buf, src, dest, mode) => {
|
||||
const destDisk = safeResolve(dest);
|
||||
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
||||
if (typeof mode === 'number' && (mode & fs.constants.COPYFILE_EXCL) && fs.existsSync(destDisk)) {
|
||||
const err = new Error(`EEXIST: file already exists, copyfile '${src}' -> '${dest}'`);
|
||||
err.code = 'EEXIST';
|
||||
err.path = dest;
|
||||
err.syscall = 'copyfile';
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return fs.writeFileSync(destDisk, buf);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, dest);
|
||||
}
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region VFS Core Operations
|
||||
// MARK: - VFS Core Operations
|
||||
|
||||
const vfs = {
|
||||
// --- Read Operations ---
|
||||
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;
|
||||
}
|
||||
if (buf) return formatBuffer(buf, opt);
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
@@ -372,8 +388,8 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
readFile: (p, opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : cb;
|
||||
const options = typeof opt === 'function' ? undefined : opt;
|
||||
vfs.promises.readFile(p, options)
|
||||
const opts = typeof opt === 'function' ? undefined : opt;
|
||||
vfs.promises.readFile(p, opts)
|
||||
.then((data) => {
|
||||
if (typeof callback === 'function') process.nextTick(callback, null, data);
|
||||
})
|
||||
@@ -395,6 +411,7 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
return fs.createReadStream(diskPath, opt);
|
||||
},
|
||||
|
||||
// --- Write & Append Operations ---
|
||||
writeFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
@@ -406,18 +423,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
writeFile: (p, data, opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : cb;
|
||||
const options = typeof opt === 'function' ? undefined : opt;
|
||||
try {
|
||||
vfs.writeFileSync(p, data, options);
|
||||
if (typeof callback === 'function') process.nextTick(callback, null);
|
||||
} catch (err) {
|
||||
if (typeof callback === 'function') process.nextTick(callback, err);
|
||||
else throw err;
|
||||
}
|
||||
},
|
||||
|
||||
createWriteStream: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
@@ -435,20 +440,110 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
existsSync: (p) => {
|
||||
const hash = extractFullHash(p);
|
||||
|
||||
// --- Copy, Rename & Truncate Operations ---
|
||||
copyFileSync: (src, dest, mode) => {
|
||||
assertNotReadOnlyDatabasePath(dest);
|
||||
const hash = extractFullHash(src);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) return true;
|
||||
if (buf) return writeCopyBuffer(buf, src, dest, mode);
|
||||
}
|
||||
const srcDisk = safeResolve(src);
|
||||
const destDisk = safeResolve(dest);
|
||||
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
||||
try {
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.existsSync(diskPath);
|
||||
return fs.copyFileSync(srcDisk, destDisk, mode);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, `${src} -> ${dest}`);
|
||||
}
|
||||
},
|
||||
|
||||
cpSync: (src, dest, opt) => {
|
||||
assertNotReadOnlyDatabasePath(dest);
|
||||
const srcDisk = safeResolve(src);
|
||||
const destDisk = safeResolve(dest);
|
||||
fs.mkdirSync(path.dirname(destDisk), { recursive: true });
|
||||
try {
|
||||
return fs.cpSync(srcDisk, destDisk, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, `${src} -> ${dest}`);
|
||||
}
|
||||
},
|
||||
|
||||
renameSync: (oldPath, newPath) => {
|
||||
assertNotReadOnlyDatabasePath(oldPath);
|
||||
assertNotReadOnlyDatabasePath(newPath);
|
||||
const oldDisk = safeResolve(oldPath);
|
||||
const newDisk = safeResolve(newPath);
|
||||
fs.mkdirSync(path.dirname(newDisk), { recursive: true });
|
||||
try {
|
||||
return fs.renameSync(oldDisk, newDisk);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, `${oldPath} -> ${newPath}`);
|
||||
}
|
||||
},
|
||||
|
||||
truncateSync: (p, len) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.truncateSync(diskPath, len);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
// --- Metadata & Access Operations ---
|
||||
accessSync: (p, mode) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash && syncFetchImage(hash)) {
|
||||
if (typeof mode === 'number' && (mode & fs.constants.W_OK)) {
|
||||
const err = new Error(`EACCES: permission denied, access '${p}'`);
|
||||
err.code = 'EACCES';
|
||||
err.path = p;
|
||||
err.syscall = 'access';
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.accessSync(diskPath, mode);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
realpathSync: (p, opt) => {
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
const resolved = fs.realpathSync(diskPath, opt);
|
||||
const rel = path.relative(vfsRoot, resolved);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
|
||||
}
|
||||
return rel ? rel.split(path.sep).join('/') : '.';
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
existsSync: (p) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash && syncFetchImage(hash)) return true;
|
||||
try {
|
||||
return fs.existsSync(safeResolve(p));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
exists: (p, cb) => {
|
||||
const res = vfs.existsSync(p);
|
||||
if (typeof cb === 'function') process.nextTick(cb, res);
|
||||
},
|
||||
|
||||
statSync: (p, opt) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
@@ -466,18 +561,40 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.statSync(diskPath, opt);
|
||||
try {
|
||||
return fs.statSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
||||
|
||||
// --- Directory & Removal Operations ---
|
||||
readdirSync: (p = '.', opt) => {
|
||||
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
|
||||
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
|
||||
return [];
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.readdirSync(diskPath, opt);
|
||||
try {
|
||||
return fs.readdirSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
readdir: (p = '.', opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : (typeof p === 'function' ? p : cb);
|
||||
const opts = typeof opt === 'function' ? undefined : opt;
|
||||
const pathArg = typeof p === 'function' ? '.' : p;
|
||||
try {
|
||||
const res = vfs.readdirSync(pathArg, opts);
|
||||
if (typeof callback === 'function') process.nextTick(callback, null, res);
|
||||
} catch (err) {
|
||||
if (typeof callback === 'function') process.nextTick(callback, err);
|
||||
else throw err;
|
||||
}
|
||||
},
|
||||
|
||||
mkdirSync: (p, opt) => {
|
||||
@@ -489,33 +606,48 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
unlinkSync: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.unlinkSync(diskPath);
|
||||
try {
|
||||
return fs.unlinkSync(diskPath);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
rmdirSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmdirSync(diskPath, opt);
|
||||
try {
|
||||
return fs.rmdirSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
rmSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmSync(diskPath, opt);
|
||||
try {
|
||||
return fs.rmSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region VFS Promises API
|
||||
// MARK: - VFS Promises API
|
||||
|
||||
promises: {
|
||||
readFile: async (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;
|
||||
}
|
||||
if (buf) return formatBuffer(buf, opt);
|
||||
if (googleAuthId) {
|
||||
try {
|
||||
const store = await getStore();
|
||||
@@ -526,8 +658,7 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true });
|
||||
fs.writeFileSync(cachePath, img.buffer);
|
||||
} catch { /* ignore cache write error */ }
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? img.buffer.toString(encoding) : img.buffer;
|
||||
return formatBuffer(img.buffer, opt);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
@@ -538,23 +669,39 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
},
|
||||
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
|
||||
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
|
||||
copyFile: async (src, dest, mode) => {
|
||||
assertNotReadOnlyDatabasePath(dest);
|
||||
const hash = extractFullHash(src);
|
||||
if (hash) {
|
||||
const buf = await vfs.promises.readFile(src);
|
||||
return writeCopyBuffer(buf, src, dest, mode);
|
||||
}
|
||||
return vfs.copyFileSync(src, dest, mode);
|
||||
},
|
||||
cp: async (src, dest, opt) => vfs.cpSync(src, dest, opt),
|
||||
rename: async (oldPath, newPath) => vfs.renameSync(oldPath, newPath),
|
||||
truncate: async (p, len) => vfs.truncateSync(p, len),
|
||||
access: async (p, mode) => vfs.accessSync(p, mode),
|
||||
realpath: async (p, opt) => vfs.realpathSync(p, opt),
|
||||
stat: async (p, opt) => vfs.statSync(p, opt),
|
||||
lstat: async (p, opt) => vfs.lstatSync(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),
|
||||
rmdir: async (p, opt) => vfs.rmdirSync(p, opt),
|
||||
unlink: async (p) => vfs.unlinkSync(p),
|
||||
rm: async (p, opt) => vfs.rmSync(p, opt),
|
||||
rm: async (p, opt) => vfs.rmSync(p, opt),
|
||||
constants: fs.constants,
|
||||
},
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region External Sandbox Utilities
|
||||
// MARK: - External Sandbox Utilities
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
* containment + read-only guards as `writeFileSync`.
|
||||
*/
|
||||
resolveWritePath: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
return safeResolve(p);
|
||||
@@ -563,15 +710,43 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
/**
|
||||
* Resolve a virtual path to an absolute on-disk path for reading, applying containment checks.
|
||||
*/
|
||||
resolveReadPath: (p) => {
|
||||
return safeResolve(p);
|
||||
},
|
||||
resolveReadPath: (p) => safeResolve(p),
|
||||
|
||||
/**
|
||||
* Sanitize on-disk error paths back to virtual paths for display in sandbox transcripts.
|
||||
*/
|
||||
sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath),
|
||||
|
||||
constants: fs.constants,
|
||||
|
||||
// #endregion
|
||||
};
|
||||
|
||||
// #region Callback Wrapper Bindings
|
||||
// MARK: - Callback Wrapper Bindings
|
||||
|
||||
vfs.writeFile = cbWrap(vfs.writeFileSync, 4);
|
||||
vfs.appendFile = cbWrap(vfs.appendFileSync, 4);
|
||||
vfs.copyFile = cbWrap(vfs.copyFileSync, 4);
|
||||
vfs.cp = cbWrap(vfs.cpSync, 4);
|
||||
vfs.rename = cbWrap(vfs.renameSync, 3);
|
||||
vfs.truncate = cbWrap(vfs.truncateSync, 3);
|
||||
vfs.access = cbWrap(vfs.accessSync, 3);
|
||||
vfs.realpath = cbWrap(vfs.realpathSync, 3);
|
||||
vfs.stat = cbWrap(vfs.statSync, 3);
|
||||
vfs.lstat = cbWrap(vfs.lstatSync, 3);
|
||||
vfs.mkdir = cbWrap(vfs.mkdirSync, 3);
|
||||
vfs.rmdir = cbWrap(vfs.rmdirSync, 3);
|
||||
vfs.unlink = cbWrap(vfs.unlinkSync, 2);
|
||||
vfs.rm = cbWrap(vfs.rmSync, 3);
|
||||
|
||||
vfs.realpathSync.native = (p, opt) => vfs.realpathSync(p, opt);
|
||||
vfs.realpath.native = (p, opt, cb) => vfs.realpath(p, opt, cb);
|
||||
vfs.promises.realpath.native = async (p, opt) => vfs.realpathSync(p, opt);
|
||||
|
||||
// #endregion
|
||||
|
||||
return vfs;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -18,14 +18,10 @@ export async function formatAttachment(file) {
|
||||
}
|
||||
|
||||
if (!skeledash && file.thumbavif) {
|
||||
try {
|
||||
const result = await computeSkeleHash(file.thumbavif);
|
||||
skeledash = result?.base62 || null;
|
||||
if (file.hash && skeledash) {
|
||||
skeleCache.set(file.hash, skeledash);
|
||||
}
|
||||
} catch {
|
||||
skeledash = null;
|
||||
const result = await computeSkeleHash(file.thumbavif);
|
||||
skeledash = result.base62;
|
||||
if (file.hash && skeledash) {
|
||||
skeleCache.set(file.hash, skeledash);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user