u
This commit is contained in:
43
src/tools/ask-user.js
Normal file
43
src/tools/ask-user.js
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/tools/dashback-request.js
Normal file
57
src/tools/dashback-request.js
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
247
src/tools/javascript-worker.js
Normal file
247
src/tools/javascript-worker.js
Normal 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
157
src/tools/javascript.js
Normal 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
39
src/tools/reset-vfs.js
Normal 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
272
src/tools/vfs.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user