Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/sim/lib/execution/isolated-vm-worker.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,17 @@ async function executeTask(request, executionId) {
releaseables.push(textDecodeCallback)
await jail.set('__textDecode', textDecodeCallback)

// btoa / atob delegate to Node's native WHATWG implementations, so
// libraries that encode base64 through them (docx's Packer.toBase64String,
// document protection hashes, data-URI images) behave as in a browser.
const btoaCallback = new ivm.Callback((data) => btoa(data))
releaseables.push(btoaCallback)
await jail.set('__btoa', btoaCallback)

const atobCallback = new ivm.Callback((data) => atob(data))
releaseables.push(atobCallback)
await jail.set('__atob', atobCallback)

// Delegate timers to Node's real timer heap via ivm.Reference (the pattern
// recommended by laverdet/isolated-vm#136). Host-side bookkeeping was
// hoisted to the function scope above so the finally can always sweep.
Expand Down Expand Up @@ -890,6 +901,8 @@ async function executeTask(request, executionId) {
const __error = globalThis.__error;
const __textEncode = globalThis.__textEncode;
const __textDecode = globalThis.__textDecode;
const __btoa = globalThis.__btoa;
const __atob = globalThis.__atob;
const __setTimeoutRef = globalThis.__setTimeoutRef;
const __clearTimeoutRef = globalThis.__clearTimeoutRef;
const __setIntervalRef = globalThis.__setIntervalRef;
Expand Down Expand Up @@ -927,6 +940,9 @@ async function executeTask(request, executionId) {
}
};

globalThis.btoa = function btoa(data) { return __btoa(String(data)); };
globalThis.atob = function atob(data) { return __atob(String(data)); };

// setTimeout / setInterval delegate to Node's real timer heap via
// ivm.Reference. The \`ms\` arg is honored (host clamps to the script
// timeout window); \`clearTimeout\` / \`clearInterval\` actually cancel.
Expand Down Expand Up @@ -1039,7 +1055,7 @@ async function executeTask(request, executionId) {
const undefined_globals = [
'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference',
'ExternalCopy', '__dirname', '__filename', '__brokerRef',
'__log', '__error', '__textEncode', '__textDecode',
'__log', '__error', '__textEncode', '__textDecode', '__btoa', '__atob',
'__setTimeoutRef', '__clearTimeoutRef', '__setIntervalRef'
];
for (const name of undefined_globals) {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/execution/sandbox/bundles/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* Every bundle is evaluated in a bare context before it is written: the
* bundler can emit a reference to a runtime helper it never defines (Bun does
* this for docx's inlined CommonJS shim), and nothing else loads these files
* this for an inlined CommonJS shim), and nothing else loads these files
* before a production document generation does.
*
* Run via: `bun run build:sandbox-bundles`.
Expand Down
48 changes: 9 additions & 39 deletions apps/sim/lib/execution/sandbox/bundles/docx.cjs

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions apps/sim/lib/execution/sandbox/bundles/isolate-polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* Beyond aliasing `global -> globalThis` for UMD-style fallbacks inside the
* bundles, this file only answers the one name the bundler can leave dangling
* (see below). All other runtime surface (`console`, `TextEncoder`,
* `TextDecoder`, timers) is installed by the worker via `ivm.Callback` /
* `ivm.Reference` bridges to Node's native implementations — no hand-rolled
* polyfill logic lives in the isolate.
* `TextDecoder`, `btoa`, `atob`, timers) is installed by the worker via
* `ivm.Callback` / `ivm.Reference` bridges to Node's native implementations —
* no hand-rolled polyfill logic lives in the isolate.
*/

const g: typeof globalThis & {
Expand All @@ -24,11 +24,10 @@ if (typeof g.global === 'undefined') g.global = globalThis

/**
* A library that inlines a CommonJS dependency ships esbuild's `__require`
* helper around it (docx >= 9.7.1 does this for JSZip's UMD build). Bun's
* browser/iife build rewrites the bare `require` references inside that helper
* to its own `__require` runtime helper and then never emits it, so the bundle
* throws `ReferenceError: __require is not defined` while it is still being
* evaluated. The isolate has no `require` at all, so the only correct answer
* helper around it. Bun's browser/iife build rewrites the bare `require`
* references inside that helper to its own `__require` runtime helper and
* then never emits it, so the bundle throws `ReferenceError: __require is not
* defined` while it is still being evaluated. The isolate has no `require` at all, so the only correct answer
* to a dynamic require is the one esbuild's helper gives when `require` is
* absent: throw. Defining it here keeps every bundle self-contained; `build.ts`
* evaluates each bundle in a bare context so a new variant of the defect fails
Expand Down
59 changes: 58 additions & 1 deletion apps/sim/lib/execution/sandbox/bundles/verify.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { evaluateSandboxBundle } from '@/lib/execution/sandbox/bundles/verify'
import {
createBareSandboxContext,
evaluateSandboxBundle,
} from '@/lib/execution/sandbox/bundles/verify'
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'
import { docxGenerateTask } from '@/sandboxes/docx-generate'

function loadCheckedInBundle(name: SandboxBundleName): Record<string, unknown> {
const source = readFileSync(new URL(`./${name}.cjs`, import.meta.url), 'utf-8')
Expand All @@ -23,6 +29,57 @@ describe('sandbox bundles', () => {
expect(typeof docx.Paragraph).toBe('function')
})

it('docx-generate packs a document with the checked-in bundle in a bare context', async () => {
const context = createBareSandboxContext()
evaluateSandboxBundle(
readFileSync(new URL('./docx.cjs', import.meta.url), 'utf-8'),
'docx',
context
)
const runAsync = (body: string) => vm.runInContext(`(async () => { ${body} })()`, context)

await runAsync(docxGenerateTask.bootstrap)
await runAsync(
"addSection({ children: [new docx.Paragraph({ children: [new docx.TextRun('R&D <ok>')] })] })"
)
const bytes: unknown = await runAsync(docxGenerateTask.finalize)

expect(ArrayBuffer.isView(bytes)).toBe(true)
const zip = await JSZip.loadAsync(bytes as Uint8Array)
expect(await zip.file('word/document.xml')?.async('string')).toContain('R&amp;D &lt;ok&gt;')
})

it('docx encodes and decodes base64 through the btoa/atob the worker provides', async () => {
const context = createBareSandboxContext()
evaluateSandboxBundle(
readFileSync(new URL('./docx.cjs', import.meta.url), 'utf-8'),
'docx',
context
)
const pngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='

const packed: unknown = await vm.runInContext(
`(async () => {
const docx = globalThis.__bundles.docx;
const doc = new docx.Document({
protection: { type: 'readOnly', password: 'secret' },
sections: [{ children: [new docx.Paragraph({ children: [
new docx.ImageRun({ type: 'png', data: 'data:image/png;base64,${pngBase64}', transformation: { width: 10, height: 10 } }),
] })] }],
});
return docx.Packer.toBase64String(doc);
})()`,
context
)

const zip = await JSZip.loadAsync(packed as string, { base64: true })
expect(await zip.file('word/settings.xml')?.async('string')).toMatch(
/<w:documentProtection[^>]*w:hash="[^"]+"/
)
expect(zip.file('word/media/image1.png')).not.toBeNull()
})

it('pdf-lib evaluates in a bare context and exposes the pdf-generate surface', () => {
const pdfLib = loadCheckedInBundle('pdf-lib')
expect(typeof pdfLib.PDFDocument).toBe('function')
Expand Down
32 changes: 23 additions & 9 deletions apps/sim/lib/execution/sandbox/bundles/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import vm from 'node:vm'
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'

/**
* Evaluates a built sandbox bundle the way the isolated-vm worker will: as a
* classic script in a context that has timers, `console`, and the text codecs
* but no `require`, `process`, or `Buffer` of its own. Returns the export the
* bundle registered on `globalThis.__bundles`, or throws with the bundle's own
* error, so a bundle that references a helper the bundler never emitted fails
* at build time and in the test suite instead of on the first document
* generated in production.
* A context shaped like the isolated-vm worker's task mode: timers, `console`,
* the text codecs, and `btoa`/`atob`, but no `require`, `process`, or `Buffer`
* of its own. Code that reaches for any other Node or browser global fails here
* the same way it fails inside the isolate.
*/
export function evaluateSandboxBundle(source: string, name: SandboxBundleName): unknown {
export function createBareSandboxContext(): vm.Context {
const context: Record<string, unknown> = {
setTimeout,
clearTimeout,
Expand All @@ -20,9 +17,26 @@ export function evaluateSandboxBundle(source: string, name: SandboxBundleName):
console,
TextEncoder,
TextDecoder,
btoa,
atob,
}
context.globalThis = context
vm.createContext(context)
return vm.createContext(context)
}

/**
* Evaluates a built sandbox bundle the way the isolated-vm worker will: as a
* classic script in a {@link createBareSandboxContext} context. Returns the
* export the bundle registered on `globalThis.__bundles`, or throws with the
* bundle's own error, so a bundle that references a helper the bundler never
* emitted fails at build time and in the test suite instead of on the first
* document generated in production.
*/
export function evaluateSandboxBundle(
source: string,
name: SandboxBundleName,
context: vm.Context = createBareSandboxContext()
): unknown {
vm.runInContext(source, context, { filename: `sandbox/${name}.cjs` })

const bundles = context.__bundles
Expand Down
2 changes: 0 additions & 2 deletions apps/sim/lib/microsoft-word/document.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,6 @@ describe('buildDocxFromContent', () => {
})

it('strips control characters that would make the package unopenable', async () => {
// The docx package writes run text into the XML verbatim, so a forbidden
// character reaching it produces invalid XML 1.0 rather than a rendering bug.
const buffer = await buildDocxFromContent(`before${String.fromCharCode(8)}after`)
const xml = await readDocumentXml(buffer)

Expand Down
9 changes: 4 additions & 5 deletions apps/sim/lib/microsoft-word/document.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,10 @@ function parseInlineRuns(line: string): ContentRun[] {
function parseContentBlocks(content: string): ContentBlock[] {
const blocks: ContentBlock[] = []

// Stripped once here rather than per run: the `docx` package writes run text
// into the XML verbatim, so a stray control character from an upstream block
// would produce a package Word refuses to open. Tab, newline, and carriage
// return are legal XML and are outside the class, so the split below is
// unaffected.
// Stripped before the Markdown patterns run, so a stray control character
// from an upstream block can't hide a heading or bullet marker. Tab, newline,
// and carriage return are legal XML and outside the class, so the split below
// is unaffected.
for (const rawLine of stripInvalidXmlChars(content).replace(/\r\n?/g, '\n').split('\n')) {
const line = rawLine.trimEnd()
if (line.trim().length === 0) continue
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
"diff": "9.0.0",
"docx": "^9.6.1",
"docx": "^9.15.2",
"docx-preview": "^0.4.1",
"drizzle-orm": "^0.45.4",
"echarts": "6.1.0",
Expand Down
20 changes: 1 addition & 19 deletions apps/sim/sandboxes/docx-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ export const docxGenerateTask = defineSandboxTask<SandboxTaskInput>({
}));
};
`,
// JSZip's browser build doesn't support nodebuffer output, so we go through
// base64 and decode back to bytes inside the isolate (avoids DataURL / Blob).
finalize: `
let doc = globalThis.doc;
if (!doc && globalThis.__docxSections.length > 0) {
Expand All @@ -91,23 +89,7 @@ export const docxGenerateTask = defineSandboxTask<SandboxTaskInput>({
if (!doc) {
throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set globalThis.doc = new docx.Document({...}) for a single write.');
}
const b64 = await globalThis.docx.Packer.toBase64String(doc);
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup = new Uint8Array(128);
for (let i = 0; i < alphabet.length; i++) lookup[alphabet.charCodeAt(i)] = i;
const clean = b64.replace(/=+$/, '');
const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
let pos = 0;
for (let i = 0; i < clean.length; i += 4) {
const c0 = lookup[clean.charCodeAt(i)];
const c1 = lookup[clean.charCodeAt(i + 1)];
const c2 = lookup[clean.charCodeAt(i + 2)];
const c3 = lookup[clean.charCodeAt(i + 3)];
out[pos++] = (c0 << 2) | (c1 >> 4);
if (i + 2 < clean.length) out[pos++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
if (i + 3 < clean.length) out[pos++] = ((c2 & 0x03) << 6) | c3;
}
return out.subarray(0, pos);
return new Uint8Array(await globalThis.docx.Packer.toArrayBuffer(doc));
`,
toResult: (bytes) => Buffer.from(bytes),
})
Loading
Loading