diff --git a/src/defaultSkills/applet.SKILL b/src/defaultSkills/applet.SKILL index 82d316b..9dbf291 100644 --- a/src/defaultSkills/applet.SKILL +++ b/src/defaultSkills/applet.SKILL @@ -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. diff --git a/src/tools/javascript-worker.js b/src/tools/javascript-worker.js index 3c74c64..6572df9 100644 --- a/src/tools/javascript-worker.js +++ b/src/tools/javascript-worker.js @@ -600,7 +600,6 @@ async function runJob(job) { sandbox: { console: sandboxConsole, fs: vfs, - vfs, sharp: bridgeSharp(vfs), ExcelJS: bridgedExcel, exceljs: bridgedExcel, diff --git a/src/tools/prompts.js b/src/tools/prompts.js index 738cf3d..3d2cb0b 100644 --- a/src/tools/prompts.js +++ b/src/tools/prompts.js @@ -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; use only root-relative paths for all file operations, absolute paths will fail), 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 ".SKILL" files.', diff --git a/src/tools/vfs.js b/src/tools/vfs.js index ea137e0..be7caa6 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -435,6 +435,193 @@ export function createVfs(vfsRoot, options = {}) { throw sanitizeFsError(err, p); } }, + + appendFile: (p, data, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + vfs.appendFileSync(p, data, options); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + + copyFileSync: (src, dest, mode) => { + assertNotReadOnlyDatabasePath(dest); + const hash = extractFullHash(src); + if (hash) { + const buf = syncFetchImage(hash); + if (buf) { + 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); + } + } + } + const srcDisk = safeResolve(src); + const destDisk = safeResolve(dest); + fs.mkdirSync(path.dirname(destDisk), { recursive: true }); + try { + return fs.copyFileSync(srcDisk, destDisk, mode); + } catch (err) { + throw sanitizeFsError(err, `${src} -> ${dest}`); + } + }, + + copyFile: (src, dest, mode, cb) => { + const callback = typeof mode === 'function' ? mode : cb; + const m = typeof mode === 'function' ? undefined : mode; + try { + vfs.copyFileSync(src, dest, m); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + + 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}`); + } + }, + + cp: (src, dest, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + vfs.cpSync(src, dest, options); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + + 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}`); + } + }, + + rename: (oldPath, newPath, cb) => { + try { + vfs.renameSync(oldPath, newPath); + if (typeof cb === 'function') process.nextTick(cb, null); + } catch (err) { + if (typeof cb === 'function') process.nextTick(cb, err); + else throw err; + } + }, + + truncateSync: (p, len) => { + assertNotReadOnlyDatabasePath(p); + const diskPath = safeResolve(p); + try { + return fs.truncateSync(diskPath, len); + } catch (err) { + throw sanitizeFsError(err, p); + } + }, + + truncate: (p, len, cb) => { + const callback = typeof len === 'function' ? len : cb; + const l = typeof len === 'function' ? undefined : len; + try { + vfs.truncateSync(p, l); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + + accessSync: (p, mode) => { + const hash = extractFullHash(p); + if (hash) { + const buf = syncFetchImage(hash); + if (buf) { + 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); + } + }, + + access: (p, mode, cb) => { + const callback = typeof mode === 'function' ? mode : cb; + const m = typeof mode === 'function' ? undefined : mode; + try { + vfs.accessSync(p, m); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + + 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); + } + }, + + realpath: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + const res = vfs.realpathSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null, res); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + existsSync: (p) => { const hash = extractFullHash(p); if (hash) { @@ -449,6 +636,11 @@ export function createVfs(vfsRoot, options = {}) { } }, + 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 +658,63 @@ 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); + } + }, + + stat: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + const res = vfs.statSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null, res); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } }, lstatSync: (p, opt) => vfs.statSync(p, opt), + lstat: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + const res = vfs.lstatSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null, res); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + 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 options = typeof opt === 'function' ? undefined : opt; + const pathArg = typeof p === 'function' ? '.' : p; + try { + const res = vfs.readdirSync(pathArg, options); + 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,22 +726,81 @@ export function createVfs(vfsRoot, options = {}) { throw sanitizeFsError(err, p); } }, + + mkdir: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + const res = vfs.mkdirSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null, res); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } + }, + unlinkSync: (p) => { assertNotReadOnlyDatabasePath(p); const diskPath = safeResolve(p); - return fs.unlinkSync(diskPath); + try { + return fs.unlinkSync(diskPath); + } catch (err) { + throw sanitizeFsError(err, p); + } + }, + + unlink: (p, cb) => { + try { + vfs.unlinkSync(p); + if (typeof cb === 'function') process.nextTick(cb, null); + } catch (err) { + if (typeof cb === 'function') process.nextTick(cb, err); + else throw err; + } }, 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); + } + }, + + rmdir: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + vfs.rmdirSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } }, 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); + } + }, + + rm: (p, opt, cb) => { + const callback = typeof opt === 'function' ? opt : cb; + const options = typeof opt === 'function' ? undefined : opt; + try { + vfs.rmSync(p, options); + if (typeof callback === 'function') process.nextTick(callback, null); + } catch (err) { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + } }, promises: { @@ -538,11 +834,41 @@ 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); + 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); + } + } + 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, }, /** @@ -573,5 +899,10 @@ export function createVfs(vfsRoot, options = {}) { sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath), }; + 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); + vfs.constants = fs.constants; + return vfs; }