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
34 changes: 28 additions & 6 deletions apps/sim/lib/execution/sandbox/bundles/_polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,37 @@
* `ivm.Reference` per laverdet/isolated-vm#136) BEFORE the bundle runs, so
* `process/browser` picks up the real delegated `setTimeout`.
*
* The only thing this file still does is alias `global -> globalThis` for
* UMD-style fallbacks inside the bundles. 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.
* 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.
*/

const g: typeof globalThis & { global?: typeof globalThis } = globalThis
const g: typeof globalThis & {
global?: typeof globalThis
__require?: (id: string) => never
} = globalThis

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
* 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
* the build instead of shipping.
*/
if (typeof g.__require === 'undefined') {
g.__require = (id: string): never => {
throw new Error(`Dynamic require of "${id}" is not supported in the sandbox`)
}
}

export {}
21 changes: 18 additions & 3 deletions apps/sim/lib/execution/sandbox/bundles/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
* `fs`). The emitted files attach their exports to `globalThis.__bundles[name]`
* and are checked in so production images don't need the bundler at runtime.
*
* 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
* before a production document generation does.
*
* Run via: `bun run build:sandbox-bundles`.
*/

import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createLogger } from '@sim/logger'
import { evaluateSandboxBundle } from '@/lib/execution/sandbox/bundles/verify'
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'

const logger = createLogger('SandboxBundleBuild')

Expand All @@ -39,7 +46,7 @@ const APP_SIM_ROOT = join(HERE, '..', '..', '..', '..')

interface BundleSpec {
/** Key on `globalThis.__bundles`. */
name: string
name: SandboxBundleName
/** Short filename written under `bundles/<file>.cjs`. */
outFile: string
/** Source of the entry file bun will bundle. */
Expand Down Expand Up @@ -121,8 +128,16 @@ async function main(): Promise<void> {

const code = await result.outputs[0].text()
const banner = `// sandbox bundle: ${spec.name}\n// generated by apps/sim/lib/execution/sandbox/bundles/build.ts\n// do not edit by hand. run \`bun run build:sandbox-bundles\` to regenerate.\n`
writeFileSync(join(BUNDLES_DIR, spec.outFile), banner + code, 'utf-8')
logger.info(`built ${spec.outFile} (${code.length.toLocaleString()} chars)`)
const output = banner + code
try {
evaluateSandboxBundle(output, spec.name)
} catch (error) {
throw new Error(
`Sandbox bundle ${spec.name} does not evaluate in a bare isolate context: ${String(error)}`
)
}
writeFileSync(join(BUNDLES_DIR, spec.outFile), output, 'utf-8')
logger.info(`built and verified ${spec.outFile} (${code.length.toLocaleString()} chars)`)
}

rmSync(ENTRIES_DIR, { recursive: true, force: true })
Expand Down
42 changes: 21 additions & 21 deletions apps/sim/lib/execution/sandbox/bundles/docx.cjs

Large diffs are not rendered by default.

42 changes: 21 additions & 21 deletions apps/sim/lib/execution/sandbox/bundles/pdf-lib.cjs

Large diffs are not rendered by default.

125 changes: 62 additions & 63 deletions apps/sim/lib/execution/sandbox/bundles/pptxgenjs.cjs

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions apps/sim/lib/execution/sandbox/bundles/verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @vitest-environment node
*/
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { evaluateSandboxBundle } from '@/lib/execution/sandbox/bundles/verify'
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'

function loadCheckedInBundle(name: SandboxBundleName): Record<string, unknown> {
const source = readFileSync(new URL(`./${name}.cjs`, import.meta.url), 'utf-8')
return evaluateSandboxBundle(source, name) as Record<string, unknown>
}

/**
* The checked-in bundles are what Trigger.dev workers run verbatim, so this is
* the only place a bundle that throws while being evaluated is caught before a
* deploy. Each case asserts the surface the matching sandbox task's bootstrap
* and finalize scripts reach for.
*/
describe('sandbox bundles', () => {
it('docx evaluates in a bare context and exposes the docx-generate surface', () => {
const docx = loadCheckedInBundle('docx')
expect(typeof docx.Document).toBe('function')
expect(typeof docx.Packer).toBe('function')
expect(typeof docx.ImageRun).toBe('function')
expect(typeof docx.Paragraph).toBe('function')
})

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')
expect(typeof pdfLib.rgb).toBe('function')
expect(typeof pdfLib.StandardFonts).toBe('object')
})

it('pptxgenjs evaluates in a bare context and exposes its constructor', () => {
const source = readFileSync(new URL('./pptxgenjs.cjs', import.meta.url), 'utf-8')
expect(typeof evaluateSandboxBundle(source, 'pptxgenjs')).toBe('function')
})
})
39 changes: 39 additions & 0 deletions apps/sim/lib/execution/sandbox/bundles/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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.
*/
export function evaluateSandboxBundle(source: string, name: SandboxBundleName): unknown {
const context: Record<string, unknown> = {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
queueMicrotask,
console,
TextEncoder,
TextDecoder,
}
context.globalThis = context
vm.createContext(context)
vm.runInContext(source, context, { filename: `sandbox/${name}.cjs` })

const bundles = context.__bundles
const bundle =
typeof bundles === 'object' && bundles !== null
? (bundles as Record<string, unknown>)[name]
: undefined
if (bundle === undefined || bundle === null) {
throw new Error(
`Sandbox bundle "${name}" evaluated without registering globalThis.__bundles["${name}"]`
)
}
return bundle
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockDownloadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
}))

vi.mock('@/lib/billing/storage', () => ({
decrementStorageUsageForBillingContextInTx: vi.fn(),
incrementStorageUsageForBillingContextInTx: vi.fn(),
maybeNotifyStorageLimitForBillingContext: vi.fn(),
resolveStorageBillingContext: vi.fn(),
}))

vi.mock('@/lib/uploads', () => ({
getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'),
}))

vi.mock('@/lib/uploads/core/storage-service', () => ({
deleteFile: vi.fn(),
downloadFile: mockDownloadFile,
hasCloudStorage: vi.fn(() => false),
headObject: vi.fn(),
uploadFile: vi.fn(),
}))

vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
assertWorkspaceFileFolderTarget: vi.fn(async () => null),
buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()),
fileNameExistsInWorkspaceFolder: vi.fn(async () => false),
findWorkspaceFileFolderIdByPath: vi.fn(),
getWorkspaceFileFolderPath: vi.fn(),
listWorkspaceFileFolders: vi.fn(async () => []),
normalizeWorkspaceFileItemName: vi.fn((name: string) => name),
resolveWorkspaceFileFolderTarget: vi.fn(async () => null),
}))

import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import {
fetchWorkspaceFileBuffer,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'

const FILE: WorkspaceFileRecord = {
id: 'file-1',
workspaceId: 'workspace-1',
name: 'notes.txt',
key: 'workspace/workspace-1/notes.txt',
path: '/api/files/serve/workspace/workspace-1/notes.txt',
size: 5,
type: 'text/plain',
uploadedBy: 'user-1',
uploadedAt: new Date('2026-09-01T00:00:00.000Z'),
updatedAt: new Date('2026-09-01T00:00:00.000Z'),
}

function sizeLimitError(): unknown {
try {
assertKnownSizeWithinLimit(2, 1, 'test')
} catch (error) {
return error
}
throw new Error('assertKnownSizeWithinLimit did not throw')
}

describe('fetchWorkspaceFileBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('forwards the byte ceiling and the cancellation signal to storage', async () => {
const bytes = Buffer.from('hello')
mockDownloadFile.mockResolvedValue(bytes)
const signal = new AbortController().signal

await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10, signal })).resolves.toBe(bytes)
expect(mockDownloadFile).toHaveBeenCalledWith({
key: FILE.key,
context: 'workspace',
maxBytes: 10,
signal,
})
})

it('surfaces a cancelled read as the abort rather than a download failure', async () => {
const controller = new AbortController()
mockDownloadFile.mockImplementation(async () => {
controller.abort()
throw new Error('read interrupted')
})

await expect(
fetchWorkspaceFileBuffer(FILE, { maxBytes: 10, signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
})

it('rethrows a byte-ceiling breach unwrapped', async () => {
mockDownloadFile.mockRejectedValue(sizeLimitError())

await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10 })).rejects.toSatisfy(
isPayloadSizeLimitError
)
})

it('wraps other transport failures', async () => {
mockDownloadFile.mockRejectedValue(new Error('socket hang up'))

await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10 })).rejects.toThrow(
'Failed to download file: socket hang up'
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1678,7 +1678,7 @@ export async function fetchServableWorkspaceFileBuffer(
*/
export async function fetchWorkspaceFileBuffer(
fileRecord: WorkspaceFileRecord,
options: { maxBytes: number }
options: { maxBytes: number; signal?: AbortSignal }
): Promise<Buffer> {
logger.info(`Downloading workspace file: ${fileRecord.name}`)

Expand All @@ -1687,12 +1687,16 @@ export async function fetchWorkspaceFileBuffer(
key: fileRecord.key,
context: fileRecord.storageContext ?? 'workspace',
maxBytes: options.maxBytes,
signal: options.signal,
Comment thread
icecrasher321 marked this conversation as resolved.
})
logger.info(
`Successfully downloaded workspace file: ${fileRecord.name} (${buffer.length} bytes)`
)
return buffer
} catch (error) {
// A cancelled read is not a download failure: surface the abort itself so the
// caller sees cancellation, not a transport error it might retry or record.
options.signal?.throwIfAborted()
logger.error(`Failed to download workspace file ${fileRecord.name}:`, error)
// Rethrow a `maxBytes` breach unwrapped: callers distinguish "too large" from a
// transport failure to answer with their own placeholder, and re-wrapping it in a
Expand Down
Loading
Loading