This commit is contained in:
seb
2026-10-03 23:13:36 +02:00
parent 1786879c4e
commit 9a7f345d8b
39 changed files with 4611 additions and 50 deletions

43
src/tools/ask-user.js Normal file
View File

@@ -0,0 +1,43 @@
import { defineTool, DirectReturnError } from 'libllmorbiter';
/**
* ask_user tool:
* Allows the LLM to ask the user for clarification or a decision (e.g. which SKU or ID
* they meant, or what parameter to use).
*
* When invoked, it raises a DirectReturnError with recordResult: false so the LLMOrbiter
* halts the inference loop immediately without generating a synthetic tool response.
* The conversation transitions into `idle` state, allowing the client/user to submit
* a tool response via the API.
*/
export function createAskUserTool() {
return defineTool({
name: 'ask_user',
description:
'Ask the user when you need clarification or a decision (e.g. which SKU or ID they meant, or what parameter to use). ' +
'Give a concise question + up to 3 short options (A,B,C); the user may select an option or provide free-form text.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['question'],
properties: {
question: {
type: 'string',
description: 'Question for the user.',
},
options: {
type: 'array',
description: 'Up to 3 short option labels.',
items: { type: 'string' },
maxItems: 3,
},
},
},
invoke: async ({ question, options = [] }) => {
const capped = Array.isArray(options) ? options.slice(0, 3) : [];
// Stop LLM loop immediately so the conversation stays idle awaiting user tool response
throw new DirectReturnError({ question, options: capped }, { recordResult: false });
},
});
}

View File

@@ -0,0 +1,57 @@
import jwt from 'jsonwebtoken';
import { defineTool } from 'libllmorbiter';
import env from '../config/env.js';
const MAX_RESPONSE_CHARS = 20_000;
const REQUEST_TIMEOUT_MS = 30_000;
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
/**
* Create an LLM tool that performs HTTP requests against this API, authenticated
* as the given user (short-lived JWT, same auth path as browser requests).
* @param {{ googleAuthId: number }} ctx
*/
export function createDashbackRequestTool({ googleAuthId }) {
return defineTool({
name: 'dashback_request',
description:
'Interrogate and call the Dashback REST API. Supports GET requests directly. ' +
'Paths are relative to the API root (e.g. "/article/2", "/category/tree", "/brand").',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['method', 'path'],
properties: {
method: { type: 'string', enum: METHODS, description: 'HTTP method' },
path: { type: 'string', description: 'Path of the endpoint to request (e.g. "/article/2", "/category/tree", "/brand").' },
query: { type: 'object', description: 'Optional query parameters as key-value pairs (e.g. { limit: 10, offset: 0 }).' },
body: { type: 'string', description: 'Raw JSON request payload string (e.g. \'{"weight": 2.5}\').' },
},
},
invoke: async ({ method = 'GET', path, query = null, body = null }) => {
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
throw new Error('path must start with a single "/"');
}
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const headers = { Authorization: `Bearer ${token}` };
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
if (body != null && method !== 'GET' && method !== 'DELETE') {
headers['Content-Type'] = 'application/json';
init.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`;
console.log(`[llmorbiter] Tool invoke dashback_request: ${method} ${url}`);
const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init);
let text = await res.text();
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return JSON.stringify({ status: res.status, body: text });
},
});
}

View File

@@ -0,0 +1,247 @@
'use strict';
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
import sharp from 'sharp';
import { validateObjects, validateObject } from 'react-canvas-label-editor';
import { createVfs } from './vfs.js';
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
function resolveDashbackUrl(inputUrl, baseApiUrl) {
const raw = String(inputUrl || '').trim();
if (raw.startsWith('http://') || raw.startsWith('https://')) {
return raw;
}
const cleanPath = raw.startsWith('/') ? raw : '/' + raw;
const base = new URL(baseApiUrl || 'http://127.0.0.1:4208/api');
const origin = base.origin;
if (cleanPath.startsWith('/api/') || cleanPath === '/api' || cleanPath.startsWith('/v1/') || cleanPath === '/v1') {
return new URL(cleanPath, origin).toString();
}
const basePath = base.pathname.replace(/\/+$/, '');
return new URL(`${basePath}${cleanPath}`, origin).toString();
}
function isDashbackTarget(targetUrl, baseApiUrl) {
if (!baseApiUrl) return false;
try {
const targetObj = new URL(targetUrl);
const baseObj = new URL(baseApiUrl);
return targetObj.origin === baseObj.origin;
} catch {
return false;
}
}
function createSandboxFetch(apiUrl, token) {
const rawFetch = globalThis.fetch;
return async function sandboxFetch(input, init) {
let url;
let isDashback = false;
if (typeof input === 'string') {
const resolved = resolveDashbackUrl(input, apiUrl);
url = resolved;
isDashback = isDashbackTarget(resolved, apiUrl);
} else if (input instanceof globalThis.Request) {
url = input.url;
isDashback = isDashbackTarget(url, apiUrl);
} else if (input instanceof globalThis.URL) {
url = input.toString();
isDashback = isDashbackTarget(url, apiUrl);
} else {
url = input;
}
if (isDashback && token) {
const existingHeaders = init?.headers || (typeof input === 'object' && input?.headers ? input.headers : {});
const headers = new globalThis.Headers(existingHeaders);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json, text/plain, */*');
}
return rawFetch(input instanceof globalThis.Request ? input : url, { ...init, headers });
}
return rawFetch(url, init);
};
}
const NETWORK_GLOBALS = {
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
Headers: globalThis.Headers,
Request: globalThis.Request,
Response: globalThis.Response,
FormData: globalThis.FormData,
AbortController: globalThis.AbortController,
AbortSignal: globalThis.AbortSignal,
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
Blob: globalThis.Blob,
File: globalThis.File,
TextEncoder: globalThis.TextEncoder,
TextDecoder: globalThis.TextDecoder,
setTimeout,
clearTimeout,
};
const lines = [];
const push = (prefix) => (...args) => {
let text;
try {
text = args
.map((a) => {
if (typeof a === 'string') return a;
if (Buffer.isBuffer(a) || ArrayBuffer.isView(a) || (a && a.constructor && a.constructor.name === 'Buffer')) {
return `<Buffer ${(a.byteLength ?? a.length ?? 0)} bytes>`;
}
try {
return JSON.stringify(a, (k, v) => {
if (v && typeof v === 'object') {
if (v.type === 'Buffer' && Array.isArray(v.data)) {
return `<Buffer ${v.data.length} bytes>`;
}
if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || (v.constructor && v.constructor.name === 'Buffer')) {
return `<Buffer ${(v.byteLength ?? v.length ?? 0)} bytes>`;
}
}
return v;
});
} catch {
return String(a);
}
})
.join(' ');
} catch {
text = String(args);
}
lines.push(prefix + text);
};
const sandboxConsole = {
log: push(''),
info: push(''),
debug: push(''),
warn: push('warn: '),
error: push('error: '),
trace: push('trace: '),
};
const MAX_ITEMS = 200;
function sanitize(value, depth = 0) {
if (value === null) return null;
const t = typeof value;
if (t === 'undefined') return { type: 'undefined' };
if (t === 'boolean' || t === 'string') return value;
if (t === 'number') return Number.isFinite(value) ? value : String(value);
if (t === 'bigint') return `${value.toString()}n`;
if (t === 'symbol') return String(value);
if (t === 'function') return { type: 'function' };
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value) || (value && value.constructor && value.constructor.name === 'Buffer')) {
const len = value.byteLength ?? value.length ?? 0;
return `<Buffer ${len} bytes>`;
}
if (depth >= 6) return { type: t, note: 'too deeply nested to serialize' };
if (Array.isArray(value)) {
const arr = new Array();
const len = Math.min(typeof value.length === 'number' ? value.length : 0, MAX_ITEMS);
for (let i = 0; i < len; i++) {
try { arr[i] = sanitize(value[i], depth + 1); }
catch { arr[i] = { type: 'unreadable' }; }
}
return arr;
}
if (t === 'object') {
let keys;
try { keys = Object.keys(value); } catch { return { type: 'object', note: 'not serializable' }; }
const out = {};
for (const k of keys.slice(0, MAX_ITEMS)) {
try { out[k] = sanitize(value[k], depth + 1); }
catch { out[k] = { type: 'unreadable' }; }
}
return out;
}
return { type: t };
}
function report(payload) {
try {
parentPort.postMessage(payload);
} catch {
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
} catch { /* thread exiting */ }
}
}
void (async () => {
try {
const vfs = createVfs(vfsDir, { googleAuthId });
const vm = new NodeVM({
console: 'off',
sandbox: {
console: sandboxConsole,
fs: vfs,
vfs,
sharp,
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
...NETWORK_GLOBALS,
},
require: {
root,
external: false,
builtin: [],
},
nesting: false,
bufferAllocLimit: bufferLimit,
timeout: vmTimeout,
});
const started = Date.now();
const done = () => ({
stdout: lines.join('\n'),
consoleLineCount: lines.length,
durationMs: Date.now() - started,
});
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
),
]);
}
} catch (err) {
failed = {
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
cause: err && err.cause ? ((err.cause && err.cause.message) || String(err.cause)) : undefined,
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
};
}
if (failed) {
report({ ...done(), ...failed });
} else {
report({ result: sanitize(value), ...done() });
}
} catch (err) {
report({
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
});
}
})();

157
src/tools/javascript.js Normal file
View File

@@ -0,0 +1,157 @@
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import path from 'path';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '../..');
const WORKER_PATH = fileURLToPath(new URL('./javascript-worker.js', import.meta.url));
const DEFAULT_TIMEOUT_MS = 20_000;
const MAX_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_OUTPUT = 20_000;
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
return new Promise((resolve) => {
let settled = false;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: ROOT_DIR,
vmTimeout: timeoutMs,
bufferLimit: DEFAULT_BUFFER_LIMIT,
dashbackUrl: `http://127.0.0.1:${env.port}/api`,
dashbackToken: token,
vfsDir: vfsRoot,
googleAuthId,
},
});
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { worker.terminate(); } catch { /* already closed */ }
resolve(payload);
};
const timer = setTimeout(() => {
finish({ timedOut: true, timeoutMs });
}, timeoutMs);
worker.on('message', (msg) => finish(msg));
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
worker.on('exit', (code) => {
finish({
error: `Sandbox worker exited unexpectedly (code ${code}).`,
errorName: 'WorkerExit',
});
});
});
}
function formatSandboxError(code, report) {
const errName = report.errorName || 'Error';
const msg = report.error || 'Unknown error';
const lines = [`${errName}: ${msg}`];
if (report.stdout) {
lines.push('Console output before crash:\n' + report.stdout);
}
return lines.join('\n');
}
/**
* Creates the javascript_execute tool bound to the authenticated user and their company.
* @param {{ googleAuthId: number, companyId: number }} ctx
*/
export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
return defineTool({
name: 'javascript_execute',
description:
'Run JavaScript in a sandboxed Node environment and return the result. ' +
'Runs in an isolated worker thread with a hard wall-clock timeout. ' +
'Return a value by assigning `module.exports` (sync value or a promise). ' +
'For async code: `module.exports = (async () => { ... })()`. ' +
'console.* is captured and returned as stdout. ' +
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
'The `sharp` image library is a pre-injected GLOBAL. ' +
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
code: {
type: 'string',
description:
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`.',
},
timeout: {
type: 'number',
description: `Hard wall-clock limit in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
},
max_output: {
type: 'number',
description: `Max chars of captured console output to return. Default ${DEFAULT_MAX_OUTPUT}.`,
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
}
const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT);
const vfsRoot = await prepareVfs({ companyId, googleAuthId });
let report;
try {
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
} finally {
await syncVfsToDb({ googleAuthId, companyId, vfsRoot });
}
if (report.timedOut) {
return {
code,
timedOut: true,
timeoutMs: report.timeoutMs,
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`,
};
}
if (report.error) {
throw new ToolError(formatSandboxError(code, report));
}
let stdout = report.stdout ?? '';
let truncated = false;
if (stdout.length > maxOutput) {
stdout = stdout.slice(0, maxOutput);
truncated = true;
}
const result = { code, result: report.result };
if (stdout) result.stdout = stdout;
if (truncated) {
result.truncated = true;
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
}
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
return result;
},
});
}

39
src/tools/reset-vfs.js Normal file
View File

@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import { defineTool } from 'libllmorbiter';
import { getStore } from '../db/store.js';
/**
* Creates the reset_vfs tool to wipe artifacts stored in the virtual filesystem.
* @param {{ googleAuthId: number, companyId: number }} ctx
*/
export function createResetVfsTool({ googleAuthId, companyId }) {
return defineTool({
name: 'reset_vfs',
description: 'Delete all files, images, and artifacts stored in the virtual filesystem (VFS) for this company.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
invoke: async () => {
const store = await getStore();
const res = await store.modelvfs.reset(googleAuthId);
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
if (fs.existsSync(vfsRoot)) {
try {
const entries = fs.readdirSync(vfsRoot);
for (const e of entries) {
fs.rmSync(path.join(vfsRoot, e), { recursive: true, force: true });
}
} catch (err) {
console.warn('[reset_vfs] local wipe warning:', err.message);
}
}
return { success: true, deletedCount: res?.deletedCount || 0 };
},
});
}

272
src/tools/vfs.js Normal file
View File

@@ -0,0 +1,272 @@
import fs from 'fs';
import path from 'path';
import { getStore } from '../db/store.js';
import env from '../config/env.js';
const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.json': return 'application/json';
case '.avif': return 'image/avif';
case '.webp': return 'image/webp';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.txt': return 'text/plain';
case '.csv': return 'text/csv';
default: return 'application/octet-stream';
}
}
export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/');
const last = parts[parts.length - 1];
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt.toLowerCase();
}
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(seg)) {
return seg.toLowerCase();
}
}
return null;
}
export function isReadOnlyDatabasePath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) return false;
const clean = filePath.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
const lower = clean.toLowerCase();
if (lower === 'image' || lower.startsWith('image/')) return true;
if (extractFullHash(clean)) return true;
return false;
}
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").`
);
}
}
/**
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
* @param {{ companyId: number, googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
*/
export async function prepareVfs({ companyId, googleAuthId }) {
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
fs.mkdirSync(vfsRoot, { recursive: true });
try {
const store = await getStore();
const files = await store.modelvfs.list(googleAuthId);
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);
}
}
}
} catch (err) {
console.warn('[vfs] hydration warning:', err.message);
}
return vfsRoot;
}
/**
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
* @param {{ companyId: number, 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);
}
}
}
/**
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
* @param {string} vfsRoot
* @param {{ googleAuthId: number, companyId: number }} [options]
*/
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const resolved = path.resolve(vfsRoot, cleanVirtual);
const relative = path.relative(vfsRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return resolved;
};
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;
} catch {
return null;
}
};
const vfs = {
readFileSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
}
const diskPath = safeResolve(p);
return fs.readFileSync(diskPath, opt);
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
},
existsSync: (p) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) return true;
}
try {
const diskPath = safeResolve(p);
return fs.existsSync(diskPath);
} catch {
return false;
}
},
statSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const now = new Date();
return {
isFile: () => true,
isDirectory: () => false,
size: buf.length,
mtime: now,
ctime: now,
atime: now,
};
}
}
const diskPath = safeResolve(p);
return fs.statSync(diskPath, opt);
},
lstatSync: (p, opt) => vfs.statSync(p, opt),
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);
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.mkdirSync(diskPath, opt);
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.unlinkSync(diskPath);
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
},
promises: {
readFile: async (p, opt) => vfs.readFileSync(p, opt),
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
unlink: async (p) => vfs.unlinkSync(p),
rm: async (p, opt) => vfs.rmSync(p, opt),
},
};
return vfs;
}