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'; const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/; const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); 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'; } } 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); if (parts.length === 0) return null; const first = parts[0].toLowerCase(); const last = parts[parts.length - 1]; const ext = path.extname(last).toLowerCase(); 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; } } // Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif") if (parts.length === 1 && FULL_HASH_REGEX.test(withoutExt) && (!ext || ext === '.avif')) { return withoutExt; } return null; } 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 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").` ); } } /** * 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} Absolute `data/vfs/` path * @throws {Error} 'No company selected' when the user has no company selected */ export async function resolveVfsRoot(googleAuthId) { return vfsRootFor(await getStore(), 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. * * @param {object} store * @param {number} googleAuthId * @returns {Promise} */ async function vfsRootFor(store, googleAuthId) { const row = await store.google_auth.getById(googleAuthId); const companyId = row?.selectedCompanyId; if (companyId == null) { throw new Error('No company selected'); } return path.resolve(process.cwd(), 'data/vfs', String(companyId)); } /** * Loads default SKILL files into the selected company's on-disk VFS. * * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx * @returns {Promise>} List of loaded skill file names */ export async function loadDefaultSkills({ 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 loaded = []; for (const skill of defaultSkills) { try { const diskPath = path.resolve(vfsRoot, skill.name); if (!fs.existsSync(diskPath)) { fs.mkdirSync(path.dirname(diskPath), { recursive: true }); fs.writeFileSync(diskPath, skill.content, 'utf8'); } loaded.push(skill.name); } catch (err) { console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message); } } return loaded; } /** * 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>} List of seeded applet file names */ 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; } /** * Initializes the VFS for the selected company with default SKILL files and example applets. * * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx * @returns {Promise} vfsRoot directory path */ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { const store = passedStore || await getStore(); const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); fs.mkdirSync(vfsRoot, { recursive: true }); await loadDefaultSkills({ googleAuthId, store, vfsRoot }); await loadDefaultApplets({ googleAuthId, store, vfsRoot }); return 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} vfsRoot directory path */ export async function prepareVfs({ googleAuthId }) { const store = await getStore(); const vfsRoot = await vfsRootFor(store, googleAuthId); fs.mkdirSync(vfsRoot, { recursive: true }); try { await loadDefaultSkills({ googleAuthId, store, vfsRoot }); } catch (err) { console.warn('[vfs] prepare warning:', err.message); } try { await loadDefaultApplets({ googleAuthId, store, vfsRoot }); } catch (err) { console.warn('[vfs] prepare applet warning:', err.message); } return vfsRoot; } /** * 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 } = {}) { // Purely disk-based VFS; no database syncing. } /** * Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`. * @param {string} vfsRoot Absolute root, as returned by `prepareVfs`/`resolveVfsRoot` * @param {{ googleAuthId: number }} [options] */ export function createVfs(vfsRoot, options = {}) { const { googleAuthId } = options; // Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory // layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix. const sanitizeFsError = (err, virtualPath) => { if (!err || typeof err.message !== 'string') return err; const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath); const message = err.message .split(vfsRoot) .join('vfs://') .split(path.sep) .join('/') .replace(/vfs:\/\/\/+/g, 'vfs:///'); return Object.assign(Object.create(Object.getPrototypeOf(err)), err, { message, path: virtual, }); }; const safeResolve = (p) => { if (typeof p !== 'string' || !p.trim()) { throw new Error('Path must be a non-empty string'); } const cleanVirtual = p.trim().replace(/^vfs:\/+/i, '').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); try { return fs.readFileSync(diskPath, opt); } catch (err) { throw sanitizeFsError(err, p); } }, readFile: (p, opt, cb) => { const callback = typeof opt === 'function' ? opt : cb; const options = typeof opt === 'function' ? undefined : opt; vfs.promises.readFile(p, options) .then((data) => { if (typeof callback === 'function') process.nextTick(callback, null, data); }) .catch((err) => { if (typeof callback === 'function') process.nextTick(callback, err); else throw err; }); }, createReadStream: (p, opt) => { const diskPath = safeResolve(p); if (!fs.existsSync(diskPath)) { const err = new Error(`ENOENT: no such file or directory, open '${String(p)}'`); err.code = 'ENOENT'; err.path = String(p); err.syscall = 'open'; throw sanitizeFsError(err, p); } return fs.createReadStream(diskPath, opt); }, writeFileSync: (p, data, opt) => { assertNotReadOnlyDatabasePath(p); const diskPath = safeResolve(p); fs.mkdirSync(path.dirname(diskPath), { recursive: true }); try { return fs.writeFileSync(diskPath, data, opt); } catch (err) { throw sanitizeFsError(err, p); } }, 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); fs.mkdirSync(path.dirname(diskPath), { recursive: true }); return fs.createWriteStream(diskPath, opt); }, appendFileSync: (p, data, opt) => { assertNotReadOnlyDatabasePath(p); const diskPath = safeResolve(p); fs.mkdirSync(path.dirname(diskPath), { recursive: true }); try { return fs.appendFileSync(diskPath, data, opt); } catch (err) { throw sanitizeFsError(err, p); } }, 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); try { return fs.mkdirSync(diskPath, opt); } catch (err) { throw sanitizeFsError(err, p); } }, 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) => { 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 (googleAuthId) { try { const store = await getStore(); const img = await store.pic.getImageByHash(googleAuthId, hash); if (img?.buffer) { try { const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`); 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; } } catch { // fall through } } } return 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), }, /** * Resolve a virtual path to an absolute on-disk path for writing, applying the SAME * containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that * bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox * boundary instead of writing wherever the process CWD happens to be. * * Deliberately does NOT create parent directories: callers such as `sharp().toFile()` * must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is * missing. Auto-creating the whole tree turns a typo'd path into silent success. */ resolveWritePath: (p) => { assertNotReadOnlyDatabasePath(p); return safeResolve(p); }, /** * Resolve a virtual path to an absolute on-disk path for reading, applying containment checks. */ resolveReadPath: (p) => { return safeResolve(p); }, /** * Sanitize on-disk error paths back to virtual paths for display in sandbox transcripts. */ sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath), }; return vfs; }