This commit is contained in:
seb
2026-10-06 13:36:03 +02:00
parent cab06d96e8
commit 07f0d5ba80
9 changed files with 1710 additions and 21 deletions

View File

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

View File

@@ -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: `![alt](relative/path)`. 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',

View File

@@ -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.'))
);
}

View File

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