u
This commit is contained in:
@@ -6,6 +6,8 @@ import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fsModule from 'fs';
|
||||
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
||||
import ExcelJS from 'exceljs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { createVfs } from './vfs.js';
|
||||
|
||||
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
|
||||
@@ -198,6 +200,218 @@ function bridgeSharp(vfs) {
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
// ExcelJS reads and writes files via Node's `fs` when using `.readFile()` / `.writeFile()` / streaming.
|
||||
// Injecting the raw library would allow escaping VFS or reading/writing in the server process CWD.
|
||||
// We wrap `Workbook`, `stream.xlsx.WorkbookWriter`, and `stream.xlsx.WorkbookReader` so that file
|
||||
// paths are resolved through the VFS containment rules, parent directories are created on write,
|
||||
// and errors are properly sanitized without leaking host server paths.
|
||||
function bridgeExcelJS(vfs) {
|
||||
const sanitizePathError = (err, diskPath, virtualPath) => {
|
||||
if (!err) return err;
|
||||
if (typeof err.message === 'string' && diskPath) {
|
||||
err.message = err.message.split(diskPath).join(virtualPath);
|
||||
}
|
||||
if (typeof err.stack === 'string' && diskPath) {
|
||||
err.stack = err.stack.split(diskPath).join(virtualPath);
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
class BridgedWorkbook extends ExcelJS.Workbook {
|
||||
get xlsx() {
|
||||
const x = super.xlsx;
|
||||
if (!x.__vfsBridged) {
|
||||
const origRead = x.readFile;
|
||||
const origWrite = x.writeFile;
|
||||
x.readFile = async function readFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origRead.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveReadPath(filename);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${filename}`);
|
||||
}
|
||||
try {
|
||||
return await origRead.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
x.writeFile = async function writeFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origWrite.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveWritePath(filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return await origWrite.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
x.__vfsBridged = true;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
get csv() {
|
||||
const c = super.csv;
|
||||
if (!c.__vfsBridged) {
|
||||
const origRead = c.readFile;
|
||||
const origWrite = c.writeFile;
|
||||
c.readFile = async function readFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origRead.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveReadPath(filename);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${filename}`);
|
||||
}
|
||||
try {
|
||||
return await origRead.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
c.writeFile = async function writeFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origWrite.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveWritePath(filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return await origWrite.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
c.__vfsBridged = true;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
addImage(image) {
|
||||
if (image && typeof image.filename === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(image.filename);
|
||||
return super.addImage({ ...image, filename: diskPath });
|
||||
}
|
||||
return super.addImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
class BridgedWorkbookWriter extends ExcelJS.stream.xlsx.WorkbookWriter {
|
||||
constructor(options = {}) {
|
||||
let opts = options;
|
||||
if (options && typeof options.filename === 'string') {
|
||||
const diskPath = vfs.resolveWritePath(options.filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
opts = { ...options, filename: diskPath };
|
||||
}
|
||||
super(opts);
|
||||
}
|
||||
|
||||
addImage(image) {
|
||||
if (image && typeof image.filename === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(image.filename);
|
||||
return super.addImage({ ...image, filename: diskPath });
|
||||
}
|
||||
return super.addImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
class BridgedWorkbookReader extends ExcelJS.stream.xlsx.WorkbookReader {
|
||||
constructor(input, options) {
|
||||
let resolvedInput = input;
|
||||
if (typeof input === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(input);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${input}`);
|
||||
}
|
||||
resolvedInput = diskPath;
|
||||
}
|
||||
super(resolvedInput, options);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ExcelJS,
|
||||
Workbook: BridgedWorkbook,
|
||||
stream: {
|
||||
...ExcelJS.stream,
|
||||
xlsx: {
|
||||
...ExcelJS.stream?.xlsx,
|
||||
WorkbookWriter: BridgedWorkbookWriter,
|
||||
WorkbookReader: BridgedWorkbookReader,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// PDFKit generates PDF documents. In Node, it emits binary chunks as a readable stream
|
||||
// or writes directly to `fs.createWriteStream`. It also loads images and custom fonts via `fs.readFileSync`.
|
||||
// We wrap `PDFDocument` so:
|
||||
// 1. `doc.image(src, ...)` and `doc.font(src, ...)` resolve files via VFS (supporting disk paths,
|
||||
// hashes, and relative paths) and pass raw Buffers so PDFKit avoids uncontained host disk reads.
|
||||
// 2. Provide `await doc.toFile(filename)` to directly stream and finalize to a VFS file.
|
||||
// 3. Provide `await doc.toBuffer()` to finalize and return the full PDF Buffer.
|
||||
// 4. Piping via `doc.pipe(fs.createWriteStream('out.pdf'))` continues to work seamlessly with VFS.
|
||||
function bridgePDFKit(vfs) {
|
||||
const STANDARD_FONTS = new Set([
|
||||
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
|
||||
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
|
||||
'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
|
||||
'Symbol', 'ZapfDingbats',
|
||||
]);
|
||||
|
||||
class BridgedPDFDocument extends PDFDocument {
|
||||
font(src, family, size) {
|
||||
if (typeof src === 'string' && !STANDARD_FONTS.has(src)) {
|
||||
try {
|
||||
const buf = vfs.readFileSync(src);
|
||||
return super.font(buf, family, size);
|
||||
} catch {
|
||||
return super.font(src, family, size);
|
||||
}
|
||||
}
|
||||
return super.font(src, family, size);
|
||||
}
|
||||
|
||||
image(src, ...args) {
|
||||
if (typeof src === 'string') {
|
||||
const buf = vfs.readFileSync(src);
|
||||
return super.image(buf, ...args);
|
||||
}
|
||||
return super.image(src, ...args);
|
||||
}
|
||||
|
||||
async toBuffer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
this.on('data', (chunk) => chunks.push(chunk));
|
||||
this.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
this.on('error', reject);
|
||||
this.end();
|
||||
});
|
||||
}
|
||||
|
||||
async toFile(virtualPath) {
|
||||
const diskPath = vfs.resolveWritePath(virtualPath);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
const writeStream = fsModule.createWriteStream(diskPath);
|
||||
return new Promise((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
this.on('error', reject);
|
||||
this.pipe(writeStream);
|
||||
this.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return BridgedPDFDocument;
|
||||
}
|
||||
|
||||
const NETWORK_GLOBALS = {
|
||||
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
|
||||
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
|
||||
@@ -329,6 +543,9 @@ void (async () => {
|
||||
try {
|
||||
const vfs = createVfs(vfsDir, { googleAuthId });
|
||||
|
||||
const bridgedExcel = bridgeExcelJS(vfs);
|
||||
const bridgedPDF = bridgePDFKit(vfs);
|
||||
|
||||
const vm = new NodeVM({
|
||||
console: 'off',
|
||||
sandbox: {
|
||||
@@ -336,6 +553,11 @@ try {
|
||||
fs: vfs,
|
||||
vfs,
|
||||
sharp: bridgeSharp(vfs),
|
||||
ExcelJS: bridgedExcel,
|
||||
exceljs: bridgedExcel,
|
||||
PDFDocument: bridgedPDF,
|
||||
PDFKit: bridgedPDF,
|
||||
pdfkit: bridgedPDF,
|
||||
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
|
||||
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
|
||||
...NETWORK_GLOBALS,
|
||||
|
||||
@@ -126,7 +126,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
|
||||
'REST API: `await fetch("/path")` (authenticated). ' +
|
||||
'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' +
|
||||
'Embed generated images/links in markdown: ``. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' +
|
||||
'All APIs are pre-injected globals: `fs`/`vfs` (virtual filesystem), `sharp` (image processing), WebCrypto `crypto` (`subtle`, `randomUUID`), and standard web globals. Do NOT use `require()` or `import` (disabled; all necessary APIs are already in global scope).',
|
||||
'All APIs are pre-injected globals: `fs`/`vfs` (virtual filesystem), `sharp` (image processing), `ExcelJS`/`exceljs` (spreadsheet processing with VFS support for readFile/writeFile/writeBuffer/load), `PDFDocument`/`PDFKit`/`pdfkit` (PDF generation with VFS support for toFile/toBuffer/pipe), WebCrypto `crypto` (`subtle`, `randomUUID`), and standard web globals. Do NOT use `require()` or `import` (disabled; all necessary APIs are already in global scope).',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
|
||||
@@ -124,7 +124,9 @@ export function createReadFileTool({ googleAuthId }) {
|
||||
`"${clean}" is a binary file (${raw.length} bytes${ext ? `, ${ext}` : ''}) and cannot be read as text. ` +
|
||||
(['.png', '.jpg', '.jpeg', '.avif', '.webp', '.gif', '.bmp'].includes(ext)
|
||||
? 'Use analyze_image({ path: ' + JSON.stringify(clean) + ' }) to have a vision model inspect it.'
|
||||
: 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.')
|
||||
: (['.xlsx', '.xls', '.xlsm'].includes(ext)
|
||||
? 'Use javascript_execute with ExcelJS to inspect or modify spreadsheet files.'
|
||||
: 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.'))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
108
src/tools/vfs.js
108
src/tools/vfs.js
@@ -32,6 +32,10 @@ export function getMimeType(filePath) {
|
||||
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';
|
||||
@@ -68,7 +72,7 @@ export function extractFullHash(p) {
|
||||
|
||||
export function isReadOnlyDatabasePath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
||||
const clean = filePath.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
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;
|
||||
@@ -186,6 +190,23 @@ export async function prepareVfs({ googleAuthId }) {
|
||||
|
||||
try {
|
||||
const files = await store.modelvfs.list(googleAuthId);
|
||||
const existingNames = new Set(files.map((f) => f.name));
|
||||
const defaultSkills = getDefaultSkills();
|
||||
for (const skill of defaultSkills) {
|
||||
if (!existingNames.has(skill.name)) {
|
||||
try {
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: skill.name,
|
||||
mime: 'text/markdown',
|
||||
content: Buffer.from(skill.content, 'utf8'),
|
||||
});
|
||||
files.push({ name: skill.name });
|
||||
existingNames.add(skill.name);
|
||||
} catch (err) {
|
||||
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
const diskPath = path.resolve(vfsRoot, file.name);
|
||||
if (!diskPath.startsWith(vfsRoot)) continue;
|
||||
@@ -276,10 +297,10 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
};
|
||||
|
||||
const safeResolve = (p) => {
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
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 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)) {
|
||||
@@ -302,20 +323,44 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
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 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 {
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.readFileSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
readFile: (p, opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : cb;
|
||||
const options = typeof opt === 'function' ? undefined : opt;
|
||||
try {
|
||||
const data = vfs.readFileSync(p, options);
|
||||
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) => {
|
||||
@@ -329,6 +374,25 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
@@ -436,6 +500,18 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user