This commit is contained in:
seb
2026-10-18 15:41:21 +02:00
parent 36d62138d4
commit 9c50d9efc3
4 changed files with 340 additions and 10 deletions

View File

@@ -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.

View File

@@ -600,7 +600,6 @@ async function runJob(job) {
sandbox: {
console: sandboxConsole,
fs: vfs,
vfs,
sharp: bridgeSharp(vfs),
ExcelJS: bridgedExcel,
exceljs: bridgedExcel,

View File

@@ -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 "<slug>.SKILL" files.',

View File

@@ -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;
}