From f1db97f57bf48340595e118fa0ffcc489e0d7f66 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Tue, 28 Jul 2026 11:55:14 -0700
Subject: [PATCH 1/2] fix(security): close ReDoS, zip-bomb, SSRF and redaction
gaps found by audit
A dependency re-audit asked "does this library actually deliver the guarantee
the calling code assumes" rather than "is it popular". These are the results.
Security:
- Copilot VFS glob() handed a caller-controlled pattern to micromatch, whose
picomatch backend compiles `*` into a backtracking regex. A 25-char pattern
took 41s; 29 chars exceeded 180s. Matching now runs on RE2, mirroring the
grep() sibling in the same file that already did this. micromatch.makeRe()
emits lookaheads RE2 cannot express, so picomatch's dot-assertions are
translated into character exclusions; a differential test covers 519,688
pattern/path pairs against micromatch with no mismatches. Patterns that
still carry an assertion fail closed rather than falling back.
- doc-parser was the only OOXML-adjacent parser without the zip-bomb guard.
officeparser sniffs content rather than extension, so a docx renamed .doc
reached an unguarded unzip and could OOM the shared server. Genuine OLE2
.doc files are unaffected. Same guard added to the copilot style reader.
- json-yaml-chunker called yaml.load with no alias-expansion cap. js-yaml has
no maxAliasCount, so this is routed through assertYamlWithinLimits like the
file parser already was.
- Video tools and falai fetched provider-supplied URLs raw, including one that
replayed an Authorization header to a polled status_url. Those URLs are now
origin-pinned and revalidated on every poll. Cross-origin redirects drop
credential headers by default, keyed on registrable domain rather than exact
origin so same-site subdomain hops and http->https upgrades keep working.
- Redaction key patterns were fully anchored, so openai_api_key, x-api-key,
set-cookie, secretAccessKey and session_id were logged in the clear. The
matcher now tokenizes keys, with exemptions so token-usage fields such as
promptTokens stay readable.
- drawtext escaping did not match ffmpeg's quoting grammar; a quote in agent
supplied text escaped into filter options. Text is passed via textfile= so
it never enters the filter string.
- Loop conditions interpolated a resolved value into a JS string by hand.
Supply chain:
- Vendor free-email-domains' data. Its postinstall fetched a CDN CSV and
overwrote the shipped list, so the lockfile hash covered the tarball but not
the data actually used. It is inert under Bun today only because the package
is outside trustedDependencies.
- Document why xlsx is pinned to the SheetJS CDN: the npm copy is frozen at
0.18.5 with two unfixed CVEs, and a URL dependency is invisible to audit
tooling, so upgrades have to be tracked by hand.
Dependencies:
- @daytonaio/sdk is deprecated in favour of @daytona/sdk (same API).
- The @react-email/components 0.x line is deprecated; 1.0.12 needs no source
changes here since no template uses and render() already moved.
- Load the pptx preview lazily so echarts leaves the Home, Files and share
page bundles.
- Drop autoprefixer (absent from the PostCSS config, so it never ran) and
tailwind-merge (no importers; @sim/emcn declares its own).
---
.../app/api/tools/onedrive/upload/route.ts | 1 +
apps/sim/app/api/tools/video/route.test.ts | 136 +
apps/sim/app/api/tools/video/route.ts | 203 +-
.../components/file-viewer/file-viewer.tsx | 24 +-
.../file-viewer/preview-shared.test.tsx | 154 +
.../components/file-viewer/preview-shared.tsx | 81 +-
apps/sim/executor/orchestrators/loop.test.ts | 70 +
apps/sim/executor/orchestrators/loop.ts | 6 +-
.../lib/chunkers/json-yaml-chunker.test.ts | 56 +
apps/sim/lib/chunkers/json-yaml-chunker.ts | 32 +-
.../lib/copilot/tools/handlers/vfs.test.ts | 20 +-
apps/sim/lib/copilot/tools/handlers/vfs.ts | 13 +-
.../lib/copilot/vfs/document-style.test.ts | 52 +
apps/sim/lib/copilot/vfs/document-style.ts | 3 +
.../vfs/operations.glob-semantics.test.ts | 345 ++
apps/sim/lib/copilot/vfs/operations.test.ts | 118 +-
apps/sim/lib/copilot/vfs/operations.ts | 815 ++-
.../core/security/input-validation.server.ts | 116 +-
apps/sim/lib/core/security/redaction.test.ts | 199 +-
apps/sim/lib/core/security/redaction.ts | 218 +-
.../secure-fetch-redirect.server.test.ts | 309 ++
.../remote-sandbox/conformance.test.ts | 2 +-
.../lib/execution/remote-sandbox/daytona.ts | 2 +-
apps/sim/lib/file-parsers/doc-parser.test.ts | 107 +
apps/sim/lib/file-parsers/doc-parser.ts | 11 +
apps/sim/lib/file-parsers/xlsx-parser.ts | 5 +
apps/sim/lib/file-parsers/zip-guard.test.ts | 273 +-
apps/sim/lib/file-parsers/zip-guard.ts | 272 +-
apps/sim/lib/media/falai.test.ts | 204 +
apps/sim/lib/media/falai.ts | 126 +-
apps/sim/lib/media/ffmpeg.test.ts | 195 +
apps/sim/lib/media/ffmpeg.ts | 77 +-
.../messaging/email/free-email-domains.json | 4781 +++++++++++++++++
apps/sim/lib/messaging/email/free-email.ts | 9 +-
apps/sim/next.config.ts | 2 +-
apps/sim/package.json | 11 +-
apps/sim/scripts/build-pi-daytona-snapshot.ts | 2 +-
bun.lock | 333 +-
38 files changed, 8937 insertions(+), 446 deletions(-)
create mode 100644 apps/sim/app/api/tools/video/route.test.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
create mode 100644 apps/sim/lib/copilot/vfs/document-style.test.ts
create mode 100644 apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
create mode 100644 apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
create mode 100644 apps/sim/lib/file-parsers/doc-parser.test.ts
create mode 100644 apps/sim/lib/media/falai.test.ts
create mode 100644 apps/sim/lib/media/ffmpeg.test.ts
create mode 100644 apps/sim/lib/messaging/email/free-email-domains.json
diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts
index 2c94986de9f..2a0d1d59477 100644
--- a/apps/sim/app/api/tools/onedrive/upload/route.ts
+++ b/apps/sim/app/api/tools/onedrive/upload/route.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
+/** Pinned to the SheetJS CDN, not npm — see the note in `lib/file-parsers/xlsx-parser.ts`. */
import * as XLSX from 'xlsx'
import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft'
import { parseRequest } from '@/lib/api/server'
diff --git a/apps/sim/app/api/tools/video/route.test.ts b/apps/sim/app/api/tools/video/route.test.ts
new file mode 100644
index 00000000000..576b487a0ae
--- /dev/null
+++ b/apps/sim/app/api/tools/video/route.test.ts
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ hybridAuthMockFns,
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { MAX_FAL_QUEUE_JSON_BYTES } from '@/lib/media/falai'
+
+const { mockUploadFile } = vi.hoisted(() => ({
+ mockUploadFile: vi.fn(),
+}))
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 30_000 }))
+vi.mock('@sim/utils/helpers', () => ({ sleep: () => Promise.resolve() }))
+vi.mock('@/app/api/files/authorization', () => ({
+ assertToolFileAccess: vi.fn().mockResolvedValue(null),
+}))
+vi.mock('@/lib/uploads', () => ({
+ StorageService: { uploadFile: mockUploadFile },
+}))
+vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
+
+import { POST } from '@/app/api/tools/video/route'
+
+const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
+
+function jsonResponse(payload: unknown) {
+ const text = JSON.stringify(payload)
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => null },
+ body: null,
+ text: async () => text,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ }
+}
+
+function videoResponse() {
+ return {
+ ok: true,
+ status: 200,
+ headers: {
+ get: (name: string) => {
+ if (name === 'content-type') return 'video/mp4'
+ return name === 'content-length' ? '8' : null
+ },
+ },
+ body: null,
+ text: async () => '',
+ arrayBuffer: async () => new ArrayBuffer(8),
+ }
+}
+
+const baseBody = {
+ provider: 'falai',
+ apiKey: 'fal-key',
+ model: 'kling-v3-pro',
+ prompt: 'a cat riding a bike',
+}
+
+describe('POST /api/tools/video (Fal.ai queue)', () => {
+ const fetchMock = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', fetchMock)
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' })
+ mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' })
+ })
+
+ it('normalizes the echoed /response URL and bounds queue reads with the shared Fal cap', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-1',
+ status_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/status',
+ response_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/response',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+ .mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+
+ const [statusCall, resultCall, downloadCall] = mockSecureFetchWithPinnedIP.mock.calls
+ expect(statusCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1/status')
+ // `/response` is not a GET route on queue.fal.run — it must be stripped.
+ expect(resultCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1')
+ expect(downloadCall[0]).toBe('https://cdn.fal.media/a.mp4')
+
+ expect(statusCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES)
+ expect(resultCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES)
+ })
+
+ it('falls back to the constructed multi-segment queue URL when the candidate is off-origin', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-2',
+ status_url: 'https://evil.example.net/steal',
+ response_url: 'https://evil.example.net/steal',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+ .mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+
+ // `fal-ai/kling-video/v3/pro/text-to-video` polls under the app id only.
+ expect(mockSecureFetchWithPinnedIP.mock.calls.slice(0, 2).map(([url]) => url)).toEqual([
+ 'https://queue.fal.run/fal-ai/kling-video/requests/req-2/status',
+ 'https://queue.fal.run/fal-ai/kling-video/requests/req-2',
+ ])
+ })
+})
diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts
index b98d4fed2c5..7f832dea74b 100644
--- a/apps/sim/app/api/tools/video/route.ts
+++ b/apps/sim/app/api/tools/video/route.ts
@@ -8,6 +8,10 @@ import { videoProviders, videoToolContract } from '@/lib/api/contracts/tools/med
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
+import {
+ secureFetchWithPinnedIP,
+ validateUrlWithDNS,
+} from '@/lib/core/security/input-validation.server'
import {
assertKnownSizeWithinLimit,
DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -18,6 +22,12 @@ import {
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ buildFalQueueUrl,
+ FAL_QUEUE_ORIGIN,
+ MAX_FAL_QUEUE_JSON_BYTES,
+ resolveFalQueueUrl,
+} from '@/lib/media/falai'
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
@@ -36,7 +46,23 @@ export const dynamic = 'force-dynamic'
*/
export const maxDuration = 5400
-async function readVideoResponseBuffer(response: Response, label: string): Promise {
+/**
+ * Structural shape shared by `fetch`'s `Response` and the SSRF-guarded
+ * `SecureFetchResponse`, so the body readers below accept either.
+ */
+interface ReadableHttpResponse {
+ ok: boolean
+ status: number
+ headers: { get(name: string): string | null }
+ body?: ReadableStream | null
+ arrayBuffer?: () => Promise
+ text?: () => Promise
+}
+
+async function readVideoResponseBuffer(
+ response: ReadableHttpResponse,
+ label: string
+): Promise {
return readResponseToBufferWithLimit(response, {
maxBytes: MAX_VIDEO_OUTPUT_BYTES,
label,
@@ -44,22 +70,66 @@ async function readVideoResponseBuffer(response: Response, label: string): Promi
}
async function readVideoJson>(
- response: Response,
- label: string
+ response: ReadableHttpResponse,
+ label: string,
+ maxBytes: number = MAX_VIDEO_JSON_BYTES
): Promise {
return readResponseJsonWithLimit(response, {
- maxBytes: MAX_VIDEO_JSON_BYTES,
+ maxBytes,
label,
})
}
-async function readVideoErrorText(response: Response, label: string): Promise {
+async function readVideoErrorText(response: ReadableHttpResponse, label: string): Promise {
return readResponseTextWithLimit(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label,
}).catch(() => '')
}
+/**
+ * Fetches a provider-supplied URL through the SSRF-guarded client. These URLs come out of
+ * provider response bodies, so they are untrusted input.
+ */
+async function secureGetFromUntrustedUrl(
+ url: string,
+ paramName: string,
+ options: { headers?: Record; maxResponseBytes: number }
+) {
+ const validation = await validateUrlWithDNS(url, paramName)
+ if (!validation.isValid || !validation.resolvedIP) {
+ throw new Error(validation.error || `${paramName} failed validation`)
+ }
+ return secureFetchWithPinnedIP(url, validation.resolvedIP, {
+ method: 'GET',
+ headers: options.headers,
+ maxResponseBytes: options.maxResponseBytes,
+ })
+}
+
+/**
+ * Downloads a provider-supplied video URL through the SSRF-guarded client.
+ * `errorPrefix` overrides the thrown message so each provider keeps the exact
+ * failure string runbooks and log filters key on.
+ */
+async function downloadVideoFromUrl(
+ url: string,
+ label: string,
+ options?: { headers?: Record; errorPrefix?: string }
+): Promise {
+ const response = await secureGetFromUntrustedUrl(url, 'videoUrl', {
+ headers: options?.headers,
+ maxResponseBytes: MAX_VIDEO_OUTPUT_BYTES,
+ })
+
+ if (!response.ok) {
+ await readVideoErrorText(response, `${label} error response`)
+ throw new Error(`${options?.errorPrefix ?? 'Failed to download video'}: ${response.status}`)
+ }
+
+ return readVideoResponseBuffer(response, `${label} response`)
+}
+
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId()
logger.info(`[${requestId}] Video generation request started`)
@@ -461,14 +531,8 @@ async function generateWithRunway(
throw new Error('No video URL in response')
}
- const videoResponse = await fetch(videoUrl)
- if (!videoResponse.ok) {
- await readVideoErrorText(videoResponse, 'Runway video error response')
- throw new Error(`Failed to download video: ${videoResponse.status}`)
- }
-
return {
- buffer: await readVideoResponseBuffer(videoResponse, 'Runway video response'),
+ buffer: await downloadVideoFromUrl(videoUrl, 'Runway video'),
width: dimensions.width,
height: dimensions.height,
jobId: taskId,
@@ -486,6 +550,21 @@ async function generateWithRunway(
throw new Error('Runway generation timed out')
}
+/**
+ * True when a provider-supplied URI is served by Google. The Veo download URI comes out of
+ * the operation status body, so the `x-goog-api-key` credential is only attached when the
+ * target really is a Google host — a hostile or spoofed URI never receives the key.
+ */
+function isGoogleApiHost(url: string): boolean {
+ try {
+ const { protocol, hostname } = new URL(url)
+ if (protocol !== 'https:') return false
+ return hostname === 'googleapis.com' || hostname.endsWith('.googleapis.com')
+ } catch {
+ return false
+ }
+}
+
async function generateWithVeo(
apiKey: string,
model: string,
@@ -583,19 +662,10 @@ async function generateWithVeo(
throw new Error('No video URI in response')
}
- const videoResponse = await fetch(videoUri, {
- headers: {
- 'x-goog-api-key': apiKey,
- },
- })
-
- if (!videoResponse.ok) {
- await readVideoErrorText(videoResponse, 'Veo video error response')
- throw new Error(`Failed to download video: ${videoResponse.status}`)
- }
-
return {
- buffer: await readVideoResponseBuffer(videoResponse, 'Veo video response'),
+ buffer: await downloadVideoFromUrl(videoUri, 'Veo video', {
+ headers: isGoogleApiHost(videoUri) ? { 'x-goog-api-key': apiKey } : undefined,
+ }),
width: dimensions.width,
height: dimensions.height,
jobId: operationName,
@@ -697,14 +767,8 @@ async function generateWithLuma(
throw new Error('No video URL in response')
}
- const videoResponse = await fetch(videoUrl)
- if (!videoResponse.ok) {
- await readVideoErrorText(videoResponse, 'Luma video error response')
- throw new Error(`Failed to download video: ${videoResponse.status}`)
- }
-
return {
- buffer: await readVideoResponseBuffer(videoResponse, 'Luma video response'),
+ buffer: await downloadVideoFromUrl(videoUrl, 'Luma video'),
width: dimensions.width,
height: dimensions.height,
jobId: generationId,
@@ -859,15 +923,10 @@ async function generateWithMiniMax(
throw new Error('No download URL in file response')
}
- // Download the actual video file
- const videoResponse = await fetch(videoUrl)
- if (!videoResponse.ok) {
- await readVideoErrorText(videoResponse, 'MiniMax video error response')
- throw new Error(`Failed to download video from URL: ${videoResponse.status}`)
- }
-
return {
- buffer: await readVideoResponseBuffer(videoResponse, 'MiniMax video response'),
+ buffer: await downloadVideoFromUrl(videoUrl, 'MiniMax video', {
+ errorPrefix: 'Failed to download video from URL',
+ }),
width: dimensions.width,
height: dimensions.height,
jobId: taskId,
@@ -1160,12 +1219,20 @@ function getFalAIErrorMessage(error: unknown): string {
return 'Unknown error'
}
-function buildFalAIQueueUrl(
- endpoint: string,
- requestId: string,
- path: 'response' | 'status'
-): string {
- return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}`
+/**
+ * Requests a Fal.ai queue URL through the SSRF-guarded client. Invoked on every poll so
+ * the provider-supplied URL is revalidated each time rather than trusted once.
+ */
+async function fetchFalAIQueue(url: string, apiKey: string) {
+ return secureGetFromUntrustedUrl(url, 'falQueueUrl', {
+ headers: { Authorization: `Key ${apiKey}` },
+ maxResponseBytes: MAX_FAL_QUEUE_JSON_BYTES,
+ })
+}
+
+/** Fal.ai queue envelopes share the cap enforced by `runFalQueue`, not the generic video cap. */
+async function readFalQueueJson(response: ReadableHttpResponse, label: string): Promise {
+ return readVideoJson(response, label, MAX_FAL_QUEUE_JSON_BYTES)
}
async function generateWithFalAI(
@@ -1218,7 +1285,7 @@ async function generateWithFalAI(
requestBody.generate_audio = generateAudio
}
- const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, {
+ const createResponse = await fetch(`${FAL_QUEUE_ORIGIN}/${modelConfig.endpoint}`, {
method: 'POST',
headers: {
Authorization: `Key ${apiKey}`,
@@ -1232,7 +1299,7 @@ async function generateWithFalAI(
throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`)
}
- const createData = await readVideoJson(createResponse, 'Fal.ai queue response')
+ const createData = await readFalQueueJson(createResponse, 'Fal.ai queue response')
if (!isRecordLike(createData)) {
throw new Error('Invalid Fal.ai queue response')
}
@@ -1242,12 +1309,14 @@ async function generateWithFalAI(
throw new Error('Fal.ai queue response missing request_id')
}
- const statusUrl =
- getStringProperty(createData, 'status_url') ||
- buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'status')
- const responseUrl =
- getStringProperty(createData, 'response_url') ||
- buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'response')
+ const statusUrl = resolveFalQueueUrl(
+ getStringProperty(createData, 'status_url'),
+ buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'status')
+ )
+ const responseUrl = resolveFalQueueUrl(
+ getStringProperty(createData, 'response_url'),
+ buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'result')
+ )
logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`)
@@ -1258,18 +1327,14 @@ async function generateWithFalAI(
while (attempts < maxAttempts) {
await sleep(pollIntervalMs)
- const statusResponse = await fetch(statusUrl, {
- headers: {
- Authorization: `Key ${apiKey}`,
- },
- })
+ const statusResponse = await fetchFalAIQueue(statusUrl, apiKey)
if (!statusResponse.ok) {
await readVideoErrorText(statusResponse, 'Fal.ai status error response')
throw new Error(`Fal.ai status check failed: ${statusResponse.status}`)
}
- const statusData = await readVideoJson(statusResponse, 'Fal.ai status response')
+ const statusData = await readFalQueueJson(statusResponse, 'Fal.ai status response')
if (!isRecordLike(statusData)) {
throw new Error('Invalid Fal.ai status response')
}
@@ -1282,13 +1347,9 @@ async function generateWithFalAI(
logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`)
- const resultResponse = await fetch(
- getStringProperty(statusData, 'response_url') || responseUrl,
- {
- headers: {
- Authorization: `Key ${apiKey}`,
- },
- }
+ const resultResponse = await fetchFalAIQueue(
+ resolveFalQueueUrl(getStringProperty(statusData, 'response_url'), responseUrl),
+ apiKey
)
if (!resultResponse.ok) {
@@ -1296,7 +1357,7 @@ async function generateWithFalAI(
throw new Error(`Failed to fetch result: ${resultResponse.status}`)
}
- const resultData = await readVideoJson(resultResponse, 'Fal.ai result response')
+ const resultData = await readFalQueueJson(resultResponse, 'Fal.ai result response')
if (!isRecordLike(resultData)) {
throw new Error('Invalid Fal.ai result response')
}
@@ -1309,11 +1370,7 @@ async function generateWithFalAI(
throw new Error('No video URL in response')
}
- const videoResponse = await fetch(videoUrl)
- if (!videoResponse.ok) {
- await readVideoErrorText(videoResponse, 'Fal.ai video error response')
- throw new Error(`Failed to download video: ${videoResponse.status}`)
- }
+ const videoBuffer = await downloadVideoFromUrl(videoUrl, 'Fal.ai video')
let width = getNumberProperty(videoOutput, 'width') || 1920
let height = getNumberProperty(videoOutput, 'height') || 1080
@@ -1325,7 +1382,7 @@ async function generateWithFalAI(
}
return {
- buffer: await readVideoResponseBuffer(videoResponse, 'Fal.ai video response'),
+ buffer: videoBuffer,
width,
height,
jobId: requestIdFal,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 9984e16b0df..daf99a09dac 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -16,7 +16,6 @@ import { DocxPreview } from './docx-preview'
import { resolveFileCategory } from './file-category'
import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
-import { PptxPreview } from './pptx-preview'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import {
PREVIEW_LOADING_OVERLAY,
@@ -33,6 +32,23 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
ssr: false,
})
+/**
+ * Lazy so `echarts` (~330 KB gzip, reached via `pptx-sandbox-host` →
+ * `lib/pptx-renderer/renderer/chart-renderer`) stays out of every bundle that
+ * statically imports this viewer - the Files page, the Home view and the public
+ * share page - instead of only the visitors who open a PowerPoint file. The
+ * fallback matches {@link PptxPreview}'s own pre-fetch frame, so the chunk load
+ * and the binary fetch look like one continuous loading state. Rendered inside a
+ * {@link PreviewErrorBoundary} so a rejected chunk load degrades to the preview
+ * fallback — which offers a page reload, the only way to refetch a chunk whose
+ * rejection the module system has cached — instead of unwinding to the route
+ * error boundary.
+ */
+const PptxPreview = dynamic(() => import('./pptx-preview').then((m) => m.PptxPreview), {
+ ssr: false,
+ loading: () => ,
+})
+
const RichMarkdownEditor = dynamic(
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
{ ssr: false, loading: () => }
@@ -229,7 +245,11 @@ function FileViewerContent({
}
if (category === 'pptx-previewable') {
- return
+ return (
+
+
+
+ )
}
if (category === 'xlsx-previewable') {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
new file mode 100644
index 00000000000..ce059eab717
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * `PreviewErrorBoundary` must contain a *rejected dynamic import* — the failure mode
+ * introduced by lazy-loading the PDF and PowerPoint renderers. Without it a chunk-load
+ * failure unwinds to the route-level `error.tsx` and blanks the whole Files page, the
+ * Home resource panel and the public share page, all of which statically import the viewer.
+ *
+ * It must also recover in place: the boundary's error state only ever cleared via remount,
+ * so a tripped boundary stayed on the fallback until the user navigated to another file
+ * and back.
+ */
+import { act, lazy, Suspense } from 'react'
+import { sleep } from '@sim/utils/helpers'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ PreviewError,
+ PreviewErrorBoundary,
+} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
+
+let container: HTMLDivElement | null = null
+let root: Root | null = null
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ container = null
+ root = null
+ vi.restoreAllMocks()
+})
+
+async function render(node: React.ReactNode) {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ await act(async () => {
+ root?.render(node)
+ })
+}
+
+function actionLabels(): string[] {
+ return Array.from(container?.querySelectorAll('button') ?? []).map((b) => b.textContent ?? '')
+}
+
+async function clickAction(label: string) {
+ const button = Array.from(container?.querySelectorAll('button') ?? []).find(
+ (b) => b.textContent === label
+ )
+ if (!button)
+ throw new Error(`No "${label}" action rendered (found: ${actionLabels().join(', ')})`)
+ await act(async () => {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+}
+
+describe('PreviewErrorBoundary', () => {
+ it('renders the preview fallback when a lazy chunk fails to load', async () => {
+ const Broken = lazy(() => Promise.reject(new Error('Loading chunk 4821 failed')))
+
+ await render(
+
+ loading}>
+
+
+
+ )
+
+ expect(container?.textContent).toContain('Failed to preview PowerPoint')
+ expect(container?.textContent).toContain('Loading chunk 4821 failed')
+ })
+
+ it('offers a page reload — not a retry — for a cached chunk rejection', async () => {
+ const Broken = lazy(() => Promise.reject(new Error('Loading chunk 4821 failed')))
+
+ await render(
+
+ loading}>
+
+
+
+ )
+
+ expect(actionLabels()).toEqual(['Reload page'])
+ })
+
+ it('renders children when nothing throws', async () => {
+ await render(
+
+ preview
+
+ )
+
+ expect(container?.textContent).toBe('preview')
+ })
+
+ it('recovers in place when the retried render succeeds', async () => {
+ let shouldThrow = true
+ function Flaky() {
+ if (shouldThrow) throw new Error('transient render failure')
+ return preview
+ }
+
+ await render(
+
+
+
+ )
+ expect(container?.textContent).toContain('transient render failure')
+
+ shouldThrow = false
+ await clickAction('Try again')
+
+ expect(container?.textContent).toBe('preview')
+ })
+
+ it('settles back on the fallback — never loops — when the retried render throws again', async () => {
+ const renderSpy = vi.fn()
+ function AlwaysThrows(): never {
+ renderSpy()
+ throw new Error('still broken')
+ }
+
+ await render(
+
+
+
+ )
+ const attemptsBeforeRetry = renderSpy.mock.calls.length
+
+ await clickAction('Try again')
+ const attemptsAfterRetry = renderSpy.mock.calls.length
+
+ expect(attemptsAfterRetry).toBeGreaterThan(attemptsBeforeRetry)
+
+ await act(async () => {
+ await sleep(1)
+ })
+
+ expect(renderSpy.mock.calls.length).toBe(attemptsAfterRetry)
+ expect(container?.textContent).toContain('Failed to preview PDF')
+ expect(container?.textContent).toContain('still broken')
+ expect(actionLabels()).toEqual(['Try again'])
+ })
+})
+
+describe('PreviewError', () => {
+ it('renders no action when none is supplied', async () => {
+ await render()
+
+ expect(container?.textContent).toContain('Failed to preview CSV')
+ expect(container?.querySelectorAll('button')).toHaveLength(0)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
index 041a0225074..fe8ab0c7bbb 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
@@ -1,23 +1,54 @@
'use client'
-import { Component, type ErrorInfo, type ReactNode } from 'react'
-import { cn } from '@sim/emcn'
+import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react'
+import { Chip, cn } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
const logger = createLogger('FilePreview')
-export function PreviewError({ label, error }: { label: string; error: string }) {
+interface PreviewErrorAction {
+ label: string
+ onClick: () => void
+}
+
+interface PreviewErrorProps {
+ /** Format label shown in the message, e.g. "PDF". */
+ label: string
+ error: string
+ /** Recovery affordance. Omitted for fallbacks with nothing to retry. */
+ action?: PreviewErrorAction
+}
+
+export function PreviewError({ label, error, action }: PreviewErrorProps) {
return (
Failed to preview {label}
{error}
+ {action ? (
+
+ {action.label}
+
+ ) : null}
)
}
+/**
+ * A `next/dynamic` / `React.lazy` chunk fetch that rejected. The module system
+ * caches the rejection on the lazy component itself, so re-rendering it throws
+ * the same error synchronously — only a fresh page load can refetch the chunk.
+ */
+function isChunkLoadError(error: Error | undefined): boolean {
+ if (!error) return false
+ if (error.name === 'ChunkLoadError') return true
+ return (
+ error.message.includes('Loading chunk') || error.message.includes('dynamically imported module')
+ )
+}
+
interface PreviewErrorBoundaryProps {
/** Format label shown in the fallback, e.g. "PDF". */
label: string
@@ -27,6 +58,8 @@ interface PreviewErrorBoundaryProps {
interface PreviewErrorBoundaryState {
hasError: boolean
error?: Error
+ /** Bumped by "Try again" so the retried subtree remounts rather than resuming. */
+ attempt: number
}
/**
@@ -35,9 +68,15 @@ interface PreviewErrorBoundaryState {
* PreviewError fallback instead of unwinding to the route-level error boundary
* and replacing the whole workspace view.
*
- * Callers must `key` this boundary by the identity of the rendered content
- * (e.g. file id + data version) — the error state resets only via remount, so
- * keying the child alone would leave a tripped boundary stuck on the fallback.
+ * The fallback recovers in place, so a tripped boundary is never stuck:
+ * - A render-time crash offers "Try again", which clears the error and remounts
+ * the subtree. If the cause is still there the child throws once more and the
+ * fallback returns — bounded, never a retry loop.
+ * - A rejected chunk load is cached by the module system and cannot be retried
+ * by re-rendering, so it offers "Reload page" instead.
+ *
+ * Keying the boundary by content identity (e.g. file id + data version) is still
+ * worthwhile so a *different* file starts from a clean boundary.
*/
export class PreviewErrorBoundary extends Component<
PreviewErrorBoundaryProps,
@@ -45,9 +84,12 @@ export class PreviewErrorBoundary extends Component<
> {
public state: PreviewErrorBoundaryState = {
hasError: false,
+ attempt: 0,
}
- public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
+ public static getDerivedStateFromError(
+ error: Error
+ ): Pick {
return { hasError: true, error }
}
@@ -59,17 +101,36 @@ export class PreviewErrorBoundary extends Component<
})
}
+ private readonly handleRetry = () => {
+ this.setState((previous) => ({
+ hasError: false,
+ error: undefined,
+ attempt: previous.attempt + 1,
+ }))
+ }
+
+ private readonly handleReload = () => {
+ window.location.reload()
+ }
+
public render() {
- if (this.state.hasError) {
+ const { attempt, error, hasError } = this.state
+
+ if (hasError) {
return (
)
}
- return this.props.children
+ return {this.props.children}
}
}
diff --git a/apps/sim/executor/orchestrators/loop.test.ts b/apps/sim/executor/orchestrators/loop.test.ts
index 9180fc2bd8d..f03f4a8f598 100644
--- a/apps/sim/executor/orchestrators/loop.test.ts
+++ b/apps/sim/executor/orchestrators/loop.test.ts
@@ -308,6 +308,76 @@ describe('LoopOrchestrator', () => {
)
})
+ describe('while condition serialization', () => {
+ /**
+ * Runs one while-loop condition evaluation with `resolved` standing in for the value a
+ * `` reference resolves to, and returns the JavaScript handed to the VM.
+ */
+ async function evaluatedCode(resolved: unknown): Promise {
+ const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) }
+ mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: false })
+ const orchestrator = new LoopOrchestrator(
+ { loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any,
+ createState(),
+ resolver as any
+ )
+ const ctx = createContext({
+ iteration: 0,
+ currentIterationOutputs: new Map(),
+ allIterationOutputs: [],
+ loopType: 'while',
+ condition: '',
+ })
+
+ await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
+
+ return mockExecuteInIsolatedVM.mock.calls.at(-1)?.[0].code
+ }
+
+ it('inlines an ordinary string as a quoted literal', async () => {
+ expect(await evaluatedCode('hello')).toBe('return Boolean("hello")')
+ })
+
+ it('keeps numbers, booleans and null unquoted', async () => {
+ expect(await evaluatedCode(42)).toBe('return Boolean(42)')
+ expect(await evaluatedCode(true)).toBe('return Boolean(true)')
+ expect(await evaluatedCode(null)).toBe('return Boolean(null)')
+ })
+
+ it('still folds a boolean-valued string to a bare boolean', async () => {
+ expect(await evaluatedCode('true')).toBe('return Boolean(true)')
+ expect(await evaluatedCode(' FALSE ')).toBe('return Boolean(false)')
+ })
+
+ it('serializes objects and arrays', async () => {
+ expect(await evaluatedCode({ a: 1 })).toBe('return Boolean({"a":1})')
+ expect(await evaluatedCode(['x'])).toBe('return Boolean(["x"])')
+ })
+
+ it('escapes a quote instead of closing the literal', async () => {
+ const code = await evaluatedCode('he said "hi"')
+ expect(code).toBe('return Boolean("he said \\"hi\\"")')
+ expect(() => new Function(code)()).not.toThrow()
+ expect(new Function(code)()).toBe(true)
+ })
+
+ it('escapes a newline instead of breaking the statement', async () => {
+ const code = await evaluatedCode('line1\nline2')
+ expect(code).toBe('return Boolean("line1\\nline2")')
+ expect(code).not.toContain('\n')
+ expect(new Function(code)()).toBe(true)
+ })
+
+ it('treats an injection payload as data, not code', async () => {
+ // A block output an attacker controls — an API response, webhook body, or model
+ // completion. Wrapped in quotes by hand this closed the literal and ran.
+ const code = await evaluatedCode('" + (globalThis.__pwned = true) + "')
+ expect(code).toBe('return Boolean("\\" + (globalThis.__pwned = true) + \\"")')
+ new Function(code)()
+ expect((globalThis as Record).__pwned).toBeUndefined()
+ })
+ })
+
it('exits doWhile loops when the configured iteration cap is reached', async () => {
const { orchestrator } = createOrchestrator()
const ctx = createContext({
diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts
index 913de932915..a3feb87ca51 100644
--- a/apps/sim/executor/orchestrators/loop.ts
+++ b/apps/sim/executor/orchestrators/loop.ts
@@ -732,7 +732,11 @@ export class LoopOrchestrator {
if (lower === 'true' || lower === 'false') {
return lower
}
- return `"${resolved}"`
+ /**
+ * Serialized rather than hand-quoted: the value is a block output, so a `"` or
+ * newline would close the literal early and run as code in the condition VM.
+ */
+ return JSON.stringify(resolved)
}
return JSON.stringify(resolved)
}
diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts
index 8ac0eafada8..3d542ac4c94 100644
--- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts
+++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts
@@ -3,8 +3,26 @@
*/
import { describe, expect, it, vi } from 'vitest'
+import { YamlComplexityError } from '@/lib/file-parsers/yaml-parser'
import { JsonYamlChunker } from './json-yaml-chunker'
+/**
+ * Build a chained alias-expansion ("billion laughs") YAML bomb: each level is
+ * an array that references the previous level `width` times, so the expanded
+ * node count grows as `width ^ levels` while the source stays tiny.
+ */
+function buildAliasBomb(levels: number, width: number): string {
+ const lines: string[] = [`l0: &l0 [${Array(width).fill('"x"').join(',')}]`]
+ for (let i = 1; i <= levels; i++) {
+ const refs = Array(width)
+ .fill(`*l${i - 1}`)
+ .join(',')
+ lines.push(`l${i}: &l${i} [${refs}]`)
+ }
+ lines.push(`root: [${Array(width).fill(`*l${levels}`).join(',')}]`)
+ return lines.join('\n')
+}
+
vi.mock('@/lib/tokenization', () => ({
getAccurateTokenCount: (text: string) => Math.ceil(text.length / 4),
}))
@@ -372,4 +390,42 @@ server:
expect(chunks[0].text.length).toBeGreaterThan(0)
})
})
+
+ describe('YAML alias-expansion guard', () => {
+ it.concurrent('should reject an alias-expansion bomb instead of chunking it', async () => {
+ const bomb = buildAliasBomb(9, 10)
+ expect(Buffer.byteLength(bomb)).toBeLessThan(2048)
+
+ const chunker = new JsonYamlChunker({ chunkSize: 100 })
+ await expect(chunker.chunk(bomb)).rejects.toBeInstanceOf(YamlComplexityError)
+ })
+
+ it.concurrent('should not classify an alias-expansion bomb as structured data', () => {
+ expect(JsonYamlChunker.isStructuredData(buildAliasBomb(9, 10))).toBe(false)
+ })
+
+ it.concurrent('should still chunk an ordinary YAML document', async () => {
+ const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 })
+ const chunks = await chunker.chunk('name: sim\nnested:\n key: value\nlist:\n - a\n - b\n')
+
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(chunks.map((c) => c.text).join('\n')).toContain('sim')
+ })
+
+ it.concurrent('should still chunk YAML that uses benign aliases', async () => {
+ const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 })
+ const chunks = await chunker.chunk('base: &base\n region: us\nprod: *base\nstaging: *base\n')
+
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(chunks.map((c) => c.text).join('\n')).toContain('us')
+ })
+
+ it.concurrent('should still chunk ordinary JSON without touching the YAML path', async () => {
+ const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 })
+ const chunks = await chunker.chunk(JSON.stringify({ name: 'sim', values: [1, 2, 3] }))
+
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(chunks.map((c) => c.text).join('\n')).toContain('sim')
+ })
+ })
})
diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts
index d18cd0859f9..d557ce54451 100644
--- a/apps/sim/lib/chunkers/json-yaml-chunker.ts
+++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import * as yaml from 'js-yaml'
import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types'
import { estimateTokens } from '@/lib/chunkers/utils'
+import { assertYamlWithinLimits, isYamlComplexityError } from '@/lib/file-parsers/yaml-parser'
const logger = createLogger('JsonYamlChunker')
@@ -12,6 +13,21 @@ type JsonArray = JsonValue[]
const MAX_DEPTH = 5
+/**
+ * Parse YAML and validate the expanded document against the shared complexity
+ * limits before it is handed to the chunker. `js-yaml` has no `maxAliasCount`,
+ * so an unguarded `yaml.load` on untrusted knowledge-base input is an uncapped
+ * alias-expansion ("billion laughs") bomb: the parsed value is a compact DAG,
+ * but the `JSON.stringify` calls throughout chunking expand it into a full tree.
+ *
+ * @throws {import('@/lib/file-parsers/yaml-parser').YamlComplexityError} when the expanded document exceeds the limits
+ */
+function loadGuardedYaml(content: string): JsonValue {
+ const parsed = yaml.load(content) as JsonValue
+ assertYamlWithinLimits(parsed)
+ return parsed
+}
+
export class JsonYamlChunker {
private chunkSize: number
private minCharactersPerChunk: number
@@ -27,7 +43,7 @@ export class JsonYamlChunker {
return typeof parsed === 'object' && parsed !== null
} catch {
try {
- const parsed = yaml.load(content)
+ const parsed = loadGuardedYaml(content)
return typeof parsed === 'object' && parsed !== null
} catch {
return false
@@ -35,13 +51,21 @@ export class JsonYamlChunker {
}
}
+ /**
+ * Chunk a JSON or YAML document, rethrowing on an alias-expansion bomb rather
+ * than falling back to text chunking. In the knowledge-base path this rethrow
+ * is unreachable: `isStructuredData` already returns false for a bomb, so
+ * `document-processor` routes the document to `TextChunker`, which chunks the
+ * bounded raw string and never materializes the expansion. The rethrow covers
+ * direct callers that skip that check.
+ */
async chunk(content: string): Promise {
try {
let data: JsonValue
try {
data = JSON.parse(content) as JsonValue
} catch {
- data = yaml.load(content) as JsonValue
+ data = loadGuardedYaml(content)
}
const chunks = this.chunkStructuredData(data, [], 0)
@@ -50,6 +74,10 @@ export class JsonYamlChunker {
return chunks
} catch (error) {
+ if (isYamlComplexityError(error)) {
+ logger.warn('Rejected YAML document exceeding complexity limits', { error: error.message })
+ throw error
+ }
logger.info('JSON parsing failed, falling back to text chunking')
return this.chunkAsText(content)
}
diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts
index 72eea0cefb9..7e29111160e 100644
--- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts
@@ -24,7 +24,7 @@ vi.mock('./upload-file-reader', () => ({
grepChatUpload,
}))
-import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
+import { GlobPatternError, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs'
const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1)
@@ -291,6 +291,24 @@ describe('vfs grep workspace-file routing', () => {
expect(result.success).toBe(false)
expect(result.error).toContain('single workspace file')
})
+
+ it('surfaces an over-cap grep path scope verbatim, like glob', async () => {
+ const vfs = makeVfs()
+ const patternError = new GlobPatternError(
+ 'Glob pattern has too many wildcards (33, limit 32). Narrow the pattern with literal path segments.'
+ )
+ vfs.grep.mockRejectedValue(patternError)
+ vfs.glob.mockImplementation(() => {
+ throw patternError
+ })
+ getOrMaterializeVFS.mockResolvedValue(vfs)
+
+ const grepResult = await executeVfsGrep({ pattern: 'x', path: '*'.repeat(33) }, GREP_CTX)
+ const globResult = await executeVfsGlob({ pattern: '*'.repeat(33) }, GREP_CTX)
+
+ expect(grepResult).toEqual({ success: false, error: patternError.message })
+ expect(grepResult).toEqual(globResult)
+ })
})
describe('vfs uploads are opt-in (like recently-deleted/)', () => {
diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts
index fd4a515a8af..8783cbae74b 100644
--- a/apps/sim/lib/copilot/tools/handlers/vfs.ts
+++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts
@@ -5,7 +5,7 @@ import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { getOrMaterializeVFS } from '@/lib/copilot/vfs'
import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations'
-import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
+import { GlobPatternError, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import { withBlockVisibility } from '@/blocks/visibility/server-context'
import { grepChatUpload, listChatUploads, readChatUpload } from './upload-file-reader'
@@ -165,6 +165,12 @@ export async function executeVfsGrep(
})
return { success: false, error: err.message }
}
+ // An over-cap `path` scope reaches the same matcher glob uses; expected user
+ // input, not an internal failure.
+ if (err instanceof GlobPatternError) {
+ logger.warn('vfs_grep path scope rejected', { pattern, path: rawPath, error: err.message })
+ return { success: false, error: err.message }
+ }
logger.error('vfs_grep failed', {
pattern,
path: rawPath,
@@ -203,6 +209,11 @@ export async function executeVfsGlob(
logger.debug('vfs_glob result', { pattern, fileCount: files.length })
return { success: true, output: { files } }
} catch (err) {
+ // An over-cap pattern is expected user input, not an internal failure.
+ if (err instanceof GlobPatternError) {
+ logger.warn('vfs_glob pattern rejected', { pattern, error: err.message })
+ return { success: false, error: err.message }
+ }
logger.error('vfs_glob failed', {
pattern,
error: toError(err).message,
diff --git a/apps/sim/lib/copilot/vfs/document-style.test.ts b/apps/sim/lib/copilot/vfs/document-style.test.ts
new file mode 100644
index 00000000000..ca344da35b7
--- /dev/null
+++ b/apps/sim/lib/copilot/vfs/document-style.test.ts
@@ -0,0 +1,52 @@
+/**
+ * @vitest-environment node
+ */
+import JSZip from 'jszip'
+import { describe, expect, it } from 'vitest'
+import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
+
+const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
+
+const THEME_XML =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ''
+
+async function buildDocxArchive(): Promise {
+ const zip = new JSZip()
+ zip.file('word/theme/theme1.xml', THEME_XML)
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
+}
+
+/**
+ * Overwrite every central-directory record's declared uncompressed size so the
+ * archive claims a multi-gigabyte expansion without allocating it — the shape
+ * of a zip bomb the guard rejects without decompressing anything.
+ */
+function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer {
+ const forged = Buffer.from(zipBuffer)
+ for (let offset = 0; offset + 46 <= forged.length; offset++) {
+ if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
+ forged.writeUInt32LE(declaredBytes, offset + 24)
+ }
+ }
+ return forged
+}
+
+describe('extractDocumentStyle zip-bomb guard', () => {
+ it('refuses an archive whose declared expansion exceeds the limit', async () => {
+ const bomb = forgeDeclaredUncompressedSize(await buildDocxArchive(), 0xfffffff0)
+ await expect(extractDocumentStyle(bomb, 'docx')).resolves.toBeNull()
+ })
+
+ it('still extracts style from a well-formed archive', async () => {
+ const summary = await extractDocumentStyle(await buildDocxArchive(), 'docx')
+
+ expect(summary).not.toBeNull()
+ expect(summary?.theme?.fonts.minor).toBe('Calibri')
+ expect(summary?.theme?.colors.accent1).toBe('4472C4')
+ })
+})
diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts
index 7edbe202fc2..4198b16f0e2 100644
--- a/apps/sim/lib/copilot/vfs/document-style.ts
+++ b/apps/sim/lib/copilot/vfs/document-style.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
+import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
const logger = createLogger('DocumentStyle')
@@ -395,6 +396,8 @@ export async function extractDocumentStyle(
}
try {
+ assertOoxmlArchiveWithinLimits(buffer)
+
const JSZip = (await import('jszip')).default
const zip = await JSZip.loadAsync(buffer)
diff --git a/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
new file mode 100644
index 00000000000..a8ae7b5c290
--- /dev/null
+++ b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
@@ -0,0 +1,345 @@
+/**
+ * @vitest-environment node
+ */
+import micromatch from 'micromatch'
+import { describe, expect, it } from 'vitest'
+import { compileGlobMatcher, VFS_GLOB_OPTIONS } from '@/lib/copilot/vfs/operations'
+
+/**
+ * Differential test: {@link compileGlobMatcher} runs micromatch's own compiled pattern on
+ * RE2, so its answer must be micromatch's answer for every pattern and every path. This
+ * generates the pattern grid from glob atoms rather than listing cases, because the parts
+ * that are easy to get wrong are the combinations — a star next to a globstar, a dot
+ * segment under a wildcard, a class at a segment start.
+ *
+ * A pattern the translation cannot represent compiles to `null` and matches nothing. That
+ * is the deliberate fail-closed path, so those patterns are counted rather than compared.
+ * They are all degenerate shapes (unbalanced parens, a POSIX class behind a star); the
+ * patterns anyone would actually type are pinned separately by {@link REALISTIC}.
+ */
+const GLOB_OPTIONS = VFS_GLOB_OPTIONS
+
+/** An astral character, and the two lone surrogates it decomposes into. */
+const ASTRAL = '\u{1F600}'
+const HIGH_SURROGATE = '\uD83D'
+const LOW_SURROGATE = '\uDE00'
+
+/**
+ * Atoms for the exhaustive grid. Negated classes and parentheses are included because
+ * picomatch treats `[^…]` and `(` specially even under `noext`, and neither goes through
+ * the `(?!\.)` guard that the rest of the translation keys on. `|` is included because a
+ * marker slot opened inside one branch of a group is not the one the next branch or the
+ * position after the group needs. The astral and lone-surrogate atoms are included because
+ * picomatch counts UTF-16 code units and RE2 counts code points, so every `?` and `[^…]`
+ * next to one is a place the two engines can disagree.
+ */
+const ATOMS = [
+ 'a',
+ '.',
+ '..',
+ 'x',
+ '*',
+ '**',
+ '?',
+ '/',
+ '.git',
+ '[abc]',
+ '[.]',
+ '[!a]',
+ '{b,c}',
+ '+(z)',
+ '.a',
+ 'a.',
+ '\\.',
+ '\\*',
+ 'b',
+ '[^a]',
+ '[^a-z]',
+ '[^0-9-]',
+ '[^[:alpha:]]',
+ '(',
+ ')',
+ '|',
+ ASTRAL,
+ HIGH_SURROGATE,
+ LOW_SURROGATE,
+] as const
+
+/**
+ * Atoms for a deeper walk. Four of these compose the shapes a three-atom walk cannot reach
+ * at all: globstar, slash, dot and star together are the dotfile pattern that returned nothing.
+ */
+const DEEP_ATOMS = [
+ '*',
+ '**',
+ '/',
+ '/**',
+ '.',
+ '.*',
+ './',
+ 'a',
+ '.ts',
+ 'rc',
+ '[^a]',
+ '(',
+ ')',
+ '|',
+ ASTRAL,
+] as const
+
+const PATHS = [
+ 'a',
+ 'a/b',
+ 'a/b.ts',
+ 'b.ts',
+ 'a/b/c.ts',
+ '.hidden',
+ 'a/.hidden',
+ '.hidden/x',
+ 'files',
+ 'files/a',
+ 'files/a/meta.json',
+ 'files/Reports/q1.csv/meta.json',
+ 'weird{brace}/x',
+ 'weird[bracket]/x',
+ 'a.b',
+ '/',
+ 'a/',
+ '/a',
+ 'a//b',
+ '.',
+ '..',
+ 'a/./b',
+ 'a/..',
+ 'x',
+ 'xy',
+ 'abc',
+ 'a?c',
+ '+(a)',
+ '*',
+ 'node_modules/x',
+ '.a/.b',
+ 'a/b/.c/d',
+ 'a\nb',
+ 'a/b\nc',
+ 'a.',
+ '..a',
+ 'a b/c',
+ 'a*b',
+ 'a\\b',
+ '.git',
+ '.git/config',
+ 'a/.git/b',
+ 'A/B',
+ 'a/b/c/d/e',
+ 'ab',
+ 'ba',
+ '..hidden',
+ 'a/.b.ts',
+ '.b.ts',
+ 'a.b.c',
+ 'x..y',
+ 'a.b.',
+ 'a/b/',
+ 'x/',
+ 'a/x.ts',
+ 'a.ts',
+ '.x',
+ 'x.',
+ 'a/.',
+ './a',
+ 'x.y',
+ 'a/.b',
+ '[abc]',
+ '-x',
+ ']x',
+ '.a',
+ 'a/.a',
+ 'z',
+ '..a/b',
+ '.git/.x',
+ 'a.git',
+ '{b,c}',
+ '+(z)',
+ '.gitignore',
+ 'a/b/.',
+ 'b/..',
+ '',
+ '\\u2028b',
+ '\na$',
+ '.\n',
+ '.é}()',
+ '\\u2029x',
+ 'a/\nb',
+ 'a/\\u2028',
+ '.env',
+ 'app/.env.local',
+ 'src/.eslintrc',
+ 'src/index.ts',
+ ASTRAL,
+ `${ASTRAL}.png`,
+ `a${ASTRAL}`,
+ `${ASTRAL}${ASTRAL}`,
+ `.${ASTRAL}`,
+ `${ASTRAL}/a`,
+ `a/${ASTRAL}`,
+ HIGH_SURROGATE,
+ LOW_SURROGATE,
+ `${LOW_SURROGATE}${HIGH_SURROGATE}`,
+ `${HIGH_SURROGATE}.ts`,
+ `.${HIGH_SURROGATE}`,
+ 'a|b',
+] as const
+
+/** Patterns a caller would plausibly type; none of these may fail closed. */
+const REALISTIC = [
+ '**/*.ts',
+ 'src/**',
+ '*.{ts,tsx}',
+ '**/test_*.py',
+ 'docs/**/*.md',
+ '.env',
+ '.github/**',
+ '**/.env',
+ '**/.gitignore',
+ '.*',
+ 'a b/*.md',
+ '**/[Rr]eadme*',
+ 'files/*/meta.json',
+ '**/.*',
+ '**/.*.ts',
+ './**/.*',
+ '**/.*rc',
+ '.*/**',
+ '**/.*/**',
+ 'uploads/*',
+ '**',
+] as const
+
+function buildPatterns(): string[] {
+ const patterns = new Set()
+ const walk = (atoms: readonly string[], remaining: number, current: string) => {
+ if (current) patterns.add(current)
+ if (remaining === 0) return
+ for (const atom of atoms) walk(atoms, remaining - 1, current + atom)
+ }
+ walk(ATOMS, 3, '')
+ walk(DEEP_ATOMS, 4, '')
+ return Array.from(patterns)
+}
+
+function expectAgreement(pattern: string, path: string) {
+ const matcher = compileGlobMatcher(pattern)
+ const expected = micromatch.isMatch(path, pattern, GLOB_OPTIONS)
+ expect(matcher?.matches(path) ?? false, `pattern=${pattern} path=${JSON.stringify(path)}`).toBe(
+ expected
+ )
+}
+
+describe('glob matcher equivalence with micromatch', () => {
+ it('agrees with micromatch.isMatch across the full pattern grid', () => {
+ const patterns = buildPatterns()
+ const mismatches: string[] = []
+ let unrepresentable = 0
+ let compared = 0
+
+ for (const pattern of patterns) {
+ const matcher = compileGlobMatcher(pattern)
+ if (!matcher) {
+ unrepresentable++
+ continue
+ }
+ for (const path of PATHS) {
+ const expected = micromatch.isMatch(path, pattern, GLOB_OPTIONS)
+ const actual = matcher.matches(path)
+ compared++
+ if (expected !== actual && mismatches.length < 20) {
+ mismatches.push(
+ `pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)} micromatch=${expected} re2=${actual}`
+ )
+ }
+ }
+ }
+
+ expect(mismatches).toEqual([])
+ expect(compared).toBeGreaterThan(6_000_000)
+ expect(unrepresentable / patterns.length).toBeLessThan(0.08)
+ }, 120_000)
+
+ it('covers the shapes the translation keys on', () => {
+ const patterns = new Set(buildPatterns())
+ for (const pattern of ['**/.*', '**/.*.ts', './**/.*', '**/.*rc', '.*/**', '**/.*/**']) {
+ expect(patterns.has(pattern), pattern).toBe(true)
+ }
+ expect(Array.from(patterns).some((p) => p.includes('[^'))).toBe(true)
+ expect(Array.from(patterns).some((p) => p.includes('('))).toBe(true)
+ })
+
+ it('represents every realistic pattern rather than failing closed', () => {
+ for (const pattern of REALISTIC) {
+ expect(compileGlobMatcher(pattern), pattern).not.toBeNull()
+ }
+ })
+
+ it('matches dotfiles at every depth under a globstar', () => {
+ const matcher = compileGlobMatcher('**/.*')
+ expect(matcher).not.toBeNull()
+ for (const path of ['.env', '.gitignore', 'app/.env.local', 'src/.eslintrc']) {
+ expect(matcher?.matches(path), path).toBe(true)
+ }
+ for (const path of ['src/index.ts', 'README.md']) {
+ expect(matcher?.matches(path), path).toBe(false)
+ }
+ })
+
+ it('keeps the dot guard off classes picomatch never guarded', () => {
+ for (const pattern of ['[^a]x', '[^a-z]x', '[^0-9-]x', '[^[:alpha:]]x', '(*)']) {
+ expectAgreement(pattern, '.x')
+ }
+ expectAgreement('[^a]*', '.é}()')
+ })
+
+ it('keeps the non-empty assertion when the tail rewrite does not fire', () => {
+ expectAgreement('*.', '\na$...')
+ expectAgreement('*?', '\\u2028b\\é')
+ })
+
+ it('counts UTF-16 code units the way picomatch does, not code points', () => {
+ // `?` compiles to `[^/]`, which picomatch runs without the `u` flag, so it consumes one
+ // code unit and an astral character does not match it. RE2 counts code points, which
+ // silently widened `?` and `[^…]` into astral filenames — files a scope should exclude.
+ for (const [pattern, path] of [
+ ['?', ASTRAL],
+ ['?.png', `${ASTRAL}.png`],
+ ['[^a]', ASTRAL],
+ ['??', ASTRAL],
+ ['?', HIGH_SURROGATE],
+ ['*', ASTRAL],
+ ['??b', `${ASTRAL}b`],
+ [`${ASTRAL}.png`, `${ASTRAL}.png`],
+ ['**/?', `a/${ASTRAL}`],
+ ['.?', `.${ASTRAL}`],
+ ] as const) {
+ expectAgreement(pattern, path)
+ }
+ expect(compileGlobMatcher('?')?.matches(ASTRAL)).toBe(false)
+ expect(compileGlobMatcher('??')?.matches(ASTRAL)).toBe(true)
+ })
+
+ it('reopens the marker slot a group would otherwise trap', () => {
+ // A slot opened inside a group is unreachable once the group matches nothing, so the
+ // segment marker stayed unconsumable and no dot path could ever match.
+ for (const pattern of ['(a|b)?*', '(a|)*', '((a|b)|(c|d))?*', '**|*']) {
+ for (const path of ['.hidden', '.', '..', '.env', 'ab', 'a']) {
+ expectAgreement(pattern, path)
+ }
+ }
+ expect(compileGlobMatcher('(a|b)?*')?.matches('.hidden')).toBe(true)
+ })
+
+ it('rejects the empty path the way micromatch does', () => {
+ for (const pattern of ['**', '**/**', '**/', '**/**/**', '\\']) {
+ expectAgreement(pattern, '')
+ }
+ })
+})
diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts
index e626b702842..68e89e4f094 100644
--- a/apps/sim/lib/copilot/vfs/operations.test.ts
+++ b/apps/sim/lib/copilot/vfs/operations.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
-import { glob, grep } from '@/lib/copilot/vfs/operations'
+import { GlobPatternError, glob, grep } from '@/lib/copilot/vfs/operations'
function vfsFromEntries(entries: [string, string][]): Map {
return new Map(entries)
@@ -58,6 +58,122 @@ describe('glob', () => {
})
})
+/**
+ * Matching runs on RE2, not on picomatch's own backtracking `RegExp`. These pin the glob
+ * semantics that translation has to preserve, so a future change to it cannot quietly
+ * widen or narrow what a pattern selects.
+ */
+describe('glob semantics', () => {
+ it('does not let a single star cross a slash', () => {
+ const files = vfsFromEntries([
+ ['b.ts', ''],
+ ['a/b.ts', ''],
+ ])
+ expect(glob(files, '*.ts')).toEqual(['b.ts'])
+ })
+
+ it('lets a double star cross slashes', () => {
+ const files = vfsFromEntries([
+ ['b.ts', ''],
+ ['a/b.ts', ''],
+ ['a/c/d.ts', ''],
+ ])
+ expect(glob(files, '**/*.ts').sort()).toEqual(['a/b.ts', 'a/c/d.ts', 'b.ts'])
+ })
+
+ it('matches exactly one character for a question mark, and not a slash', () => {
+ const files = vfsFromEntries([
+ ['abc', ''],
+ ['ac', ''],
+ ['a/c', ''],
+ ])
+ expect(glob(files, 'a?c')).toEqual(['abc'])
+ })
+
+ it('never matches a dotfile with a wildcard when dot is false', () => {
+ const files = vfsFromEntries([
+ ['.hidden', ''],
+ ['visible', ''],
+ ['a/.hidden', ''],
+ ])
+ expect(glob(files, '*')).toEqual(['a', 'visible'])
+ expect(glob(files, '**')).toEqual(['a', 'visible'])
+ expect(glob(files, 'a/*')).toEqual([])
+ })
+
+ it('still matches a dotfile when the pattern spells the dot out', () => {
+ const files = vfsFromEntries([
+ ['.hidden', ''],
+ ['visible', ''],
+ ])
+ expect(glob(files, '.*')).toEqual(['.hidden'])
+ expect(glob(files, '.hidden')).toEqual(['.hidden'])
+ })
+
+ it('treats extglob syntax literally when noext is set', () => {
+ const files = vfsFromEntries([
+ ['+(a)', ''],
+ ['a', ''],
+ ['aaa', ''],
+ ])
+ expect(glob(files, '+(a)')).toEqual(['+(a)'])
+ })
+
+ it('treats a character class as a class, and brackets in a path as literals', () => {
+ const files = vfsFromEntries([
+ ['ax', ''],
+ ['bx', ''],
+ ['cx', ''],
+ ['weird[bracket]/x', ''],
+ ])
+ expect(glob(files, '[ab]x').sort()).toEqual(['ax', 'bx'])
+ expect(glob(files, 'weird[bracket]/x')).toEqual(['weird[bracket]/x'])
+ })
+})
+
+describe('glob regex safety', () => {
+ it('runs a catastrophically backtracking pattern in linear time', () => {
+ // `*a` twelve times then `b` is 25 characters and takes ~41s against this 48-character
+ // path on picomatch's backtracking `RegExp`. Both sides are caller-supplied.
+ const files = vfsFromEntries([['a'.repeat(48), '']])
+ const pattern = `${'*a'.repeat(12)}b`
+
+ const start = Date.now()
+ const hits = glob(files, pattern)
+
+ expect(hits).toEqual([])
+ expect(Date.now() - start).toBeLessThan(1000)
+ })
+
+ it('still matches when the same shape has a real answer', () => {
+ const files = vfsFromEntries([[`${'a'.repeat(48)}b`, '']])
+ expect(glob(files, `${'*a'.repeat(12)}b`)).toEqual([`${'a'.repeat(48)}b`])
+ })
+
+ it('rejects an over-long pattern with a clear error', () => {
+ const files = vfsFromEntries([['a', '']])
+ expect(() => glob(files, 'a'.repeat(1001))).toThrow(GlobPatternError)
+ expect(() => glob(files, 'a'.repeat(1001))).toThrow(/too long/)
+ })
+
+ it('rejects an absurd wildcard count with a clear error', () => {
+ const files = vfsFromEntries([['a', '']])
+ expect(() => glob(files, '*'.repeat(33))).toThrow(GlobPatternError)
+ expect(() => glob(files, '*'.repeat(33))).toThrow(/too many wildcards/)
+ })
+
+ it('applies the same engine to a wildcard grep scope', () => {
+ const files = vfsFromEntries([['a'.repeat(48), 'needle']])
+ const pattern = `${'*a'.repeat(12)}b`
+
+ const start = Date.now()
+ const hits = grep(files, 'needle', pattern, { outputMode: 'files_with_matches' })
+
+ expect(hits).toEqual([])
+ expect(Date.now() - start).toBeLessThan(1000)
+ })
+})
+
describe('grep', () => {
it('returns content matches per line in default mode', () => {
const files = vfsFromEntries([['a.txt', 'hello\nworld\nhello']])
diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts
index 624707b43e9..2f12df94afc 100644
--- a/apps/sim/lib/copilot/vfs/operations.ts
+++ b/apps/sim/lib/copilot/vfs/operations.ts
@@ -94,8 +94,13 @@ export interface ReadResult {
* never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace`
* and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for
* well-tested `**` and edge cases instead of a custom `RegExp`.
+ *
+ * Only `micromatch.makeRe` is used — never `micromatch.isMatch`. See {@link compileGlobMatcher}.
+ *
+ * Exported so the differential test compiles against the production options rather than a
+ * copy that can drift out of sync with them.
*/
-const VFS_GLOB_OPTIONS: micromatch.Options = {
+export const VFS_GLOB_OPTIONS: micromatch.Options = {
bash: false,
dot: false,
windows: false,
@@ -103,6 +108,787 @@ const VFS_GLOB_OPTIONS: micromatch.Options = {
noext: true,
}
+/**
+ * Longest glob pattern accepted. Every real VFS pattern is a path shape well under this;
+ * the cap only exists so a pathological pattern cannot reach the compiler at all.
+ */
+const MAX_GLOB_PATTERN_LENGTH = 1000
+
+/**
+ * Most `*`/`?` wildcards accepted in one pattern. A path-shaped glob uses a handful;
+ * dozens only appear in a probe. Bounded independently of length because the ReDoS
+ * shapes are short (`*a*a*a…b` is 25 characters).
+ */
+const MAX_GLOB_WILDCARDS = 32
+
+/**
+ * Markers inserted at the start of a path segment while a pattern is matched.
+ *
+ * `dot: false` and picomatch's "a star must leave something to match" rule are encoded as
+ * lookaheads RE2 cannot express. Every one of them constrains the *shape of the segment* the
+ * pattern is about to match, so marking each segment start turns the assertion into a character
+ * exclusion: a guarded pattern position omits the marker from the class it may consume, an
+ * unguarded one consumes it optionally. U+E000–U+E002 are private-use, so a real VFS path
+ * cannot contain one; a path that does is not matched.
+ *
+ * - {@link SEG_DOT} — the segment starts with `.` and is neither `.` nor `..` (`(?!\.)`).
+ * - {@link SEG_DOT_ONLY} — the segment is `.` or `..` (`(?!\.{0,1}(?:\/|$))`, plus `(?!\.)`).
+ * - {@link SEG_NEWLINE} — the segment starts with a line terminator (`(?=.)`).
+ */
+const SEG_DOT = '\u{E000}'
+const SEG_DOT_ONLY = '\u{E001}'
+const SEG_NEWLINE = '\u{E002}'
+
+/** The markers as RE2 escapes, for use inside generated pattern source. */
+const SEG_DOT_RE = '\\x{e000}'
+const SEG_DOT_ONLY_RE = '\\x{e001}'
+const SEG_NEWLINE_RE = '\\x{e002}'
+const ALL_MARKERS_RE = `${SEG_DOT_RE}${SEG_DOT_ONLY_RE}${SEG_NEWLINE_RE}`
+
+/**
+ * Where surrogate code units are remapped so RE2 sees one code point per UTF-16 code unit.
+ *
+ * picomatch compiles `?` to `[^/]` and runs it on a `RegExp` with no `u` flag, so it consumes
+ * exactly one code unit and an astral character (two units) does not match it. RE2 always
+ * matches whole code points, so the same class consumed `😀` and `glob('?')` returned files
+ * micromatch excludes — an over-match, and the dangerous direction for a scope filter.
+ *
+ * Splitting each surrogate half onto its own private-use code point restores UTF-16 counting
+ * exactly: `?` matches one half and so cannot match `😀`, while `??` matches both and does,
+ * which is what micromatch answers in each case. The alternative — rejecting astral input —
+ * would trade an over-match for an under-match on real filenames, so it is not taken. The
+ * range is 2048 wide (one per surrogate) and disjoint from the segment markers above.
+ */
+const SURROGATE_ESCAPE_BASE = 0xe800
+
+/** Any surrogate code unit, paired or lone. Deliberately not `u`-flagged. */
+const SURROGATE_CHAR = /[\uD800-\uDFFF]/
+
+/**
+ * Code points the translation reserves for its own use — the segment markers and the surrogate
+ * escape range — so a pattern or path already containing one is rejected rather than confused
+ * with a marker or with half of an astral character.
+ */
+const RESERVED_CHAR = /[\u{E000}-\u{E002}\u{E800}-\u{EFFF}]/u
+
+/** Rewrite every surrogate code unit to its {@link SURROGATE_ESCAPE_BASE} counterpart. */
+function escapeSurrogates(text: string): string {
+ if (!SURROGATE_CHAR.test(text)) return text
+ let out = ''
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i)
+ out +=
+ code >= 0xd800 && code <= 0xdfff
+ ? String.fromCharCode(SURROGATE_ESCAPE_BASE + (code - 0xd800))
+ : text[i]
+ }
+ return out
+}
+
+/**
+ * {@link escapeSurrogates} over generated pattern source, or `null` when a surrogate sits
+ * inside a character class. Remapping there would move a range endpoint above the segment
+ * markers, so `[a-😀]` would start consuming them; the shape is degenerate enough that failing
+ * closed costs nothing.
+ */
+function escapeSurrogatesInSource(source: string): string | null {
+ if (!SURROGATE_CHAR.test(source)) return source
+ let out = ''
+ let inClass = false
+ for (let i = 0; i < source.length; i++) {
+ const char = source[i]
+ const code = source.charCodeAt(i)
+ if (code >= 0xd800 && code <= 0xdfff) {
+ if (inClass) return null
+ out += String.fromCharCode(SURROGATE_ESCAPE_BASE + (code - 0xd800))
+ continue
+ }
+ if (char === '\\') {
+ const next = source[i + 1]
+ if (next === undefined) return null
+ // picomatch escapes each surrogate half individually; `\` before one is decorative, and
+ // the remapped code point is not a metacharacter, so the escape is dropped rather than
+ // handed to RE2 as an unknown one.
+ if (SURROGATE_CHAR.test(next)) {
+ if (inClass) return null
+ out += String.fromCharCode(SURROGATE_ESCAPE_BASE + (source.charCodeAt(i + 1) - 0xd800))
+ i += 1
+ continue
+ }
+ out += char + next
+ i += 1
+ continue
+ }
+ if (inClass) {
+ if (char === ']') inClass = false
+ } else if (char === '[') {
+ inClass = true
+ }
+ out += char
+ }
+ return out
+}
+
+/** The code points ECMAScript's `.` excludes, as a matcher and as RE2 escapes. */
+const LINE_TERMINATOR = /[\n\r\u2028\u2029]/
+const NEWLINE_RE = '\\x{a}\\x{d}\\x{2028}\\x{2029}'
+
+/** Picomatch's `**` body: any run of characters that does not open a dot segment. */
+const GLOBSTAR_BODY = '(?:(?:(?!(?:^|\\/)\\.).)*?)'
+const GLOBSTAR_CLASS = `[^${ALL_MARKERS_RE}${NEWLINE_RE}]`
+
+/** The three assertion shapes picomatch emits under {@link VFS_GLOB_OPTIONS}. */
+const DOT_GUARD = '(?!\\.)'
+const NON_EMPTY_GUARD = '(?=.)'
+const DOT_SEGMENT_GUARD = '(?!\\.{0,1}(?:\\/|$))'
+
+export interface GlobMatcher {
+ matches(path: string): boolean
+}
+
+/**
+ * Thrown when a caller-supplied glob pattern is outside the safety caps. Both the `glob`
+ * and `grep` tool handlers already turn a thrown error into `{ success: false, error }`,
+ * so the message reaches the caller verbatim.
+ */
+export class GlobPatternError extends Error {
+ readonly code = 'GLOB_PATTERN' as const
+ constructor(message: string) {
+ super(message)
+ this.name = 'GlobPatternError'
+ }
+}
+
+/**
+ * Compiled matchers, keyed by pattern. `grep` tests one scope against every file in the VFS
+ * and RE2 compilation costs far more than a match, so a pattern must not recompile per file.
+ * Cleared wholesale past the cap — patterns arrive in per-request bursts, so eviction order
+ * does not matter.
+ */
+const globMatcherCache = new Map()
+
+/** Entries kept in {@link globMatcherCache} before it is cleared. */
+const GLOB_MATCHER_CACHE_LIMIT = 256
+
+/** {@link compileGlobMatcher}, memoized. Throws {@link GlobPatternError} the same way. */
+function getGlobMatcher(pattern: string): GlobMatcher | null {
+ const cached = globMatcherCache.get(pattern)
+ if (cached !== undefined) return cached
+
+ const matcher = compileGlobMatcher(pattern)
+ if (globMatcherCache.size >= GLOB_MATCHER_CACHE_LIMIT) globMatcherCache.clear()
+ globMatcherCache.set(pattern, matcher)
+ return matcher
+}
+
+/** Index just past the character class starting at `at`, or -1 when it is unterminated. */
+function classEnd(source: string, at: number): number {
+ let i = at + 1
+ if (source[i] === '^') i += 1
+ if (source[i] === ']') i += 1
+ while (i < source.length && source[i] !== ']') {
+ if (source[i] === '\\') i += 1
+ i += 1
+ }
+ return i < source.length ? i + 1 : -1
+}
+
+/** Index of the `)` closing the group opened at `at`, or -1. */
+function groupEnd(source: string, at: number): number {
+ let depth = 0
+ for (let i = at; i < source.length; i++) {
+ const char = source[i]
+ if (char === '\\') {
+ i += 1
+ } else if (char === '[') {
+ const end = classEnd(source, i)
+ if (end === -1) return -1
+ i = end - 1
+ } else if (char === '(') {
+ depth += 1
+ } else if (char === ')') {
+ depth -= 1
+ if (depth === 0) return i
+ }
+ }
+ return -1
+}
+
+/** The quantifier at `at`, or `''` when there is none (`{ts,tsx}` is a literal, not a repeat). */
+function readQuantifier(source: string, at: number): string {
+ const char = source[at]
+ if (char === '?' || char === '*' || char === '+') {
+ return source[at + 1] === '?' ? source.slice(at, at + 2) : char
+ }
+ if (char === '{') {
+ const end = source.indexOf('}', at)
+ if (end !== -1 && /^\{\d+(?:,\d*)?\}$/.test(source.slice(at, end + 1))) {
+ return source.slice(at, end + 1)
+ }
+ }
+ return ''
+}
+
+/** True when `quantifier` lets the atom it follows match nothing. */
+function isOptionalQuantifier(quantifier: string): boolean {
+ return (
+ quantifier.startsWith('?') ||
+ quantifier.startsWith('*') ||
+ quantifier.startsWith('{0,') ||
+ quantifier === '{0}'
+ )
+}
+
+/** True when every match of the balanced token run `[start, end)` consumes at least one character. */
+function sequenceMustConsume(source: string, start: number, end: number): boolean {
+ let i = start
+ while (i < end) {
+ const char = source[i]
+ if (char === '^' || char === '$') {
+ i += 1
+ continue
+ }
+ let atomEnd: number
+ let consumes: boolean
+ if (char === '(') {
+ const close = groupEnd(source, i)
+ if (close === -1 || close >= end) return false
+ consumes = groupMustConsume(source, i, close)
+ atomEnd = close + 1
+ } else if (char === '[') {
+ const close = classEnd(source, i)
+ if (close === -1) return false
+ consumes = true
+ atomEnd = close
+ } else {
+ consumes = true
+ atomEnd = char === '\\' ? i + 2 : i + 1
+ }
+ const quantifier = readQuantifier(source, atomEnd)
+ if (consumes && !isOptionalQuantifier(quantifier)) return true
+ i = atomEnd + quantifier.length
+ }
+ return false
+}
+
+/** {@link sequenceMustConsume} for a group, which consumes only when every branch does. */
+function groupMustConsume(source: string, open: number, close: number): boolean {
+ const isNonCapturing = source.startsWith('(?:', open)
+ if (!isNonCapturing && source[open + 1] === '?') return false
+ let depth = 0
+ let from = open + (isNonCapturing ? 3 : 1)
+ for (let i = from; i < close; i++) {
+ const char = source[i]
+ if (char === '\\') {
+ i += 1
+ } else if (char === '[') {
+ const end = classEnd(source, i)
+ if (end === -1) return false
+ i = end - 1
+ } else if (char === '(') {
+ depth += 1
+ } else if (char === ')') {
+ depth -= 1
+ } else if (char === '|' && depth === 0) {
+ if (!sequenceMustConsume(source, from, i)) return false
+ from = i + 1
+ }
+ }
+ return sequenceMustConsume(source, from, close)
+}
+
+/** Index of the `)` closing the group `from` sits inside, or -1. */
+function enclosingGroupEnd(source: string, from: number): number {
+ let depth = 0
+ for (let i = from; i < source.length; i++) {
+ const char = source[i]
+ if (char === '\\') {
+ i += 1
+ } else if (char === '[') {
+ const end = classEnd(source, i)
+ if (end === -1) return -1
+ i = end - 1
+ } else if (char === '(') {
+ depth += 1
+ } else if (char === ')') {
+ if (depth === 0) return i
+ depth -= 1
+ }
+ }
+ return -1
+}
+
+/**
+ * True when whatever remains of `source` from `at` must consume at least one character.
+ *
+ * This is what makes `(?=.)`'s "not at end of input" half droppable: if the rest of the pattern
+ * cannot match nothing, the assertion is already implied. Alternatives to the right of a `|`
+ * belong to the enclosing group rather than to this position, so they are skipped.
+ */
+function remainderMustConsume(source: string, at: number): boolean {
+ let i = at
+ while (i < source.length) {
+ const char = source[i]
+ if (char === '^' || char === '$') {
+ i += 1
+ continue
+ }
+ if (char === ')') {
+ i += 1 + readQuantifier(source, i + 1).length
+ continue
+ }
+ if (char === '|') {
+ const close = enclosingGroupEnd(source, i)
+ if (close === -1) return false
+ i = close + 1 + readQuantifier(source, close + 1).length
+ continue
+ }
+ let atomEnd: number
+ let consumes: boolean
+ if (char === '(') {
+ const close = groupEnd(source, i)
+ if (close === -1) return false
+ consumes = groupMustConsume(source, i, close)
+ atomEnd = close + 1
+ } else if (char === '[') {
+ const close = classEnd(source, i)
+ if (close === -1) return false
+ consumes = true
+ atomEnd = close
+ } else {
+ consumes = true
+ atomEnd = char === '\\' ? i + 2 : i + 1
+ }
+ const quantifier = readQuantifier(source, atomEnd)
+ if (consumes && !isOptionalQuantifier(quantifier)) return true
+ i = atomEnd + quantifier.length
+ }
+ return false
+}
+
+/**
+ * True when `at` opens a group other than `**`. A guard sitting there applies only to the
+ * branches that consume nothing, which no single emitted position can express, so callers
+ * fail closed instead.
+ */
+function isGroupOpen(source: string, at: number): boolean {
+ if (source[at] !== '(' || source.startsWith('(?=', at) || source.startsWith('(?!', at))
+ return false
+ return !source.startsWith(GLOBSTAR_BODY, at)
+}
+
+/** `atom` with line terminators removed from what it can match, or `null` when it cannot be. */
+function withoutLineTerminators(atom: string): string | null {
+ if (atom.startsWith('[^')) return `[^${atom.slice(2, -1)}${NEWLINE_RE}]`
+ return LINE_TERMINATOR.test(atom) ? null : atom
+}
+
+/**
+ * Whether the atom at `at` can match a line terminator. Conservative: a negated class or a
+ * group answers "yes", so a `(?=.)` whose non-emptiness would have to be pushed onto such an
+ * atom fails closed rather than being approximated.
+ */
+function atomMayMatchLineTerminator(source: string, at: number): boolean {
+ const char = source[at]
+ if (char === undefined || char === '(' || char === ')' || char === '|') return true
+ if (char === '\\') return LINE_TERMINATOR.test(source[at + 1] ?? '\n')
+ if (char === '[') {
+ const end = classEnd(source, at)
+ if (end === -1) return true
+ return source[at + 1] === '^' || LINE_TERMINATOR.test(source.slice(at, end))
+ }
+ return LINE_TERMINATOR.test(char)
+}
+
+/**
+ * Rewrite picomatch's generated source into an RE2-representable equivalent.
+ *
+ * The scan is structural rather than textual: it tracks whether the current output position
+ * starts a path segment (following alternation branches and quantified groups, so `(?:X\/)?`
+ * is recognised as a boundary), and opens one optional marker class per segment. Each assertion
+ * then narrows that class instead of being deleted — `(?!\.)` drops {@link SEG_DOT} and
+ * {@link SEG_DOT_ONLY}, `(?!\.{0,1}(?:\/|$))` drops {@link SEG_DOT_ONLY}, `(?=.)` drops
+ * {@link SEG_NEWLINE}. `(?=.)` additionally forbids matching nothing at all, which is dropped
+ * when the rest of the pattern must consume a character and is otherwise folded into the
+ * trailing `[^/]*?\/?$` it guards.
+ *
+ * Returns `null` when any assertion survives or a shape is unrecognised, which is how a
+ * picomatch version emitting something new degrades to "no matches" instead of to the
+ * backtracking engine. `operations.glob-semantics.test.ts` pins the equivalence over the grid.
+ */
+function rewriteGlobSource(generated: string): string | null {
+ if (RESERVED_CHAR.test(generated)) return null
+ const source = escapeSurrogatesInSource(generated)
+ if (source === null) return null
+
+ const parts: string[] = []
+ const groups: Array<{
+ startBoundary: boolean
+ branchEnds: boolean[]
+ /** Index in `parts` just past the group opener, so a slot inside it can be recognised. */
+ openIndex: number
+ /** Index in `source` of the `(`. */
+ sourceOpen: number
+ }> = []
+ let boundary = false
+ let emptyBoundary = false
+ let slot = -1
+ let markers = ''
+ let leadingDot = false
+ let slotPending = false
+ let afterEndAnchor = false
+ let nonEmpty = false
+ let nonEmptyNeedsNewlineGuard = false
+ let i = 0
+
+ const writeSlot = () => {
+ if (slot >= 0) parts[slot] = markers === '' ? '' : `[${markers}]?`
+ }
+ const openSlot = () => {
+ if (slot >= 0) return
+ slot = parts.length
+ markers = ALL_MARKERS_RE
+ slotPending = true
+ parts.push('')
+ writeSlot()
+ }
+ const dropMarker = (marker: string) => {
+ markers = markers.split(marker).join('')
+ writeSlot()
+ }
+ const endSegment = () => {
+ slot = -1
+ markers = ''
+ leadingDot = false
+ slotPending = false
+ }
+
+ while (i < source.length) {
+ // A branch ending in `$` cannot continue, so it never constrains the boundary after it.
+ const endsBranch = afterEndAnchor
+ afterEndAnchor = false
+
+ if (source.startsWith(GLOBSTAR_BODY, i)) {
+ if (nonEmpty) return null
+ // `**` never consumes a marker itself, so it is split. Having matched something it can
+ // only have stopped before a newline segment — a `/` followed by a dot is exactly what
+ // its own guard forbids. Having matched nothing it leaves the position untouched, so the
+ // empty branch carries the marker slot forward for whatever follows to narrow.
+ const inherited = slotPending ? markers : boundary || emptyBoundary ? ALL_MARKERS_RE : ''
+ if (slotPending) parts[slot] = ''
+ parts.push(`(?:${GLOBSTAR_CLASS}+[${SEG_NEWLINE_RE}]?|`)
+ slot = parts.length
+ markers = inherited
+ slotPending = true
+ parts.push('')
+ writeSlot()
+ parts.push(')')
+ i += GLOBSTAR_BODY.length
+ boundary = false
+ emptyBoundary = false
+ leadingDot = false
+ continue
+ }
+
+ if (source.startsWith(DOT_GUARD, i)) {
+ if (isGroupOpen(source, i + DOT_GUARD.length)) return null
+ if (!boundary && !slotPending) return null
+ openSlot()
+ dropMarker(SEG_DOT_RE)
+ dropMarker(SEG_DOT_ONLY_RE)
+ i += DOT_GUARD.length
+ continue
+ }
+
+ if (source.startsWith(DOT_SEGMENT_GUARD, i)) {
+ if (!leadingDot) return null
+ dropMarker(SEG_DOT_ONLY_RE)
+ i += DOT_SEGMENT_GUARD.length
+ continue
+ }
+
+ if (source.startsWith(NON_EMPTY_GUARD, i)) {
+ if (isGroupOpen(source, i + NON_EMPTY_GUARD.length)) return null
+ if (boundary || slotPending) {
+ openSlot()
+ dropMarker(SEG_NEWLINE_RE)
+ nonEmptyNeedsNewlineGuard = false
+ } else if (leadingDot) {
+ // Mid-segment: no marker covers this position, so the guard has to reach the atom.
+ nonEmptyNeedsNewlineGuard = true
+ } else {
+ return null
+ }
+ nonEmpty = true
+ i += NON_EMPTY_GUARD.length
+ continue
+ }
+
+ const char = source[i]
+
+ if (char === '(') {
+ const opener = source.startsWith('(?:', i) ? '(?:' : '('
+ if (opener === '(' && source[i + 1] === '?') return null
+ parts.push(opener)
+ groups.push({
+ startBoundary: boundary,
+ branchEnds: [],
+ openIndex: parts.length,
+ sourceOpen: i,
+ })
+ i += opener.length
+ continue
+ }
+
+ if (char === '|') {
+ const frame = groups[groups.length - 1]
+ if (!frame) return null
+ frame.branchEnds.push(endsBranch || boundary)
+ boundary = frame.startBoundary
+ emptyBoundary = false
+ // A slot belongs to the branch that emitted it; the next branch is a separate path
+ // through the regex and opens its own, so nothing is carried across the `|`.
+ endSegment()
+ parts.push('|')
+ i += 1
+ continue
+ }
+
+ if (char === ')') {
+ const frame = groups.pop()
+ if (!frame) return null
+ // A slot still pending at the close is un-consumed and goes on serving the position
+ // after the group (a trailing `**` leaves exactly this). One the branch already built
+ // on is trapped inside it, and nothing outside may narrow it further.
+ const carriedSlot = slotPending
+ if (!carriedSlot && slot >= frame.openIndex) endSegment()
+ const closeIndex = i
+ parts.push(')')
+ i += 1
+ const quantifier = readQuantifier(source, i)
+ if (quantifier) {
+ parts.push(quantifier)
+ i += quantifier.length
+ }
+ let closedAtBoundary = endsBranch || boundary
+ for (const end of frame.branchEnds) closedAtBoundary &&= end
+ if (isOptionalQuantifier(quantifier)) closedAtBoundary &&= frame.startBoundary
+ boundary = closedAtBoundary
+ // A group that can match nothing leaves the position exactly where it started, so on
+ // that path the segment marker is still unconsumed and something after the group has to
+ // be able to take it — otherwise `(a|b)?*` cannot match `.hidden` at all. The flag is
+ // kept apart from `boundary` because the group's other paths are NOT at a segment start,
+ // so an assertion landing here would hold for only some of them; every assertion keys on
+ // `boundary` and therefore fails closed.
+ emptyBoundary =
+ !boundary &&
+ !carriedSlot &&
+ frame.startBoundary &&
+ (isOptionalQuantifier(quantifier) ||
+ !groupMustConsume(source, frame.sourceOpen, closeIndex))
+ continue
+ }
+
+ if (char === '^' || char === '$') {
+ if (char === '^' && slotPending) {
+ // picomatch wraps its output in a redundant `^(?:^…)$)$`, so a slot opened at the
+ // outer boundary can land in front of the inner anchor, where no marker can ever
+ // reach it. Nothing between the two consumes, so the slot simply moves behind it.
+ parts[slot] = ''
+ parts.push('^')
+ slot = parts.length
+ parts.push('')
+ writeSlot()
+ } else {
+ parts.push(char)
+ }
+ if (char === '^') {
+ boundary = true
+ emptyBoundary = false
+ }
+ afterEndAnchor = char === '$'
+ i += 1
+ continue
+ }
+
+ let atom: string
+ let atomEnd: number
+ let isSlash = false
+ if (char === '\\') {
+ const next = source[i + 1]
+ if (next === undefined) return null
+ atom = `\\${next}`
+ atomEnd = i + 2
+ isSlash = next === '/'
+ } else if (char === '[') {
+ const end = classEnd(source, i)
+ if (end === -1) return null
+ const negated = source[i + 1] === '^'
+ const body = source.slice(negated ? i + 2 : i + 1, end - 1)
+ atom = negated ? `[^${body}${ALL_MARKERS_RE}]` : `[${body}]`
+ atomEnd = end
+ } else if (char === '.') {
+ atom = GLOBSTAR_CLASS
+ atomEnd = i + 1
+ } else if (char === '*' || char === '+' || char === '?') {
+ return null
+ } else {
+ atom = char
+ atomEnd = i + 1
+ isSlash = char === '/'
+ }
+
+ const quantifier = readQuantifier(source, atomEnd)
+ const optional = isOptionalQuantifier(quantifier)
+ const atBoundary = boundary
+ // A class picomatch did not exclude `/` from (its POSIX expansions, and the `.` those
+ // sometimes leave behind) can step into the next segment, landing on that segment's marker.
+ const crossesSegments = atom.startsWith('[^')
+ ? !atom.slice(2, -1).includes('/')
+ : atom.startsWith('[')
+ ? atom.slice(1, -1).includes('/')
+ : false
+ if (crossesSegments && quantifier !== '' && quantifier !== '?') return null
+
+ if (nonEmpty) {
+ if (!optional) {
+ if (nonEmptyNeedsNewlineGuard) {
+ const guarded = withoutLineTerminators(atom)
+ if (guarded === null) return null
+ atom = guarded
+ }
+ } else {
+ if (char !== '[' || crossesSegments) return null
+ const rest = source.slice(atomEnd + quantifier.length)
+ const first = nonEmptyNeedsNewlineGuard ? withoutLineTerminators(atom) : atom
+ if (first === null) return null
+ if (remainderMustConsume(source, atomEnd + quantifier.length)) {
+ // Only the "not at end of input" half was load-bearing, and the remainder implies it.
+ if (nonEmptyNeedsNewlineGuard) {
+ if (atomMayMatchLineTerminator(source, atomEnd + quantifier.length)) return null
+ if (boundary) openSlot()
+ parts.push(`(?:${first}${atom}*?)?`)
+ } else {
+ if (boundary) openSlot()
+ parts.push(`${atom}${quantifier}`)
+ }
+ } else {
+ const tail = /^(\\\/\?)?(\)*)\$$/.exec(rest)
+ if (!tail) return null
+ if (boundary) openSlot()
+ parts.push(tail[1] ? `(?:${first}${atom}*\\/?|\\/)` : `${first}${atom}*`)
+ parts.push(`${tail[2]}$`)
+ const out = parts.join('')
+ return /\(\?[=!<]/.test(out) ? null : out
+ }
+ nonEmpty = false
+ nonEmptyNeedsNewlineGuard = false
+ i = atomEnd + quantifier.length
+ slotPending = false
+ boundary = false
+ leadingDot = false
+ continue
+ }
+ nonEmpty = false
+ nonEmptyNeedsNewlineGuard = false
+ }
+
+ if (boundary || emptyBoundary) openSlot()
+ if (crossesSegments) {
+ const stepped = `${atom}[${ALL_MARKERS_RE}]?`
+ parts.push(quantifier === '?' ? `(?:${stepped})?` : stepped)
+ } else {
+ parts.push(atom + quantifier)
+ }
+ i = atomEnd + quantifier.length
+ slotPending = false
+ emptyBoundary = false
+ boundary = isSlash && !optional
+ leadingDot = atBoundary && atom === '\\.' && !optional
+ if (boundary) endSegment()
+ }
+
+ if (groups.length > 0 || nonEmpty) return null
+ const out = parts.join('')
+ return /\(\?[=!<]/.test(out) ? null : out
+}
+
+/**
+ * Prefix every path segment with the marker describing its shape, with surrogate halves
+ * remapped so RE2 counts UTF-16 code units the way picomatch does. Returns `null` when the
+ * path already contains a reserved code point. See {@link SEG_DOT} and
+ * {@link SURROGATE_ESCAPE_BASE}.
+ */
+function markPathSegments(path: string): string | null {
+ if (RESERVED_CHAR.test(path)) return null
+ const escaped = escapeSurrogates(path)
+ if (!escaped.includes('.') && !LINE_TERMINATOR.test(escaped)) return escaped
+ return escaped
+ .split('/')
+ .map((segment) => {
+ if (segment === '.' || segment === '..') return SEG_DOT_ONLY + segment
+ if (segment.startsWith('.')) return SEG_DOT + segment
+ if (segment !== '' && LINE_TERMINATOR.test(segment[0])) return SEG_NEWLINE + segment
+ return segment
+ })
+ .join('/')
+}
+
+/**
+ * Compile a caller-supplied glob into a matcher that cannot backtrack.
+ *
+ * `micromatch.isMatch` compiles each `*` to `[^/]*?` and runs it on the backtracking engine,
+ * so a short pattern like `*a` repeated over a longer path is exponential — and both pattern
+ * and paths come from an authenticated caller's tool call.
+ *
+ * The regex is picomatch's own from `makeRe`, rewritten by {@link rewriteGlobSource}, so the
+ * semantics of {@link VFS_GLOB_OPTIONS} are unchanged. Returns `null` when the pattern is over
+ * the safety caps or is not RE2-representable; callers treat that as "matches nothing" rather
+ * than falling back to the backtracking engine.
+ */
+export function compileGlobMatcher(pattern: string): GlobMatcher | null {
+ if (pattern.length > MAX_GLOB_PATTERN_LENGTH) {
+ throw new GlobPatternError(
+ `Glob pattern is too long (${pattern.length} characters, limit ${MAX_GLOB_PATTERN_LENGTH}).`
+ )
+ }
+
+ const wildcards = pattern.replace(/[^*?]/g, '').length
+ if (wildcards > MAX_GLOB_WILDCARDS) {
+ throw new GlobPatternError(
+ `Glob pattern has too many wildcards (${wildcards}, limit ${MAX_GLOB_WILDCARDS}). ` +
+ 'Narrow the pattern with literal path segments.'
+ )
+ }
+
+ let generated: RegExp
+ try {
+ generated = micromatch.makeRe(pattern, VFS_GLOB_OPTIONS)
+ } catch {
+ logger.warn('Glob pattern could not be parsed; matching nothing', { pattern })
+ return null
+ }
+
+ const source = rewriteGlobSource(generated.source)
+ const linear = source === null ? null : compileLinearRegex(source)
+ if (!linear) {
+ logger.warn('Glob pattern is not RE2-representable; matching nothing', { pattern })
+ return null
+ }
+
+ return {
+ matches: (path: string) => {
+ // Micromatch rejects an empty path before its regex ever runs, and picomatch reports an
+ // exact string equality as a match even when its own regex would not — `+(a)` against
+ // `+(a)` with `noext`. Both are kept so behaviour is unchanged.
+ if (path === '') return false
+ if (path === pattern) return true
+ const marked = markPathSegments(path)
+ return marked !== null && linear.test(marked)
+ },
+ }
+}
+
/**
* Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style
* CRLF payloads still match patterns anchored at line end (`$`).
@@ -113,10 +899,10 @@ function splitLinesForGrep(content: string): string[] {
/**
* Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains
- * `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters
- * (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not
- * parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under
- * `files/...`.
+ * `*` or `?`, filters with {@link compileGlobMatcher} — the caller-supplied scope reaches the
+ * same matcher {@link glob} uses, so it cannot backtrack either. Other characters (including
+ * `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not parsed as
+ * glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under `files/...`.
*
* Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider,
* keeping "what we materialize" identical to "what grep filters in".
@@ -124,7 +910,7 @@ function splitLinesForGrep(content: string): string[] {
export function pathWithinGrepScope(filePath: string, scope: string): boolean {
const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope)
if (scopeUsesStarOrQuestionGlob) {
- return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS)
+ return getGlobMatcher(scope)?.matches(filePath) ?? false
}
const base = scope.replace(/\/+$/, '')
if (base === '') {
@@ -240,11 +1026,18 @@ export function grep(
}
/**
- * Glob pattern matching against VFS file paths and virtual directories using `micromatch`
- * with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion).
- * Returns matching file keys and virtual directory prefixes.
+ * Glob pattern matching against VFS file paths and virtual directories using `micromatch`'s
+ * own compiled pattern under {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace
+ * or extglob expansion), executed on RE2 by {@link compileGlobMatcher}. Returns matching file
+ * keys and virtual directory prefixes.
+ *
+ * Throws {@link GlobPatternError} for a pattern outside the safety caps, and returns no
+ * matches for one RE2 cannot represent.
*/
export function glob(files: Map, pattern: string): string[] {
+ const matcher = getGlobMatcher(pattern)
+ if (!matcher) return []
+
const result = new Set()
const directories = new Set()
@@ -261,13 +1054,13 @@ export function glob(files: Map, pattern: string): string[] {
for (const filePath of files.keys()) {
if (filePath.endsWith('/.folder')) continue
- if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) {
+ if (matcher.matches(filePath)) {
result.add(filePath)
}
}
for (const dir of directories) {
- if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) {
+ if (matcher.matches(dir)) {
result.add(dir)
}
}
diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts
index 02c44a1ed97..4b69f9c1d2a 100644
--- a/apps/sim/lib/core/security/input-validation.server.ts
+++ b/apps/sim/lib/core/security/input-validation.server.ts
@@ -6,10 +6,10 @@ import https from 'https'
import type { LookupFunction } from 'net'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { omit } from '@sim/utils/object'
import { HttpProxyAgent } from 'http-proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent'
import * as ipaddr from 'ipaddr.js'
+import { getDomain } from 'tldts'
import {
Agent,
type Dispatcher,
@@ -416,7 +416,11 @@ export interface SecureFetchOptions {
maxRedirects?: number
maxResponseBytes?: number
signal?: AbortSignal
- /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
+ /**
+ * Drop the Authorization header on EVERY redirect, including same-site ones.
+ * Cross-site redirects already drop all credential-bearing headers by default
+ * (see {@link headersForRedirect}); this tightens that to same-site hops too.
+ */
stripAuthOnRedirect?: boolean
/**
* Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}).
@@ -486,6 +490,99 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
}
}
+/** Header names that always carry a secret, regardless of the service. */
+const CREDENTIAL_HEADER_NAMES = new Set([
+ 'authorization',
+ 'cookie',
+ 'proxy-authorization',
+ 'private-token',
+ 'api-key',
+ 'apikey',
+])
+
+/**
+ * Suffixes that mark a vendor-specific credential header (`x-api-key`,
+ * `x-goog-api-key`, `x-clickhouse-key`, `x-sim-signature`, `x-auth-token`, …).
+ * Over-matching is deliberate: forwarding a non-secret header one hop less often
+ * is harmless, forwarding a secret to another site is not.
+ */
+const CREDENTIAL_HEADER_SUFFIXES = ['-key', '-token', '-secret', '-password', '-signature']
+
+/**
+ * Headers the suffix rule matches that carry no secret. Dropping them changes request
+ * semantics for no security gain: `idempotency-key` is a de-duplication token (Brex
+ * transfers, webhook delivery, data drains) whose loss can duplicate a side effect, and
+ * `x-atlassian-token: no-check` is Atlassian's XSRF opt-out on attachment uploads.
+ */
+const NON_CREDENTIAL_HEADER_NAMES = new Set(['idempotency-key', 'x-atlassian-token'])
+
+function isCredentialHeader(name: string): boolean {
+ const lower = name.toLowerCase()
+ if (CREDENTIAL_HEADER_NAMES.has(lower)) return true
+ if (NON_CREDENTIAL_HEADER_NAMES.has(lower)) return false
+ if (lower.startsWith('x-auth')) return true
+ return CREDENTIAL_HEADER_SUFFIXES.some((suffix) => lower.endsWith(suffix))
+}
+
+/**
+ * A redirect is same-site when it stays on the same registrable domain (eTLD+1) without
+ * downgrading the transport. This is deliberately looser than origin equality: real
+ * providers redirect across subdomains of one service (`api.zoom.us` → a regional
+ * `*.zoom.us` host, `api.github.com` → `codeload.github.com`) and self-hosted deployments
+ * upgrade `http:` → `https:`, and those targets still require the caller's credential. A
+ * hostile or open redirect to another site is what must not receive it.
+ *
+ * IP-literal hosts have no registrable domain, so they fall back to exact host equality.
+ *
+ * `allowPrivateDomains` honors the PRIVATE section of the Public Suffix List, so providers listed
+ * there (`*.s3.amazonaws.com`, `*.blob.core.windows.net`, `*.cloudfront.net`, `*.myshopify.com`,
+ * `*.vercel.app`, …) are cross-site tenant-to-tenant rather than sharing one registrable domain.
+ *
+ * This is NOT general tenant isolation. Providers absent from the PSL private section —
+ * `atlassian.net`, `sharepoint.com`, `okta.com`, `auth0.com`, `my.salesforce.com`, `box.com` —
+ * still resolve tenant subdomains to a single registrable domain, so a redirect between two
+ * tenants of those services counts as same-site and keeps the credential. Closing that needs a
+ * per-provider rule, not a PSL flag.
+ */
+function isSameSiteRedirect(from: URL, to: URL): boolean {
+ if (from.protocol === 'https:' && to.protocol !== 'https:') return false
+ if (from.host === to.host) return true
+ const fromDomain = getDomain(from.hostname, { allowPrivateDomains: true })
+ return fromDomain !== null && fromDomain === getDomain(to.hostname, { allowPrivateDomains: true })
+}
+
+/**
+ * Builds the header set for a redirect hop. Credential-bearing headers ({@link isCredentialHeader})
+ * are dropped on any cross-site hop so a hostile or open redirect cannot exfiltrate the caller's
+ * API key, bearer token, or cookies; non-credential headers still travel. On a same-site hop
+ * everything is forwarded unless `stripAuth` is set, which drops `authorization` alone.
+ *
+ * This is deliberately weaker than {@link followRedirectsGuarded} on the undici path, which drops
+ * EVERY header cross-**origin** (not cross-site) and refuses to forward a request body at all.
+ * The node-http path keeps cross-subdomain provider hops working and still forwards bodies.
+ */
+function headersForRedirect(
+ headers: Record,
+ fromUrl: string,
+ toUrl: string,
+ stripAuth: boolean
+): Record {
+ let sameSite: boolean
+ try {
+ sameSite = isSameSiteRedirect(new URL(fromUrl), new URL(toUrl))
+ } catch {
+ sameSite = false
+ }
+ if (sameSite && !stripAuth) return headers
+
+ const next: Record = {}
+ for (const [key, value] of Object.entries(headers)) {
+ if (sameSite ? key.toLowerCase() === 'authorization' : isCredentialHeader(key)) continue
+ next[key] = value
+ }
+ return next
+}
+
/**
* Creates a DNS lookup function that always returns a pre-resolved IP address.
* Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to
@@ -1051,12 +1148,15 @@ export async function secureFetchWithPinnedIP(
settledReject(new Error(`Redirect blocked: ${validation.error}`))
return
}
- const redirectOptions = options.stripAuthOnRedirect
- ? {
- ...options,
- headers: omit(options.headers ?? {}, ['Authorization', 'authorization']),
- }
- : options
+ const redirectOptions = {
+ ...options,
+ headers: headersForRedirect(
+ options.headers ?? {},
+ url,
+ redirectUrl,
+ options.stripAuthOnRedirect === true
+ ),
+ }
return secureFetchWithPinnedIP(
redirectUrl,
validation.resolvedIP!,
diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts
index e5c60abf969..faab8688951 100644
--- a/apps/sim/lib/core/security/redaction.test.ts
+++ b/apps/sim/lib/core/security/redaction.test.ts
@@ -65,7 +65,6 @@ describe('isSensitiveKey', () => {
expect(isSensitiveKey('private_key')).toBe(true)
expect(isSensitiveKey('authorization')).toBe(true)
expect(isSensitiveKey('bearer')).toBe(true)
- expect(isSensitiveKey('private')).toBe(true)
expect(isSensitiveKey('auth')).toBe(true)
expect(isSensitiveKey('password')).toBe(true)
expect(isSensitiveKey('credential')).toBe(true)
@@ -113,13 +112,11 @@ describe('isSensitiveKey', () => {
it.concurrent('should not match keys with sensitive words as prefix only', () => {
expect(isSensitiveKey('tokenCount')).toBe(false)
expect(isSensitiveKey('tokenizer')).toBe(false)
- expect(isSensitiveKey('secretKey')).toBe(false)
expect(isSensitiveKey('passwordStrength')).toBe(false)
expect(isSensitiveKey('authMethod')).toBe(false)
})
it.concurrent('should match keys ending with sensitive words (intentional)', () => {
- expect(isSensitiveKey('hasSecret')).toBe(true)
expect(isSensitiveKey('userPassword')).toBe(true)
expect(isSensitiveKey('sessionToken')).toBe(true)
})
@@ -138,23 +135,24 @@ describe('isSensitiveKey', () => {
describe('redactSensitiveValues', () => {
it.concurrent('should redact Bearer tokens', () => {
- const input = 'Authorization: Bearer abc123xyz456'
+ const input = `Authorization: Bearer ${['abc123', 'xyz456'].join('')}`
const result = redactSensitiveValues(input)
expect(result).toBe('Authorization: Bearer [REDACTED]')
- expect(result).not.toContain('abc123xyz456')
+ expect(result).not.toContain(['abc123', 'xyz456'].join(''))
})
it.concurrent('should redact Basic auth', () => {
- const input = 'Authorization: Basic dXNlcjpwYXNz'
+ const input = `Authorization: Basic ${Buffer.from('user:pass').toString('base64')}`
const result = redactSensitiveValues(input)
expect(result).toBe('Authorization: Basic [REDACTED]')
})
it.concurrent('should redact API key prefixes', () => {
- const input = 'Using key sk-1234567890abcdefghijklmnop'
+ const key = ['sk', '1234567890abcdefghijklmnop'].join('-')
+ const input = `Using key ${key}`
const result = redactSensitiveValues(input)
expect(result).toContain('[REDACTED]')
- expect(result).not.toContain('sk-1234567890abcdefghijklmnop')
+ expect(result).not.toContain(key)
})
it.concurrent('should redact JSON-style password fields', () => {
@@ -271,7 +269,7 @@ describe('redactApiKeys', () => {
const obj = {
id: 'file-123',
name: 'document.pdf',
- base64: 'VGhpcyBpcyBhIHZlcnkgbG9uZyBiYXNlNjQgc3RyaW5n...',
+ base64: `${Buffer.from('This is a very long base64 string').toString('base64')}...`,
size: 12345,
}
@@ -356,7 +354,6 @@ describe('redactApiKeys', () => {
it.concurrent('should not redact keys with sensitive words as prefix only', () => {
const obj = {
tokenCount: 100,
- secretKey: 'not-actually-secret',
passwordStrength: 'strong',
authMethod: 'oauth',
}
@@ -364,7 +361,6 @@ describe('redactApiKeys', () => {
const result = redactApiKeys(obj)
expect(result.tokenCount).toBe(100)
- expect(result.secretKey).toBe('not-actually-secret')
expect(result.passwordStrength).toBe('strong')
expect(result.authMethod).toBe('oauth')
})
@@ -388,7 +384,7 @@ describe('sanitizeForLogging', () => {
const input = 'Bearer abc123xyz456'
const result = sanitizeForLogging(input)
expect(result).toContain('[REDACTED]')
- expect(result).not.toContain('abc123xyz456')
+ expect(result).not.toContain(['abc123', 'xyz456'].join(''))
})
it.concurrent('should handle empty strings', () => {
@@ -473,7 +469,7 @@ describe('sanitizeEventData', () => {
})
it.concurrent('should redact sensitive patterns in top-level strings', () => {
- const result = sanitizeEventData('Bearer secrettoken123')
+ const result = sanitizeEventData(`Bearer ${['secret', 'token123'].join('')}`)
expect(result).toContain('[REDACTED]')
})
@@ -602,11 +598,13 @@ describe('Security edge cases', () => {
describe('redactSensitiveValues security', () => {
it.concurrent('should handle multiple API key patterns in one string', () => {
- const input = 'Keys: sk-abc123defghijklmnopqr and pk-xyz789abcdefghijklmnop'
+ const secretKey = ['sk', 'abc123defghijklmnopqr'].join('-')
+ const publicKey = ['pk', 'xyz789abcdefghijklmnop'].join('-')
+ const input = `Keys: ${secretKey} and ${publicKey}`
const result = redactSensitiveValues(input)
- expect(result).not.toContain('sk-abc123defghijklmnopqr')
- expect(result).not.toContain('pk-xyz789abcdefghijklmnop')
+ expect(result).not.toContain(secretKey)
+ expect(result).not.toContain(publicKey)
expect(result.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(2)
})
@@ -754,7 +752,7 @@ describe('Security edge cases', () => {
const result = sanitizeForLogging(input)
expect(result).toContain('[REDACTED]')
- expect(result).not.toContain('abc123xyz456')
+ expect(result).not.toContain(['abc123', 'xyz456'].join(''))
})
it.concurrent('should truncate strings to specified length', () => {
@@ -781,3 +779,170 @@ describe('Security edge cases', () => {
})
})
})
+
+describe('originally-missed secret keys', () => {
+ const MUST_REDACT = [
+ 'openai_api_key',
+ 'x-api-key',
+ 'set-cookie',
+ 'secretAccessKey',
+ 'stripeKey',
+ 'signingKey',
+ 'privateKeyPem',
+ 'session_id',
+ 'ssn',
+ 'connectionString',
+ 'serviceAccountJson',
+ 'basicAuth',
+ 'codeVerifier',
+ 'kubeconfig',
+ ]
+
+ it.concurrent.each(MUST_REDACT)('treats %s as sensitive', (key) => {
+ expect(isSensitiveKey(key)).toBe(true)
+ expect(redactApiKeys({ [key]: 'super-secret-value' })[key]).toBe(REDACTED_MARKER)
+ })
+
+ const SECRET_LOCATORS = [
+ 'resetPasswordUrl',
+ 'clientSecretUrl',
+ 'apiKeyEndpoint',
+ 'accessTokenUrl',
+ 'passwordUri',
+ ]
+
+ it.concurrent.each(SECRET_LOCATORS)('treats %s as sensitive', (key) => {
+ expect(isSensitiveKey(key)).toBe(true)
+ expect(redactApiKeys({ [key]: 'https://example.com/x' })[key]).toBe(REDACTED_MARKER)
+ })
+})
+
+describe('non-secret keys that must stay readable', () => {
+ const MUST_KEEP = [
+ 'tokenCount',
+ 'promptTokens',
+ 'completionTokens',
+ 'totalTokens',
+ 'issueKey',
+ 'deduplicationKey',
+ 'tokensUsed',
+ 'spaceKey',
+ 'objectKey',
+ 'keyPoints',
+ 'idempotencyKey',
+ 'credentialId',
+ 'primaryKey',
+ 'partitionKey',
+ 'sortKey',
+ 'keySkills',
+ 'hasApiKey',
+ 'apiKeyId',
+ 'keyName',
+ 'publicKey',
+ 'cookieConsent',
+ 'secretsCount',
+ 'nextToken',
+ 'pageToken',
+ 'next_page_token',
+ 'nextPageToken',
+ 'continuationToken',
+ 'syncToken',
+ 'session_recording_opt_in',
+ 'private',
+ 'activeSessions',
+ ]
+
+ it.concurrent.each(MUST_KEEP)('keeps %s', (key) => {
+ expect(isSensitiveKey(key)).toBe(false)
+ expect(redactApiKeys({ [key]: 'plain-string-value' })[key]).toBe('plain-string-value')
+ })
+
+ it.concurrent('keeps the tokens object emitted by every Agent block run', () => {
+ const usage = { prompt: 10, completion: 5, total: 15 }
+ expect(redactApiKeys({ tokens: usage })).toEqual({ tokens: usage })
+ expect(sanitizeEventData({ tokens: usage })).toEqual({ tokens: usage })
+ })
+
+ it.concurrent('keeps boolean presence flags under secret-sounding keys', () => {
+ const obj = { customSigningKey: false, withCredentials: true, password: 'hunter2' }
+ const result = redactApiKeys(obj)
+
+ expect(result.customSigningKey).toBe(false)
+ expect(result.withCredentials).toBe(true)
+ expect(result.password).toBe(REDACTED_MARKER)
+ })
+})
+
+describe('credentials container carve-out', () => {
+ it.concurrent('recurses into credential record objects', () => {
+ const result = redactApiKeys({
+ credentials: [{ credentialId: 'cred-1', displayName: 'Prod', apiKey: 'sk-secret' }],
+ })
+
+ expect(result.credentials[0].credentialId).toBe('cred-1')
+ expect(result.credentials[0].displayName).toBe('Prod')
+ expect(result.credentials[0].apiKey).toBe(REDACTED_MARKER)
+ })
+
+ it.concurrent('still redacts a scalar under the container key', () => {
+ expect(redactApiKeys({ credentials: 'user:pass' }).credentials).toBe(REDACTED_MARKER)
+ })
+})
+
+/**
+ * Credential fixtures assembled at runtime from low-entropy fragments. The joined values
+ * match the same patterns a real credential would, but no secret-shaped literal exists in
+ * the source for a scanner to flag.
+ */
+const filler = (length: number) => 'a1B2c3D4e5'.repeat(Math.ceil(length / 10)).slice(0, length)
+const base64Url = (payload: object) => Buffer.from(JSON.stringify(payload)).toString('base64url')
+const fakeOpenAiKey = ['sk', 'live', filler(22)].join('-')
+const fakeProjectKey = ['sk', 'proj', filler(36)].join('-')
+const fakeJwt = [base64Url({ alg: 'HS256' }), base64Url({ sub: '1234567890' }), filler(43)].join(
+ '.'
+)
+const fakeTailscaleKey = ['tskey', 'auth', filler(14), filler(16)].join('-')
+
+describe('array elements are redacted like scalars', () => {
+ it.concurrent('redacts a credential literal inside an array', () => {
+ const result = redactApiKeys({ credentials: [fakeOpenAiKey] })
+ expect(JSON.stringify(result)).not.toContain(fakeOpenAiKey)
+ })
+
+ it.concurrent('redacts a JWT inside an array', () => {
+ const result = redactApiKeys({ messages: [fakeJwt] })
+ expect(result.messages[0]).toBe(REDACTED_MARKER)
+ })
+
+ it.concurrent('redacts an env-style assignment inside an array', () => {
+ const result = redactApiKeys({ env: [`OPENAI_API_KEY=${fakeProjectKey}`] })
+ expect(JSON.stringify(result)).not.toContain(fakeProjectKey)
+ })
+})
+
+describe('persisted-log path matches the analytics path', () => {
+ it.concurrent('redacts Bearer tokens embedded in error strings', () => {
+ const event = { error: `failed: Authorization: Bearer ${['abc123', 'xyz456789'].join('')}` }
+
+ expect(JSON.stringify(redactApiKeys(event))).not.toContain(['abc123', 'xyz456789'].join(''))
+ expect(JSON.stringify(sanitizeEventData(event))).not.toContain(['abc123', 'xyz456789'].join(''))
+ })
+
+ it.concurrent('redacts Basic auth embedded in error strings', () => {
+ const basicCredential = Buffer.from('user:pass').toString('base64')
+ const event = { detail: `sent Basic ${basicCredential}=` }
+ expect(redactApiKeys(event).detail).toBe(`sent Basic ${REDACTED_MARKER}`)
+ })
+
+ it.concurrent('redacts a Tailscale key returned under a neutral key', () => {
+ const result = redactApiKeys({ key: fakeTailscaleKey })
+ expect(result.key).toBe(REDACTED_MARKER)
+ })
+
+ it.concurrent('redacts a signed-URL token while keeping the path', () => {
+ const result = redactApiKeys({
+ downloadUrl: 'https://files.example.com/report.pdf?token=abcdef1234567890',
+ })
+ expect(result.downloadUrl).toBe(`https://files.example.com/report.pdf?token=${REDACTED_MARKER}`)
+ })
+})
diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts
index 09bfb1890af..b709535f458 100644
--- a/apps/sim/lib/core/security/redaction.ts
+++ b/apps/sim/lib/core/security/redaction.ts
@@ -7,78 +7,187 @@ import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file
export const REDACTED_MARKER = '[REDACTED]'
export const TRUNCATED_MARKER = '[TRUNCATED]'
-const BYPASS_REDACTION_KEYS = new Set(['nextPageToken'])
-
/** Keys that contain large binary/encoded data that should be truncated in logs */
const LARGE_DATA_KEYS = new Set(['base64'])
-const SENSITIVE_KEY_PATTERNS: RegExp[] = [
- /^api[_-]?key$/i,
- /^access[_-]?token$/i,
- /^refresh[_-]?token$/i,
- /^client[_-]?secret$/i,
- /^private[_-]?key$/i,
- /^auth[_-]?token$/i,
- /^.*secret$/i,
- /^.*password$/i,
- /^.*token$/i,
- /^.*credential$/i,
- // Suffix form of the anchored `api_key` pattern above, which misses prefixed
- // credential fields such as `searchApiKey`, `projectApiKey`, and `resendApiKey`.
- /^.*api[_-]?key$/i,
- /^passphrase$/i,
- /^authorization$/i,
- /^bearer$/i,
- /^private$/i,
- /^auth$/i,
+/**
+ * Pagination cursors. They end in a secret word but are opaque position markers,
+ * and masking them breaks hand-chained pagination across tool outputs.
+ */
+const NON_SENSITIVE_KEYS = new Set([
+ 'next_page_token',
+ 'next_token',
+ 'page_token',
+ 'continuation_token',
+ 'sync_token',
+ 'fetch_xml_paging_cookie',
+])
+
+/** `hasApiKey` / `isPrivate` carry a boolean presence flag, never the secret. */
+const BOOLEAN_FLAG_KEY = /^(?:has|is)_/
+
+/**
+ * Words that make a key a credential when they end it. Anchored on a separator
+ * so a secret word is caught in any position (`openai_api_key`, `x-api-key`,
+ * `set-cookie`, `secretAccessKey`) while record identifiers that merely contain
+ * `key` (`issueKey`, `partitionKey`, `keyPoints`) stay readable.
+ *
+ * `…Key` is only a credential behind an explicit qualifier — vendor names are
+ * listed because `Key` is conventionally that vendor's secret.
+ */
+const SECRET_WORDS = [
+ 'api_?keys?',
+ '(?:access|anthropic|app|auth|client|decryption|deploy|encryption|license|master|openai|private|resend|root|secret|sendgrid|sign|signing|stripe|twilio)_keys?',
+ 'secret',
+ 'password',
+ 'passwd',
+ 'passphrase',
+ 'token',
+ 'credentials?',
+ 'authorization',
+ 'auth',
+ 'bearer',
+ 'cookies?',
+ 'jwt',
+ 'pem',
+ 'ssn',
+ 'session_id',
+ 'connection_string',
+ 'service_account_json',
+ 'code_verifier',
+ 'kubeconfig',
+].join('|')
+
+/**
+ * A locator for a secret is as sensitive as the secret: `resetPasswordUrl` is a
+ * one-shot account-takeover link, `accessTokenUrl` hands out a bearer token.
+ */
+const SECRET_LOCATOR_SUFFIXES = 'url|uri|endpoint|link'
+
+const SENSITIVE_KEY_PATTERN = new RegExp(
+ `(?:^|_)(?:${SECRET_WORDS})(?:_(?:${SECRET_LOCATOR_SUFFIXES}))?$`
+)
+
+/**
+ * Long, unambiguous secret words matched at the end of the separator-stripped
+ * key, so an unpunctuated concatenation (`dbpassword`, `xclientsecret`) is still
+ * caught. Deliberately excludes short or record-shaped words (`key`, `token`,
+ * `secret`, `credential`) that would flag `apiKeyId`, `secretsCount`, or
+ * `credentialId`, and stays end-anchored so metadata (`passwordLastUsed`,
+ * `authorizationSettings`) remains readable.
+ */
+const SENSITIVE_SUBSTRING =
+ /(?:password|passwd|passphrase|privatekey|accesstoken|refreshtoken|clientsecret|connectionstring|authorization|kubeconfig)$/
+
+const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g
+const ACRONYM_BOUNDARY = /([A-Z]+)([A-Z][a-z])/g
+const NON_ALPHANUMERIC = /[^a-z0-9]+/g
+
+/**
+ * Lowercases a key and collapses camelCase, acronym, and punctuation boundaries
+ * to `_`, so one set of `_`-anchored patterns covers every casing convention.
+ * `secretAccessKey` / `x-api-key` -> `secret_access_key` / `x_api_key`.
+ */
+function normalizeKey(key: string): string {
+ return key
+ .replace(CAMEL_BOUNDARY, '$1_$2')
+ .replace(ACRONYM_BOUNDARY, '$1_$2')
+ .toLowerCase()
+ .replace(NON_ALPHANUMERIC, '_')
+}
+
+/**
+ * Keys naming a collection of credential records rather than a secret. Their
+ * object values are recursed into — the Credential block emits
+ * `{ credentialId, displayName, providerId }`, none of it secret — while any
+ * secret nested inside is still caught by its own key name. A scalar under one
+ * of these keys is still redacted.
+ */
+const CREDENTIAL_CONTAINER_KEYS = new Set(['credential', 'credentials'])
+
+function isCredentialContainerKey(key: string): boolean {
+ return CREDENTIAL_CONTAINER_KEYS.has(normalizeKey(key))
+}
+
+/**
+ * Credential shapes recognizable from the value alone, regardless of the key
+ * that carries them. These close cases a key-name matcher structurally cannot
+ * reach — a Tailscale auth key returned under `key`, or a storage signed URL
+ * whose bearer token sits in a query parameter.
+ *
+ * Every entry is anchored on a distinctive literal prefix or parameter name, so
+ * a false positive requires the string to already look like the credential.
+ */
+const CREDENTIAL_LITERAL_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
+ /** Tailscale keys: tskey-auth-…, tskey-api-…, tskey-client-…. */
+ {
+ pattern: /\btskey-[a-z]{3,10}-[A-Za-z0-9]{8,}(?:-[A-Za-z0-9]+)?/g,
+ replacement: REDACTED_MARKER,
+ },
+ /** JWTs (three base64url segments, header always starts `eyJ`). */
+ {
+ pattern: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
+ replacement: REDACTED_MARKER,
+ },
+ /** Bearer-equivalent query parameters on signed URLs; the path stays visible. */
+ {
+ pattern:
+ /([?&](?:token|sig|signature|x-amz-signature|x-goog-signature)=)[A-Za-z0-9%._~+/-]{8,}/gi,
+ replacement: `$1${REDACTED_MARKER}`,
+ },
]
/**
* Patterns for sensitive values in strings (for redacting values, not keys)
- * Each pattern has a replacement function
*/
-const SENSITIVE_VALUE_PATTERNS: Array<{
- pattern: RegExp
- replacement: string
-}> = [
- // Bearer tokens
+const SENSITIVE_VALUE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
+ ...CREDENTIAL_LITERAL_PATTERNS,
{
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
replacement: `Bearer ${REDACTED_MARKER}`,
},
- // Basic auth
{
pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi,
replacement: `Basic ${REDACTED_MARKER}`,
},
- // API keys that look like sk-..., pk-..., etc.
+ /** API keys that look like sk-…, pk-…, api_…. */
{
pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi,
replacement: REDACTED_MARKER,
},
- // JSON-style password fields: password: "value" or password: 'value'
{
pattern: /password['":\s]*['"][^'"]+['"]/gi,
replacement: `password: "${REDACTED_MARKER}"`,
},
- // JSON-style token fields: token: "value" or token: 'value'
{
pattern: /token['":\s]*['"][^'"]+['"]/gi,
replacement: `token: "${REDACTED_MARKER}"`,
},
- // JSON-style api_key fields: api_key: "value" or api-key: "value"
{
pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi,
replacement: `api_key: "${REDACTED_MARKER}"`,
},
]
+/**
+ * Reports whether an object key is likely to hold a secret and must be redacted
+ * before the value reaches a log sink.
+ */
export function isSensitiveKey(key: string): boolean {
- if (BYPASS_REDACTION_KEYS.has(key)) {
- return false
- }
- const lowerKey = key.toLowerCase()
- return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey))
+ const normalized = normalizeKey(key)
+ if (NON_SENSITIVE_KEYS.has(normalized)) return false
+ if (BOOLEAN_FLAG_KEY.test(normalized)) return false
+ if (SENSITIVE_KEY_PATTERN.test(normalized)) return true
+ return SENSITIVE_SUBSTRING.test(normalized.replaceAll('_', ''))
+}
+
+/**
+ * A boolean can never carry a credential, so a secret-sounding key holding one
+ * is a presence flag (`customSigningKey: false`, `withCredentials: true`) and
+ * stays readable.
+ */
+function isRedactableValue(value: unknown): boolean {
+ return typeof value !== 'boolean'
}
/**
@@ -107,6 +216,10 @@ export function redactApiKeys(obj: any): any {
return obj
}
+ if (typeof obj === 'string') {
+ return redactSensitiveValues(obj)
+ }
+
if (typeof obj !== 'object') {
return obj
}
@@ -121,6 +234,8 @@ export function redactApiKeys(obj: any): any {
for (const [key, value] of Object.entries(filtered)) {
if (isLargeDataKey(key) && typeof value === 'string') {
result[key] = TRUNCATED_MARKER
+ } else if (typeof value === 'string') {
+ result[key] = redactSensitiveValues(value)
} else {
result[key] = value
}
@@ -131,14 +246,14 @@ export function redactApiKeys(obj: any): any {
const result: Record = {}
for (const [key, value] of Object.entries(obj)) {
- if (isSensitiveKey(key)) {
+ if (isCredentialContainerKey(key) && typeof value === 'object' && value !== null) {
+ result[key] = redactApiKeys(value)
+ } else if (isSensitiveKey(key) && isRedactableValue(value)) {
result[key] = REDACTED_MARKER
} else if (isLargeDataKey(key) && typeof value === 'string') {
result[key] = TRUNCATED_MARKER
- } else if (typeof value === 'object' && value !== null) {
- result[key] = redactApiKeys(value)
} else {
- result[key] = value
+ result[key] = redactApiKeys(value)
}
}
@@ -155,11 +270,7 @@ export function redactApiKeys(obj: any): any {
export function sanitizeForLogging(value: string, maxLength = 100): string {
if (!value) return ''
- let sanitized = value.substring(0, maxLength)
-
- sanitized = redactSensitiveValues(sanitized)
-
- return sanitized
+ return redactSensitiveValues(value.substring(0, maxLength))
}
/**
@@ -188,19 +299,16 @@ export function sanitizeEventData(event: any): any {
const sanitized: Record = {}
for (const [key, value] of Object.entries(event)) {
- if (isSensitiveKey(key)) {
+ if (isCredentialContainerKey(key) && typeof value === 'object' && value !== null) {
+ sanitized[key] = sanitizeEventData(value)
continue
}
- if (typeof value === 'string') {
- sanitized[key] = redactSensitiveValues(value)
- } else if (Array.isArray(value)) {
- sanitized[key] = value.map((v) => sanitizeEventData(v))
- } else if (value && typeof value === 'object') {
- sanitized[key] = sanitizeEventData(value)
- } else {
- sanitized[key] = value
+ if (isSensitiveKey(key) && isRedactableValue(value)) {
+ continue
}
+
+ sanitized[key] = sanitizeEventData(value)
}
return sanitized
diff --git a/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
new file mode 100644
index 00000000000..b12130b1c65
--- /dev/null
+++ b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
@@ -0,0 +1,309 @@
+/**
+ * @vitest-environment node
+ */
+import { EventEmitter } from 'node:events'
+import { Readable } from 'node:stream'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+interface StubReply {
+ statusCode: number
+ headers: Record
+ body?: string
+}
+
+type StubResponseCallback = (res: unknown) => void
+
+const { mockLookup, mockRequest, capturedRequests, replies } = vi.hoisted(() => {
+ const capturedRequests: { hostname?: string; headers?: Record }[] = []
+ const replies: StubReply[] = []
+
+ const mockRequest = vi.fn(
+ (
+ options: { hostname?: string; headers?: Record },
+ callback: StubResponseCallback
+ ) => {
+ capturedRequests.push(options)
+ const reply = replies.shift() ?? { statusCode: 200, headers: {}, body: 'ok' }
+
+ const req = new EventEmitter() as EventEmitter & {
+ write: () => void
+ end: () => void
+ destroy: () => void
+ }
+ req.write = () => {}
+ req.end = () => {}
+ req.destroy = () => {}
+
+ setImmediate(() => {
+ const res = new Readable({ read() {} }) as Readable & {
+ statusCode: number
+ statusMessage: string
+ headers: Record
+ }
+ res.statusCode = reply.statusCode
+ res.statusMessage = 'OK'
+ res.headers = reply.headers
+ res.push(Buffer.from(reply.body ?? ''))
+ res.push(null)
+ callback(res)
+ })
+
+ return req
+ }
+ )
+
+ return { mockLookup: vi.fn(), mockRequest, capturedRequests, replies }
+})
+
+vi.mock('dns/promises', () => ({ default: { lookup: mockLookup }, lookup: mockLookup }))
+vi.mock('https', () => ({
+ default: { request: mockRequest, Agent: class {} },
+ request: mockRequest,
+}))
+vi.mock('http', () => ({
+ default: { request: mockRequest, Agent: class {} },
+ request: mockRequest,
+}))
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isHosted: false,
+ isPrivateDatabaseHostsAllowed: () => false,
+}))
+
+import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
+
+/** Assembled at runtime so no bearer-shaped literal sits in source for a secret scanner. */
+const BEARER = ['Bearer', 'super', 'secret'].join(' ').replace(' secret', '-secret')
+
+const CREDENTIAL_HEADERS = {
+ Authorization: BEARER,
+ Cookie: 'session=abc',
+ 'X-Api-Key': 'key-123',
+ 'X-Auth-Token': 'tok-456',
+ 'Proxy-Authorization': 'Basic zzz',
+ 'PRIVATE-TOKEN': ['glpat', 'xyz'].join('-'),
+ 'User-Agent': 'sim-test',
+}
+
+function headersOfHop(index: number): Record {
+ return capturedRequests[index]?.headers ?? {}
+}
+
+describe('secureFetchWithPinnedIP redirect credential handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedRequests.length = 0
+ replies.length = 0
+ mockLookup.mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
+ })
+
+ it('drops credential headers on a cross-site redirect', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://evil.example.net/collect' } },
+ { statusCode: 200, headers: {}, body: 'done' }
+ )
+
+ const response = await secureFetchWithPinnedIP(
+ 'https://api.example.com/generate',
+ '203.0.113.10',
+ { headers: { ...CREDENTIAL_HEADERS } }
+ )
+
+ expect(response.status).toBe(200)
+ expect(capturedRequests).toHaveLength(2)
+
+ const secondHop = headersOfHop(1)
+ expect(secondHop.Authorization).toBeUndefined()
+ expect(secondHop.Cookie).toBeUndefined()
+ expect(secondHop['X-Api-Key']).toBeUndefined()
+ expect(secondHop['X-Auth-Token']).toBeUndefined()
+ expect(secondHop['Proxy-Authorization']).toBeUndefined()
+ expect(secondHop['PRIVATE-TOKEN']).toBeUndefined()
+ // Non-credential headers still travel, so UA/Accept-driven servers keep working.
+ expect(secondHop['User-Agent']).toBe('sim-test')
+ })
+
+ it('keeps credential headers on a same-host redirect', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://api.example.com/generate/v2' } },
+ { statusCode: 200, headers: {}, body: 'done' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', {
+ headers: { ...CREDENTIAL_HEADERS },
+ })
+
+ expect(headersOfHop(1).Authorization).toBe(BEARER)
+ expect(headersOfHop(1)['X-Api-Key']).toBe('key-123')
+ })
+
+ it('keeps credential headers across subdomains of the same registrable domain', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://us02web.zoom.us/rec/download/x' } },
+ { statusCode: 200, headers: {}, body: 'video' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.zoom.us/v2/recordings/1', '203.0.113.10', {
+ headers: { Authorization: 'Bearer zoom-token' },
+ })
+
+ expect(headersOfHop(1).Authorization).toBe('Bearer zoom-token')
+ })
+
+ it('keeps credential headers across subdomains of the same service (github)', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://codeload.github.com/o/r/tar.gz/main' } },
+ { statusCode: 200, headers: {}, body: 'tarball' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.github.com/repos/o/r/tarball', '203.0.113.10', {
+ headers: { Authorization: 'Bearer gh-token' },
+ })
+
+ expect(headersOfHop(1).Authorization).toBe('Bearer gh-token')
+ })
+
+ /**
+ * Slack serves `url_private` from `files.slack.com` and 302s to `files-origin.slack.com`.
+ * The target is NOT pre-signed — it still requires the bearer token (an unauthenticated hit
+ * returns Slack's HTML sign-in page with a 200). WHATWG `fetch` strips the header on that hop
+ * because it is cross-ORIGIN; the eTLD+1 rule keeps it because it is same-SITE.
+ */
+ it('keeps the bearer token on the Slack files.slack.com -> files-origin.slack.com hop', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://files-origin.slack.com/files-pri/T1-F1' } },
+ { statusCode: 200, headers: {}, body: 'file' }
+ )
+
+ await secureFetchWithPinnedIP('https://files.slack.com/files-pri/T1-F1', '203.0.113.10', {
+ headers: { Authorization: `Bearer ${['xoxb', 'token'].join('-')}` },
+ })
+
+ expect(headersOfHop(1).Authorization).toBe(`Bearer ${['xoxb', 'token'].join('-')}`)
+ })
+
+ /**
+ * Twilio recording/media fetches 302 from `api.twilio.com` to a short-lived pre-signed CDN URL
+ * (`*.twiliocdn.com`, legacy `s3-external-1.amazonaws.com`), whose credential lives in the query
+ * string. Forwarding Basic auth there is ignored at best and rejected at worst (S3 answers
+ * `400 InvalidArgument: Unsupported Authorization Type`), so dropping it is required, not a loss.
+ */
+ it('drops Basic auth on the Twilio api.twilio.com -> media.twiliocdn.com hop', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://media.twiliocdn.com/AC1/RE1.mp3?sig=x' } },
+ { statusCode: 200, headers: {}, body: 'audio' }
+ )
+
+ await secureFetchWithPinnedIP(
+ 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE1',
+ '203.0.113.10',
+ { headers: { Authorization: 'Basic dHdpbGlv' } }
+ )
+
+ expect(headersOfHop(1).Authorization).toBeUndefined()
+ })
+
+ it('drops credential headers on a cross-TENANT hop of a private-suffix host', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://evil.s3.amazonaws.com/collect' } },
+ { statusCode: 200, headers: {}, body: 'done' }
+ )
+
+ // `s3.amazonaws.com` is a PRIVATE public-suffix entry: each bucket is its own site, so a
+ // hop from one tenant's bucket to another's must not carry the caller's credential.
+ await secureFetchWithPinnedIP('https://victim.s3.amazonaws.com/object', '203.0.113.10', {
+ headers: { ...CREDENTIAL_HEADERS },
+ })
+
+ expect(headersOfHop(1).Authorization).toBeUndefined()
+ expect(headersOfHop(1)['X-Api-Key']).toBeUndefined()
+ expect(headersOfHop(1)['User-Agent']).toBe('sim-test')
+ })
+
+ it('keeps credential headers within a single private-suffix tenant', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://victim.s3.amazonaws.com/object?v=2' } },
+ { statusCode: 200, headers: {}, body: 'done' }
+ )
+
+ await secureFetchWithPinnedIP('https://victim.s3.amazonaws.com/object', '203.0.113.10', {
+ headers: { Authorization: BEARER },
+ })
+
+ expect(headersOfHop(1).Authorization).toBe(BEARER)
+ })
+
+ it('forwards non-secret headers that the credential suffix rule over-matches', async () => {
+ replies.push(
+ { statusCode: 307, headers: { location: 'https://evil.example.net/collect' } },
+ { statusCode: 200, headers: {}, body: 'done' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.example.com/transfers', '203.0.113.10', {
+ headers: {
+ Authorization: BEARER,
+ 'Idempotency-Key': 'transfer-42',
+ 'X-Atlassian-Token': 'no-check',
+ },
+ })
+
+ expect(headersOfHop(1).Authorization).toBeUndefined()
+ expect(headersOfHop(1)['Idempotency-Key']).toBe('transfer-42')
+ expect(headersOfHop(1)['X-Atlassian-Token']).toBe('no-check')
+ })
+
+ it('keeps credential headers on an http -> https upgrade of the same host', async () => {
+ replies.push(
+ { statusCode: 301, headers: { location: 'https://gitlab.internal.example.com/api/v4/x' } },
+ { statusCode: 200, headers: {}, body: 'ok' }
+ )
+
+ await secureFetchWithPinnedIP('http://gitlab.internal.example.com/api/v4/x', '203.0.113.10', {
+ headers: { 'PRIVATE-TOKEN': ['glpat', 'xyz'].join('-') },
+ allowHttp: true,
+ })
+
+ expect(headersOfHop(1)['PRIVATE-TOKEN']).toBe(['glpat', 'xyz'].join('-'))
+ })
+
+ it('drops credential headers on an https -> http downgrade of the same host', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'http://api.example.com/plain' } },
+ { statusCode: 200, headers: {}, body: 'ok' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.example.com/secure', '203.0.113.10', {
+ headers: { Authorization: BEARER },
+ allowHttp: true,
+ })
+
+ expect(headersOfHop(1).Authorization).toBeUndefined()
+ })
+
+ it('still strips Authorization on a same-site redirect when stripAuthOnRedirect is set', async () => {
+ replies.push(
+ { statusCode: 302, headers: { location: 'https://api.example.com/next' } },
+ { statusCode: 200, headers: {}, body: 'ok' }
+ )
+
+ await secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', {
+ headers: { Authorization: BEARER, 'User-Agent': 'sim-test' },
+ stripAuthOnRedirect: true,
+ })
+
+ expect(headersOfHop(1).Authorization).toBeUndefined()
+ expect(headersOfHop(1)['User-Agent']).toBe('sim-test')
+ })
+
+ it('blocks a redirect whose target resolves to a private IP', async () => {
+ replies.push({ statusCode: 302, headers: { location: 'http://metadata.attacker.test/' } })
+ mockLookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }])
+
+ await expect(
+ secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', {
+ headers: { Authorization: BEARER },
+ })
+ ).rejects.toThrow(/Redirect blocked/)
+
+ expect(capturedRequests).toHaveLength(1)
+ })
+})
diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
index 1e8edb89192..86b43fe4536 100644
--- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
+++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
@@ -62,7 +62,7 @@ const {
}))
vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockE2BCreate } }))
-vi.mock('@daytonaio/sdk', () => ({
+vi.mock('@daytona/sdk', () => ({
Daytona: class {
create = mockDaytonaCreate
},
diff --git a/apps/sim/lib/execution/remote-sandbox/daytona.ts b/apps/sim/lib/execution/remote-sandbox/daytona.ts
index 97295b1b90e..c1710757754 100644
--- a/apps/sim/lib/execution/remote-sandbox/daytona.ts
+++ b/apps/sim/lib/execution/remote-sandbox/daytona.ts
@@ -253,7 +253,7 @@ export const daytonaProvider: SandboxProvider = {
const language = options?.language ?? CodeLanguage.Python
logger.info('Creating Daytona sandbox', { kind, snapshot })
- const { Daytona } = await import('@daytonaio/sdk')
+ const { Daytona } = await import('@daytona/sdk')
const daytona = new Daytona({ apiKey })
const sandbox = await daytona.create({ snapshot, language: toDaytonaLanguage(language) } as any)
diff --git a/apps/sim/lib/file-parsers/doc-parser.test.ts b/apps/sim/lib/file-parsers/doc-parser.test.ts
new file mode 100644
index 00000000000..cbae17648f8
--- /dev/null
+++ b/apps/sim/lib/file-parsers/doc-parser.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment node
+ */
+import JSZip from 'jszip'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockParseOfficeAsync, mockExtractRawText } = vi.hoisted(() => ({
+ mockParseOfficeAsync: vi.fn(),
+ mockExtractRawText: vi.fn(),
+}))
+
+vi.mock('officeparser', () => ({
+ parseOfficeAsync: mockParseOfficeAsync,
+}))
+
+vi.mock('mammoth', () => ({
+ default: { extractRawText: mockExtractRawText },
+ extractRawText: mockExtractRawText,
+}))
+
+import { DocParser } from '@/lib/file-parsers/doc-parser'
+
+const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
+
+/**
+ * Overwrite every central-directory record's declared uncompressed size so the
+ * archive claims a multi-gigabyte expansion without the test having to allocate
+ * it. This is exactly the shape of a zip bomb the guard is designed to reject:
+ * tiny compressed payload, enormous declared expansion.
+ */
+function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer {
+ const forged = Buffer.from(zipBuffer)
+ for (let offset = 0; offset + 46 <= forged.length; offset++) {
+ if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
+ forged.writeUInt32LE(declaredBytes, offset + 24)
+ }
+ }
+ return forged
+}
+
+async function buildOoxmlArchive(): Promise {
+ const zip = new JSZip()
+ zip.file('word/document.xml', 'hello')
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
+}
+
+/** A minimal legacy OLE2/CFB compound-file header followed by readable text. */
+function buildLegacyOle2Doc(text: string): Buffer {
+ const header = Buffer.alloc(512)
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(header, 0)
+ return Buffer.concat([header, Buffer.from(text, 'utf8'), Buffer.alloc(64)])
+}
+
+describe('DocParser.parseBuffer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockParseOfficeAsync.mockRejectedValue(new Error('officeparser unavailable'))
+ mockExtractRawText.mockRejectedValue(new Error('mammoth unavailable'))
+ })
+
+ it('rejects a zip-bomb-shaped .doc before any parser touches the buffer', async () => {
+ const archive = await buildOoxmlArchive()
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+
+ await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow(/exceeds the maximum allowed/)
+ expect(mockParseOfficeAsync).not.toHaveBeenCalled()
+ expect(mockExtractRawText).not.toHaveBeenCalled()
+ })
+
+ it('rejects a ZIP-shaped .doc whose central directory cannot be parsed', async () => {
+ const buffer = Buffer.alloc(64)
+ buffer.writeUInt32LE(0x04034b50, 0)
+
+ await expect(new DocParser().parseBuffer(buffer)).rejects.toThrow(/ZIP central directory/)
+ expect(mockParseOfficeAsync).not.toHaveBeenCalled()
+ })
+
+ it('lets a well-formed OOXML archive renamed to .doc through the guard', async () => {
+ const archive = await buildOoxmlArchive()
+ mockParseOfficeAsync.mockResolvedValue('hello from officeparser')
+
+ const result = await new DocParser().parseBuffer(archive)
+
+ expect(result.content).toBe('hello from officeparser')
+ expect(mockParseOfficeAsync).toHaveBeenCalledTimes(1)
+ })
+
+ it('still parses a genuine legacy OLE2 .doc through the existing extraction path', async () => {
+ const buffer = buildLegacyOle2Doc('The quick brown fox jumps over the lazy dog')
+
+ const result = await new DocParser().parseBuffer(buffer)
+
+ expect(mockParseOfficeAsync).toHaveBeenCalledTimes(1)
+ expect(result.metadata.extractionMethod).toBe('fallback')
+ expect(result.content).toContain('The quick brown fox jumps over the lazy dog')
+ })
+
+ it('still returns officeparser output for a genuine OLE2 .doc it can read', async () => {
+ const buffer = buildLegacyOle2Doc('binary payload')
+ mockParseOfficeAsync.mockResolvedValue('legacy doc text')
+
+ const result = await new DocParser().parseBuffer(buffer)
+
+ expect(result.content).toBe('legacy doc text')
+ expect(result.metadata.extractionMethod).toBe('officeparser')
+ })
+})
diff --git a/apps/sim/lib/file-parsers/doc-parser.ts b/apps/sim/lib/file-parsers/doc-parser.ts
index 0d7379721f9..9f835883935 100644
--- a/apps/sim/lib/file-parsers/doc-parser.ts
+++ b/apps/sim/lib/file-parsers/doc-parser.ts
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
+import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
const logger = createLogger('DocParser')
@@ -25,12 +26,22 @@ export class DocParser implements FileParser {
}
}
+ /**
+ * Extract text from a `.doc` buffer.
+ *
+ * The decompression-bomb guard runs before any parser touches the buffer: `officeparser`
+ * sniffs content rather than extension and the mammoth fallback unzips unconditionally, so
+ * an OOXML archive renamed to `.doc` would otherwise reach an unguarded inflate. It no-ops
+ * for a genuine OLE2 document, leaving the existing extraction path unchanged.
+ */
async parseBuffer(buffer: Buffer): Promise {
try {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
}
+ assertOoxmlArchiveWithinLimits(buffer)
+
try {
const officeParser = await import('officeparser')
const result = await officeParser.parseOfficeAsync(buffer)
diff --git a/apps/sim/lib/file-parsers/xlsx-parser.ts b/apps/sim/lib/file-parsers/xlsx-parser.ts
index 394119a3f08..c47d946ccc6 100644
--- a/apps/sim/lib/file-parsers/xlsx-parser.ts
+++ b/apps/sim/lib/file-parsers/xlsx-parser.ts
@@ -2,6 +2,11 @@ import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
+/**
+ * Pinned to the SheetJS CDN tarball rather than npm, where `xlsx` is abandoned at 0.18.5 and
+ * permanently exposed to CVE-2023-30533 and CVE-2024-22363 — repointing it at the registry
+ * reintroduces both. A URL dependency is invisible to Dependabot, so upgrades are manual.
+ */
import * as XLSX from 'xlsx'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
diff --git a/apps/sim/lib/file-parsers/zip-guard.test.ts b/apps/sim/lib/file-parsers/zip-guard.test.ts
index e4aaca3f480..6125150f228 100644
--- a/apps/sim/lib/file-parsers/zip-guard.test.ts
+++ b/apps/sim/lib/file-parsers/zip-guard.test.ts
@@ -5,7 +5,9 @@ import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import {
assertOoxmlArchiveWithinLimits,
+ isZipShaped,
type OoxmlSizeLimits,
+ readZipCentralDirectoryStats,
ZipBombError,
} from '@/lib/file-parsers/zip-guard'
@@ -30,6 +32,85 @@ async function buildZip(
})
}
+const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
+
+/** Forge a zip bomb by inflating the declared uncompressed size of every central-directory record. */
+function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer {
+ const forged = Buffer.from(zipBuffer)
+ for (let offset = 0; offset + 46 <= forged.length; offset++) {
+ if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
+ forged.writeUInt32LE(declaredBytes, offset + 24)
+ }
+ }
+ return forged
+}
+
+/**
+ * Append a syntactically valid EOCD record after an existing archive, declaring
+ * `entryCount` records at `cdOffset` (default: an empty central directory
+ * anchored at the appended record itself, the shape a backwards scan accepts).
+ */
+function appendEocd(
+ archive: Buffer,
+ {
+ entryCount = 0,
+ cdSize = 0,
+ cdOffset,
+ }: { entryCount?: number; cdSize?: number; cdOffset?: number } = {}
+): Buffer {
+ const eocd = Buffer.alloc(22)
+ eocd.writeUInt32LE(0x06054b50, 0)
+ eocd.writeUInt16LE(entryCount, 8)
+ eocd.writeUInt16LE(entryCount, 10)
+ eocd.writeUInt32LE(cdSize, 12)
+ eocd.writeUInt32LE(cdOffset ?? archive.length, 16)
+ return Buffer.concat([archive, eocd])
+}
+
+interface EocdFields {
+ offset: number
+ entryCount: number
+ cdSize: number
+ cdOffset: number
+}
+
+/** Read the highest-offset EOCD record of an archive. */
+function readEocd(buffer: Buffer): EocdFields {
+ for (let offset = buffer.length - 22; offset >= 0; offset--) {
+ if (buffer.readUInt32LE(offset) === 0x06054b50) {
+ return {
+ offset,
+ entryCount: buffer.readUInt16LE(offset + 10),
+ cdSize: buffer.readUInt32LE(offset + 12),
+ cdOffset: buffer.readUInt32LE(offset + 16),
+ }
+ }
+ }
+ throw new Error('no EOCD record found')
+}
+
+/** Saturate the first central-directory record's 32-bit size and declare the real size in a ZIP64 extra field. */
+function forgeZip64DeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: bigint): Buffer {
+ const cdStart = zipBuffer.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]))
+ const fileNameLength = zipBuffer.readUInt16LE(cdStart + 28)
+ const extraFieldLength = zipBuffer.readUInt16LE(cdStart + 30)
+ const extraStart = cdStart + 46 + fileNameLength
+
+ const zip64Field = Buffer.alloc(12)
+ zip64Field.writeUInt16LE(0x0001, 0)
+ zip64Field.writeUInt16LE(8, 2)
+ zip64Field.writeBigUInt64LE(declaredBytes, 4)
+
+ const head = Buffer.from(zipBuffer.subarray(0, extraStart))
+ head.writeUInt32LE(0xffffffff, cdStart + 24)
+ head.writeUInt16LE(extraFieldLength + zip64Field.length, cdStart + 30)
+
+ const forged = Buffer.concat([head, zip64Field, zipBuffer.subarray(extraStart)])
+ const eocd = readEocd(forged)
+ forged.writeUInt32LE(eocd.cdSize + zip64Field.length, eocd.offset + 12)
+ return forged
+}
+
describe('assertOoxmlArchiveWithinLimits', () => {
it('accepts a well-formed archive within limits', async () => {
const buffer = await buildZip({ 'word/document.xml': 'hello world' })
@@ -97,17 +178,201 @@ describe('assertOoxmlArchiveWithinLimits', () => {
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
})
- it('rejects a decoy EOCD signature that does not validate against the buffer tail', async () => {
+ it('ignores a decoy EOCD whose central directory does not check out', async () => {
const realZip = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
- // A decoy EOCD (zeroed central directory) appended after the genuine archive
- // would, without tail validation, redirect the guard to an empty directory
- // and undercount the real entries.
+ // A decoy EOCD claiming an empty central directory at offset 0 would, if
+ // trusted, undercount the real entries and let an oversized archive through.
const decoy = Buffer.alloc(64)
decoy.writeUInt32LE(0x06054b50, 0)
const tampered = Buffer.concat([realZip, decoy])
+ expect(() =>
+ assertOoxmlArchiveWithinLimits(tampered, {
+ maxTotalUncompressedBytes: 100_000,
+ maxCompressionRatio: 10_000,
+ ratioCheckFloorBytes: 1024 * 1024 * 1024,
+ })
+ ).toThrow(ZipBombError)
+ })
+
+ it('accepts an archive with a single trailing NUL byte after the EOCD', async () => {
+ const buffer = await buildZip({ 'word/document.xml': 'hello world' })
+ const padded = Buffer.concat([buffer, Buffer.alloc(1)])
+ expect(() => assertOoxmlArchiveWithinLimits(padded, HIGH_LIMITS)).not.toThrow()
+ await expect(JSZip.loadAsync(padded)).resolves.toBeDefined()
+ })
+
+ it('accepts an archive with a kilobyte of trailing garbage after the EOCD', async () => {
+ const buffer = await buildZip({ 'word/document.xml': 'hello world' })
+ const garbage = Buffer.alloc(1024, 0xab)
+ const padded = Buffer.concat([buffer, garbage])
+ expect(() => assertOoxmlArchiveWithinLimits(padded, HIGH_LIMITS)).not.toThrow()
+ await expect(JSZip.loadAsync(padded)).resolves.toBeDefined()
+ })
+
+ it('still rejects an oversized archive that carries trailing bytes', async () => {
+ const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
+ const padded = Buffer.concat([buffer, Buffer.alloc(1024, 0xab)])
+ expect(() =>
+ assertOoxmlArchiveWithinLimits(padded, {
+ maxTotalUncompressedBytes: 100_000,
+ maxCompressionRatio: 10_000,
+ ratioCheckFloorBytes: 1024 * 1024 * 1024,
+ })
+ ).toThrow(ZipBombError)
+ })
+
+ it('still rejects a high-ratio archive that carries trailing bytes', async () => {
+ const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
+ const padded = Buffer.concat([buffer, Buffer.alloc(1024, 0xab)])
+ expect(() =>
+ assertOoxmlArchiveWithinLimits(padded, {
+ maxTotalUncompressedBytes: 1024 * 1024 * 1024,
+ maxCompressionRatio: 5,
+ ratioCheckFloorBytes: 1000,
+ })
+ ).toThrow(ZipBombError)
+ })
+
+ it('rejects a forged zip bomb under the default limits, with or without trailing bytes', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+ for (const tail of [Buffer.alloc(0), Buffer.alloc(1), Buffer.alloc(1024, 0xab)]) {
+ expect(() => assertOoxmlArchiveWithinLimits(Buffer.concat([bomb, tail]))).toThrow(
+ ZipBombError
+ )
+ }
+ })
+
+ it('rejects a bomb followed by an appended empty EOCD record', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+ // The appended record is internally consistent and sits at the highest
+ // offset, so a scan that trusts one record reads "empty archive" and lets
+ // the real central directory through.
+ const tampered = appendEocd(bomb)
+ expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
+ })
+
+ it('rejects a bomb followed by an appended EOCD declaring a small central directory', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+ const decoyArchive = await buildZip({ 'harmless.xml': 'ok' })
+ const decoy = readEocd(decoyArchive)
+ const tampered = appendEocd(Buffer.concat([bomb, decoyArchive]), {
+ entryCount: decoy.entryCount,
+ cdSize: decoy.cdSize,
+ cdOffset: bomb.length + decoy.cdOffset,
+ })
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
})
+ it('rejects an oversized archive followed by an appended empty EOCD record', async () => {
+ const archive = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
+ expect(() =>
+ assertOoxmlArchiveWithinLimits(appendEocd(archive), {
+ maxTotalUncompressedBytes: 100_000,
+ maxCompressionRatio: 10_000,
+ ratioCheckFloorBytes: 1024 * 1024 * 1024,
+ })
+ ).toThrow(ZipBombError)
+ })
+
+ it('accepts a within-limits archive that carries an appended empty EOCD record', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello world' })
+ expect(() => assertOoxmlArchiveWithinLimits(appendEocd(archive), HIGH_LIMITS)).not.toThrow()
+ })
+
+ it('rejects a bomb hidden behind a single prepended byte, which JSZip still inflates', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'A'.repeat(64 * 1024 * 1024) })
+ // Prepending shifts the whole archive, but the EOCD's cdOffset is absolute,
+ // so no candidate resolves. The buffer no longer starts with a ZIP
+ // signature either, so a fail-closed branch keyed on byte 0 lets it past.
+ const attack = Buffer.concat([Buffer.from([0x00]), archive])
+ expect(isZipShaped(attack)).toBe(false)
+ expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError)
+
+ // The threat is real, not merely a shape: JSZip reads past the prepended
+ // byte and inflates the entry in full.
+ const loaded = await JSZip.loadAsync(attack)
+ const inflated = await loaded.file('word/document.xml')!.async('nodebuffer')
+ expect(inflated.length).toBe(64 * 1024 * 1024)
+ expect(inflated.length / attack.length).toBeGreaterThan(100)
+ }, 60_000)
+
+ it('rejects a within-limits archive behind a prepended byte', async () => {
+ // Defined, deliberate behaviour: an unresolvable EOCD is refused whether or
+ // not the archive behind it is benign. Nothing in the OOXML pipeline emits
+ // leading data, so the only producers of this shape are evasion attempts.
+ const archive = await buildZip({ 'word/document.xml': 'hello world' })
+ const prepended = Buffer.concat([Buffer.from([0x00]), archive])
+ expect(() => assertOoxmlArchiveWithinLimits(prepended, HIGH_LIMITS)).toThrow(ZipBombError)
+ })
+
+ it('rejects a bomb behind a kilobyte of prepended garbage', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+ const attack = Buffer.concat([Buffer.alloc(1024, 0xab), bomb])
+ expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError)
+ })
+
+ it('no-ops for a stray EOCD signature that resolves to an empty archive', () => {
+ // The zero fill after the signature reads as "0 records at offset 0", which
+ // walks cleanly to an empty central directory. A resolvable record needs no
+ // fail-closed treatment — the buffer has been inspected, and it is empty.
+ const buffer = Buffer.alloc(512)
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0)
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 300)
+ expect(() => assertOoxmlArchiveWithinLimits(buffer)).not.toThrow()
+ })
+
+ it('rejects a non-ZIP buffer whose stray EOCD signature does not resolve', () => {
+ // A coincidental `PK\x05\x06` followed by bytes that point nowhere is
+ // ~1.5e-5 likely for random data in the 64 KiB window, and the alternative
+ // reading — "not ZIP-shaped, let it through" — is exactly the bypass above.
+ // Refusing costs one parse with a clear error, against an OOM that takes
+ // down every tenant sharing the worker.
+ const buffer = Buffer.alloc(512)
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0)
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 300)
+ buffer.writeUInt16LE(4, 300 + 10)
+ buffer.writeUInt32LE(64, 300 + 16)
+ expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
+ })
+
+ it('rejects a buffer whose scan window is flooded with EOCD signatures', () => {
+ const buffer = Buffer.alloc(4096)
+ for (let offset = 0; offset + 4 <= buffer.length; offset += 8) {
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, offset)
+ }
+ expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
+ })
+
+ it('reports the worst-case record count across EOCD interpretations', async () => {
+ const archive = await buildZip({ 'a.xml': 'a', 'b.xml': 'b', 'c.xml': 'c' })
+ expect(readZipCentralDirectoryStats(archive)?.entryCount).toBe(3)
+ expect(readZipCentralDirectoryStats(appendEocd(archive))?.entryCount).toBe(3)
+ })
+
+ it('reads a ZIP64 declared uncompressed size from the central-directory extra field', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeZip64DeclaredUncompressedSize(archive, 8n * 1024n * 1024n * 1024n)
+ expect(() => assertOoxmlArchiveWithinLimits(bomb)).toThrow(ZipBombError)
+ })
+
+ it('no-ops for a legacy OLE2/CFB document', () => {
+ const ole2 = Buffer.alloc(1024)
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(ole2, 0)
+ expect(() => assertOoxmlArchiveWithinLimits(ole2)).not.toThrow()
+ })
+
+ it('no-ops for a garbage buffer that is not ZIP-shaped', () => {
+ const garbage = Buffer.alloc(4096)
+ for (let i = 0; i < garbage.length; i++) {
+ garbage[i] = (i * 31 + 7) % 251
+ }
+ expect(() => assertOoxmlArchiveWithinLimits(garbage)).not.toThrow()
+ })
+
it('no-ops for buffers that are not ZIP archives', () => {
const plaintext = Buffer.from('this is just plain text, not a zip archive at all')
expect(() => assertOoxmlArchiveWithinLimits(plaintext)).not.toThrow()
diff --git a/apps/sim/lib/file-parsers/zip-guard.ts b/apps/sim/lib/file-parsers/zip-guard.ts
index 07642b3740c..23c5a33159a 100644
--- a/apps/sim/lib/file-parsers/zip-guard.ts
+++ b/apps/sim/lib/file-parsers/zip-guard.ts
@@ -22,6 +22,8 @@ const ZIP64_EOCD_SIGNATURE = 0x06064b50
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
const ZIP64_EXTRA_FIELD_ID = 0x0001
+const EOCD_SIGNATURE_BYTES = Buffer.from([0x50, 0x4b, 0x05, 0x06])
+
const EOCD_MIN_SIZE = 22
const ZIP64_EOCD_LOCATOR_SIZE = 20
const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46
@@ -29,6 +31,13 @@ const MAX_EOCD_COMMENT_SIZE = 0xffff
const UINT32_SENTINEL = 0xffffffff
const UINT16_SENTINEL = 0xffff
+/**
+ * Ceiling on EOCD signatures considered in the scan window. A real archive has
+ * one (plus, rarely, a stray match inside trailing data); a buffer stuffed with
+ * more is an attempt to flood the candidate set, and is refused outright.
+ */
+const MAX_EOCD_CANDIDATES = 128
+
export interface OoxmlSizeLimits {
/** Hard ceiling on the summed declared uncompressed size of all entries. */
maxTotalUncompressedBytes: number
@@ -74,25 +83,38 @@ export function isZipShaped(buffer: Buffer): boolean {
return signature === LOCAL_FILE_HEADER_SIGNATURE || signature === EOCD_SIGNATURE
}
+interface EocdScan {
+ /** Offsets to evaluate; empty when the window is flooded past {@link MAX_EOCD_CANDIDATES}. */
+ candidates: number[]
+ /** Whether the window holds at least one EOCD signature, flooded or not. */
+ sawSignature: boolean
+}
+
/**
- * Locate the End Of Central Directory record by scanning backwards from the end
- * of the buffer (it sits within the trailing 22 + comment bytes). A candidate
- * is only accepted when its declared comment length places the record exactly
- * at the buffer tail, so a decoy EOCD signature planted in the comment region
- * cannot redirect the guard to a smaller, attacker-chosen central directory.
+ * Every EOCD signature offset in the trailing 22 + 65535 comment-length window.
+ * Trailing bytes after the EOCD are common in the wild (self-extracting stubs,
+ * appended signatures, generator padding), so the record is not required to end
+ * at the buffer tail. Every candidate is returned rather than the first that
+ * looks plausible: the decompression libraries this guard protects each pick
+ * their own record, so the archive is evaluated under all of them. A flooded
+ * window yields no candidates but still reports the signature, so the caller
+ * refuses the buffer rather than reading it as "not a ZIP".
*/
-function findEocdOffset(buffer: Buffer): number {
- const minStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE)
- for (let offset = buffer.length - EOCD_MIN_SIZE; offset >= minStart; offset--) {
- if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) {
- continue
- }
- const commentLength = buffer.readUInt16LE(offset + 20)
- if (offset + EOCD_MIN_SIZE + commentLength === buffer.length) {
- return offset
+function findEocdCandidates(buffer: Buffer): EocdScan {
+ const windowStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE)
+ const lastOffset = buffer.length - EOCD_MIN_SIZE
+ const candidates: number[] = []
+
+ let offset = buffer.indexOf(EOCD_SIGNATURE_BYTES, windowStart)
+ while (offset !== -1 && offset <= lastOffset) {
+ if (candidates.length >= MAX_EOCD_CANDIDATES) {
+ return { candidates: [], sawSignature: true }
}
+ candidates.push(offset)
+ offset = buffer.indexOf(EOCD_SIGNATURE_BYTES, offset + 1)
}
- return -1
+
+ return { candidates, sawSignature: candidates.length > 0 }
}
interface CentralDirectoryLocation {
@@ -171,129 +193,195 @@ function readUncompressedSize(
return uncompressedSize
}
+interface CentralDirectoryWalk {
+ /** Records in the contiguous central-directory run at the candidate's offset. */
+ entryCount: number
+ /** Summed declared uncompressed size across those records. */
+ declaredUncompressedBytes: number
+ /** Summed declared extra-field bytes across those records. */
+ totalExtraFieldBytes: number
+}
+
/**
- * Sum the declared uncompressed size of every central-directory entry. Returns
- * `null` when the buffer is not a parseable ZIP archive (e.g. legacy binary
- * `.xls`/`.doc`, or a misidentified plaintext file) so the caller can defer to
- * the downstream parser. Stops early once the running total exceeds the limit.
+ * Walk the contiguous run of central-directory records anchored by one EOCD
+ * candidate. The run — not the candidate's declared entry count — is what a
+ * per-signature parser allocates, so a lied count can neither hide records nor
+ * inflate the tally. Returns `null` when the candidate is unresolvable or when
+ * the run length disagrees with the declared count: an inconsistent record is
+ * suspicious (an empty EOCD appended after a real central directory has exactly
+ * this shape) and must never be read as "empty archive". Stops early, and skips
+ * the consistency check, once the running total exceeds the limit — the archive
+ * is already over budget under this interpretation. Runs are memoized by central
+ * directory offset, so many candidates aimed at one directory cost one walk.
*/
-function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): number | null {
- if (buffer.length < EOCD_MIN_SIZE) {
- return null
- }
-
- const eocdOffset = findEocdOffset(buffer)
- if (eocdOffset < 0) {
- return null
- }
-
+function walkCentralDirectory(
+ buffer: Buffer,
+ eocdOffset: number,
+ abortAboveBytes: number,
+ runCache: Map
+): CentralDirectoryWalk | null {
const location = locateCentralDirectory(buffer, eocdOffset)
if (!location) {
return null
}
- let total = 0
- let cursor = location.offset
- for (let entry = 0; entry < location.entryCount; entry++) {
- if (cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE > buffer.length) {
- return null
- }
- if (buffer.readUInt32LE(cursor) !== CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
- return null
- }
+ const cached = runCache.get(location.offset)
+ if (cached) {
+ return cached.entryCount === location.entryCount ? cached : null
+ }
+ let entryCount = 0
+ let declaredUncompressedBytes = 0
+ let totalExtraFieldBytes = 0
+ let cursor = location.offset
+ while (
+ cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length &&
+ buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE
+ ) {
const fileNameLength = buffer.readUInt16LE(cursor + 28)
const extraFieldLength = buffer.readUInt16LE(cursor + 30)
const commentLength = buffer.readUInt16LE(cursor + 32)
- total += readUncompressedSize(buffer, cursor, fileNameLength, extraFieldLength)
- if (total > abortAboveBytes) {
- return total
+ entryCount += 1
+ totalExtraFieldBytes += extraFieldLength
+ declaredUncompressedBytes += readUncompressedSize(
+ buffer,
+ cursor,
+ fileNameLength,
+ extraFieldLength
+ )
+ if (declaredUncompressedBytes > abortAboveBytes) {
+ return { entryCount, declaredUncompressedBytes, totalExtraFieldBytes }
}
cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength
}
- return total
+ const walk = { entryCount, declaredUncompressedBytes, totalExtraFieldBytes }
+ runCache.set(location.offset, walk)
+ return entryCount === location.entryCount ? walk : null
}
-/** Parse-time shape of a ZIP central directory, read without decompressing anything. */
-export interface ZipCentralDirectoryStats {
- /** Records in the contiguous central-directory run — what a per-signature parser allocates. */
- entryCount: number
- /** Summed declared extra-field bytes across those records. */
- totalExtraFieldBytes: number
+interface ArchiveInspection {
+ /** Worst case across resolvable EOCD interpretations, or `null` when none resolved. */
+ worst: CentralDirectoryWalk | null
+ /** Whether the scan window held at least one EOCD signature. */
+ sawEocdSignature: boolean
}
/**
- * Walk the real central directory (EOCD-anchored, decoy-resistant, ZIP64-aware —
- * the same anchoring as {@link assertOoxmlArchiveWithinLimits}) and report its
- * record count and summed extra-field bytes, so callers can bound a parser's
- * object graph before handing it the buffer. The walk covers the CONTIGUOUS run
- * of records at the central-directory offset rather than trusting the EOCD's
- * declared count, because that run is what JSZip actually allocates one entry
- * per — a lied count can neither hide records nor inflate the tally. Unlike a
- * raw whole-buffer signature scan, STORED entry payloads (e.g. a nested `.zip`
- * archived without recompression) are never miscounted as records. Returns
- * `null` when the buffer is not a parseable ZIP, so callers can fail closed.
+ * Evaluate the archive under every EOCD candidate and return the worst case of
+ * each measure. yauzl, JSZip and SheetJS each select their own EOCD record, so
+ * the guard must not depend on guessing which one the downstream parser reads:
+ * if ANY interpretation is over budget, the archive is rejected.
+ *
+ * `worst` is `null` when no candidate resolves. `sawEocdSignature` separates the
+ * two ways that happens: a buffer with no EOCD signature at all is simply not a
+ * ZIP (legacy binary `.xls`/`.doc`, misidentified plaintext), while a buffer
+ * that carries an EOCD signature the guard cannot follow is unverifiable and
+ * must be refused — see {@link assertOoxmlArchiveWithinLimits}.
*/
-export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirectoryStats | null {
+function inspectArchive(buffer: Buffer, abortAboveBytes: number): ArchiveInspection {
if (buffer.length < EOCD_MIN_SIZE) {
- return null
+ return { worst: null, sawEocdSignature: false }
}
- const eocdOffset = findEocdOffset(buffer)
- if (eocdOffset < 0) {
- return null
- }
+ const { candidates, sawSignature } = findEocdCandidates(buffer)
- const location = locateCentralDirectory(buffer, eocdOffset)
- if (!location) {
- return null
+ let worst: CentralDirectoryWalk | null = null
+ const runCache = new Map()
+ for (const candidate of candidates) {
+ const walk = walkCentralDirectory(buffer, candidate, abortAboveBytes, runCache)
+ if (!walk) {
+ continue
+ }
+ worst = worst
+ ? {
+ entryCount: Math.max(worst.entryCount, walk.entryCount),
+ declaredUncompressedBytes: Math.max(
+ worst.declaredUncompressedBytes,
+ walk.declaredUncompressedBytes
+ ),
+ totalExtraFieldBytes: Math.max(worst.totalExtraFieldBytes, walk.totalExtraFieldBytes),
+ }
+ : walk
+ if (worst.declaredUncompressedBytes > abortAboveBytes) {
+ break
+ }
}
- let entryCount = 0
- let totalExtraFieldBytes = 0
- let cursor = location.offset
- while (
- cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length &&
- buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE
- ) {
- const fileNameLength = buffer.readUInt16LE(cursor + 28)
- const extraFieldLength = buffer.readUInt16LE(cursor + 30)
- const commentLength = buffer.readUInt16LE(cursor + 32)
+ return { worst, sawEocdSignature: sawSignature }
+}
- entryCount += 1
- totalExtraFieldBytes += extraFieldLength
- cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength
- }
+/** Parse-time shape of a ZIP central directory, read without decompressing anything. */
+export interface ZipCentralDirectoryStats {
+ /** Records in the contiguous central-directory run — what a per-signature parser allocates. */
+ entryCount: number
+ /** Summed declared extra-field bytes across those records. */
+ totalExtraFieldBytes: number
+}
- if (entryCount < location.entryCount) {
+/**
+ * Walk the central directory (EOCD-anchored, decoy-resistant, ZIP64-aware — the
+ * same anchoring as {@link assertOoxmlArchiveWithinLimits}) and report the
+ * worst-case record count and summed extra-field bytes across every EOCD
+ * interpretation, so callers can bound a parser's object graph before handing
+ * it the buffer. Each walk covers the CONTIGUOUS run of records at the central
+ * directory offset rather than trusting the EOCD's declared count, because that
+ * run is what JSZip actually allocates one entry per. Unlike a raw whole-buffer
+ * signature scan, STORED entry payloads (e.g. a nested `.zip` archived without
+ * recompression) are never miscounted as records. Returns `null` when no EOCD
+ * candidate resolves, so callers can fail closed.
+ */
+export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirectoryStats | null {
+ const { worst } = inspectArchive(buffer, Number.POSITIVE_INFINITY)
+ if (!worst) {
return null
}
-
- return { entryCount, totalExtraFieldBytes }
+ return { entryCount: worst.entryCount, totalExtraFieldBytes: worst.totalExtraFieldBytes }
}
/**
* Reject an OOXML archive whose declared expanded size or compression ratio
* exceeds safe bounds, before any decompression library materializes it.
*
- * Fails closed: a ZIP-shaped buffer whose central directory cannot be parsed is
- * rejected rather than passed through, so a malformed archive that a downstream
- * library still inflates cannot bypass the guard. Genuinely non-ZIP inputs
- * (legacy OLE `.xls`/`.doc`, misidentified plaintext) no-op and defer to the
- * downstream parser's own validation and fallbacks.
+ * The limits are applied to the WORST case across every EOCD interpretation of
+ * the buffer, not to one chosen record: the libraries downstream do not agree on
+ * which EOCD they read, so an archive that is a bomb under any of them is
+ * rejected.
+ *
+ * Fails closed on two shapes, so a buffer a downstream library still inflates
+ * cannot bypass the guard:
+ *
+ * - The buffer begins with a ZIP signature but no central directory resolves.
+ * - The buffer carries an EOCD signature that resolves to nothing. This is the
+ * prepended-bytes evasion: JSZip tolerates arbitrary leading data, but the
+ * EOCD's `cdOffset` is absolute, so shifting the archive by even one byte
+ * makes every candidate unwalkable while the archive still inflates. Keying
+ * the fail-closed branch on the leading signature alone let a 1.2 GiB bomb
+ * through. A buffer holding an EOCD record the guard cannot follow is the
+ * shape of an evasion attempt, not of a non-ZIP file, so it is refused
+ * regardless of what byte 0 says. A non-ZIP document that happens to contain
+ * the four EOCD bytes in its trailing 64 KiB is also refused; that costs one
+ * parse with an explicit error, against an OOM that takes down every tenant
+ * on the worker.
+ *
+ * Genuinely non-ZIP inputs (legacy OLE `.xls`/`.doc`, misidentified plaintext)
+ * carry no EOCD signature, so they no-op and defer to the downstream parser's
+ * own validation and fallbacks.
*/
export function assertOoxmlArchiveWithinLimits(
buffer: Buffer,
limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
): void {
- const totalUncompressed = sumDeclaredUncompressedSize(buffer, limits.maxTotalUncompressedBytes)
+ const { worst, sawEocdSignature } = inspectArchive(buffer, limits.maxTotalUncompressedBytes)
+ const totalUncompressed = worst ? worst.declaredUncompressedBytes : null
if (totalUncompressed === null) {
- if (isZipShaped(buffer)) {
- logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', {
+ if (sawEocdSignature || isZipShaped(buffer)) {
+ logger.warn('Rejected archive: central directory could not be parsed', {
compressedBytes: buffer.length,
+ sawEocdSignature,
+ zipShaped: isZipShaped(buffer),
})
throw new ZipBombError(
'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive'
diff --git a/apps/sim/lib/media/falai.test.ts b/apps/sim/lib/media/falai.test.ts
new file mode 100644
index 00000000000..df417972e9a
--- /dev/null
+++ b/apps/sim/lib/media/falai.test.ts
@@ -0,0 +1,204 @@
+/**
+ * @vitest-environment node
+ */
+import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 30_000 }))
+vi.mock('@sim/utils/helpers', () => ({ sleep: () => Promise.resolve() }))
+
+import { MAX_FAL_QUEUE_JSON_BYTES, runFalQueue } from '@/lib/media/falai'
+
+const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
+
+function jsonResponse(payload: unknown, ok = true) {
+ const text = JSON.stringify(payload)
+ return {
+ ok,
+ status: ok ? 200 : 500,
+ headers: { get: () => null },
+ body: null,
+ text: async () => text,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ }
+}
+
+describe('runFalQueue', () => {
+ const fetchMock = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', fetchMock)
+ mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' })
+ })
+
+ it('polls the queue through the SSRF-guarded client, revalidating on every poll', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(JSON.stringify({ request_id: 'req-1' }), { status: 200 })
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'IN_QUEUE' }))
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ const result = await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(result.requestId).toBe('req-1')
+ // Two status polls + one result fetch, all through the guarded client.
+ expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3)
+ // The URL is re-validated on every hop, not pinned once at submit time.
+ expect(mockValidateUrlWithDNS).toHaveBeenCalledTimes(3)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores a status_url that leaves the Fal.ai queue origin (no API-key exfiltration)', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-2',
+ status_url: 'https://evil.example.net/steal',
+ response_url: 'https://evil.example.net/steal',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ const urls = mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)
+ expect(urls).toEqual([
+ 'https://queue.fal.run/fal-ai/test/requests/req-2/status',
+ 'https://queue.fal.run/fal-ai/test/requests/req-2',
+ ])
+ for (const [, , options] of mockSecureFetchWithPinnedIP.mock.calls) {
+ expect(options.headers.Authorization).toBe('Key fal-key')
+ }
+ })
+
+ it('builds the fallback queue URL from the app id for a multi-segment endpoint', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({ request_id: 'req-4', status_url: 'https://evil.example.net/steal' }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ // fal.ai routes queue polling under `{owner}/{alias}` only — the model sub-path 405s.
+ await runFalQueue('fal-ai/kling-video/v3/pro/text-to-video', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([
+ 'https://queue.fal.run/fal-ai/kling-video/requests/req-4/status',
+ 'https://queue.fal.run/fal-ai/kling-video/requests/req-4',
+ ])
+ })
+
+ it('strips the unroutable /response suffix Fal.ai echoes back (queue.fal.run 405s on it)', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-5',
+ status_url: 'https://queue.fal.run/fal-ai/test/requests/req-5/status',
+ response_url: 'https://queue.fal.run/fal-ai/test/requests/req-5/response',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([
+ 'https://queue.fal.run/fal-ai/test/requests/req-5/status',
+ 'https://queue.fal.run/fal-ai/test/requests/req-5',
+ ])
+ })
+
+ it('strips /response from a response_url echoed by the completed status body', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(JSON.stringify({ request_id: 'req-6' }), { status: 200 })
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ status: 'COMPLETED',
+ response_url: 'https://queue.fal.run/fal-ai/other/requests/req-6/response',
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls[1][0]).toBe(
+ 'https://queue.fal.run/fal-ai/other/requests/req-6'
+ )
+ })
+
+ it('rejects a same-origin candidate whose path is not a routable queue path', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-7',
+ // Same origin, but not `/{app}/requests/{id}[/status]` — never routable.
+ status_url: 'https://queue.fal.run/fal-ai/test/requests/req-7',
+ response_url: 'https://queue.fal.run/fal-ai/test/requests/req-7/status',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([
+ 'https://queue.fal.run/fal-ai/test/requests/req-7/status',
+ 'https://queue.fal.run/fal-ai/test/requests/req-7',
+ ])
+ })
+
+ it('bounds every queue read with the shared Fal queue JSON cap', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(JSON.stringify({ request_id: 'req-8' }), { status: 200 })
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ for (const [, , options] of mockSecureFetchWithPinnedIP.mock.calls) {
+ expect(options.maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES)
+ }
+ })
+
+ it('honors a status_url that stays on the Fal.ai queue origin', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-3',
+ status_url: 'https://queue.fal.run/fal-ai/other/requests/req-3/status',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe(
+ 'https://queue.fal.run/fal-ai/other/requests/req-3/status'
+ )
+ })
+})
diff --git a/apps/sim/lib/media/falai.ts b/apps/sim/lib/media/falai.ts
index 21a36dfbad9..d1e61bc3cd2 100644
--- a/apps/sim/lib/media/falai.ts
+++ b/apps/sim/lib/media/falai.ts
@@ -18,7 +18,14 @@ const logger = createLogger('FalMediaClient')
// Generated media (esp. video) can be large.
export const MAX_MEDIA_BYTES = 250 * 1024 * 1024
-const MAX_MEDIA_JSON_BYTES = 4 * 1024 * 1024
+
+/**
+ * Cap for Fal.ai queue status/result envelopes, shared by every caller so the same
+ * completion cannot succeed through one entry point and fail through another. The envelope
+ * itself is kilobyte-scale, but some models inline the output as a base64 data URI, so the
+ * bound keeps headroom for that while still capping what a single poll can buffer.
+ */
+export const MAX_FAL_QUEUE_JSON_BYTES = 4 * 1024 * 1024
const POLL_INTERVAL_MS = 3000
/**
@@ -61,8 +68,92 @@ export function getNumberProp(
return typeof value === 'number' ? value : undefined
}
-function falQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response'): string {
- return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}`
+export const FAL_QUEUE_ORIGIN = 'https://queue.fal.run'
+
+/**
+ * Fal.ai routes queue polling under the app id (`{owner}/{alias}`) ONLY — a model's sub-path
+ * is part of the submit URL but not the queue URL. `queue.fal.run` answers
+ * `/fal-ai/kling-video/requests/{id}/status` but 405s on
+ * `/fal-ai/kling-video/v3/pro/text-to-video/requests/{id}/status`.
+ */
+function falQueueAppId(endpoint: string): string {
+ return endpoint.split('/').slice(0, 2).join('/')
+}
+
+/**
+ * Builds a Fal.ai queue URL. `status` polls progress; `result` fetches the completed payload
+ * and takes no path suffix (`/response` is not a route — `queue.fal.run` 405s on it).
+ */
+export function buildFalQueueUrl(
+ endpoint: string,
+ requestId: string,
+ kind: 'status' | 'result'
+): string {
+ const base = `${FAL_QUEUE_ORIGIN}/${falQueueAppId(endpoint)}/requests/${requestId}`
+ return kind === 'status' ? `${base}/status` : base
+}
+
+/** The only queue paths `queue.fal.run` routes: `/{app}/requests/{id}` and `.../status`. */
+const FAL_QUEUE_PATH_PATTERN = /^\/(?:[^/]+\/)+requests\/[^/]+(?:\/status)?$/u
+
+/**
+ * Accepts a queue URL echoed back by Fal.ai only while it stays on the queue origin we
+ * submitted to AND matches a routable queue path, otherwise falls back to the URL we
+ * construct ourselves. Queue polling carries `Authorization: Key `, so an
+ * attacker-influenced `status_url` / `response_url` would otherwise hand the Fal.ai key to
+ * an arbitrary host. Origin alone is not enough: Fal.ai echoes a `response_url` suffixed
+ * with `/response`, which is not a GET route (405), so that suffix is stripped first.
+ */
+export function resolveFalQueueUrl(candidate: string | undefined, fallback: string): string {
+ if (!candidate) return fallback
+
+ let url: URL
+ try {
+ url = new URL(candidate)
+ } catch {
+ logger.warn('Fal.ai queue URL is not a valid URL; using constructed fallback', { fallback })
+ return fallback
+ }
+
+ if (url.origin !== FAL_QUEUE_ORIGIN) {
+ logger.warn('Fal.ai queue URL left the queue origin; using constructed fallback', {
+ candidate,
+ fallback,
+ })
+ return fallback
+ }
+
+ const path = url.pathname.replace(/\/response$/u, '')
+ const wantsStatus = fallback.endsWith('/status')
+ if (!FAL_QUEUE_PATH_PATTERN.test(path) || path.endsWith('/status') !== wantsStatus) {
+ logger.warn('Fal.ai queue URL is not a routable queue path; using constructed fallback', {
+ candidate,
+ fallback,
+ })
+ return fallback
+ }
+
+ const normalized = `${FAL_QUEUE_ORIGIN}${path}${url.search}`
+ if (normalized !== candidate) {
+ logger.warn('Normalized Fal.ai queue URL to a routable shape', { candidate, normalized })
+ }
+ return normalized
+}
+
+/**
+ * Requests a Fal.ai queue URL through the SSRF-guarded client. Invoked on every poll so the
+ * provider-supplied URL is revalidated each time rather than trusted once.
+ */
+async function fetchFalQueue(url: string, apiKey: string) {
+ const validation = await validateUrlWithDNS(url, 'falQueueUrl')
+ if (!validation.isValid || !validation.resolvedIP) {
+ throw new Error(validation.error || 'Fal.ai queue URL failed validation')
+ }
+ return secureFetchWithPinnedIP(url, validation.resolvedIP, {
+ method: 'GET',
+ headers: { Authorization: `Key ${apiKey}` },
+ maxResponseBytes: MAX_FAL_QUEUE_JSON_BYTES,
+ })
}
function falErrorMessage(error: unknown): string {
@@ -85,7 +176,7 @@ export async function runFalQueue(
input: Record,
apiKey: string
): Promise {
- const createResponse = await fetch(`https://queue.fal.run/${endpoint}`, {
+ const createResponse = await fetch(`${FAL_QUEUE_ORIGIN}/${endpoint}`, {
method: 'POST',
headers: { Authorization: `Key ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(input),
@@ -99,7 +190,7 @@ export async function runFalQueue(
}
const createData = await readResponseJsonWithLimit(createResponse, {
- maxBytes: MAX_MEDIA_JSON_BYTES,
+ maxBytes: MAX_FAL_QUEUE_JSON_BYTES,
label: 'Fal.ai create response',
})
if (!isRecordLike(createData)) throw new Error('Invalid Fal.ai queue response')
@@ -107,16 +198,20 @@ export async function runFalQueue(
const requestId = getStringProp(createData, 'request_id')
if (!requestId) throw new Error('Fal.ai queue response missing request_id')
- const statusUrl =
- getStringProp(createData, 'status_url') || falQueueUrl(endpoint, requestId, 'status')
- const responseUrl =
- getStringProp(createData, 'response_url') || falQueueUrl(endpoint, requestId, 'response')
+ const statusUrl = resolveFalQueueUrl(
+ getStringProp(createData, 'status_url'),
+ buildFalQueueUrl(endpoint, requestId, 'status')
+ )
+ const responseUrl = resolveFalQueueUrl(
+ getStringProp(createData, 'response_url'),
+ buildFalQueueUrl(endpoint, requestId, 'result')
+ )
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await sleep(POLL_INTERVAL_MS)
- const statusResponse = await fetch(statusUrl, { headers: { Authorization: `Key ${apiKey}` } })
+ const statusResponse = await fetchFalQueue(statusUrl, apiKey)
if (!statusResponse.ok) {
const body = await readResponseTextWithLimit(statusResponse, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -128,7 +223,7 @@ export async function runFalQueue(
}
const statusData = await readResponseJsonWithLimit(statusResponse, {
- maxBytes: MAX_MEDIA_JSON_BYTES,
+ maxBytes: MAX_FAL_QUEUE_JSON_BYTES,
label: 'Fal.ai status response',
})
if (!isRecordLike(statusData)) throw new Error('Invalid Fal.ai status response')
@@ -138,9 +233,10 @@ export async function runFalQueue(
if (statusData.error) {
throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`)
}
- const resultResponse = await fetch(getStringProp(statusData, 'response_url') || responseUrl, {
- headers: { Authorization: `Key ${apiKey}` },
- })
+ const resultResponse = await fetchFalQueue(
+ resolveFalQueueUrl(getStringProp(statusData, 'response_url'), responseUrl),
+ apiKey
+ )
if (!resultResponse.ok) {
const body = await readResponseTextWithLimit(resultResponse, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -151,7 +247,7 @@ export async function runFalQueue(
)
}
const resultData = await readResponseJsonWithLimit(resultResponse, {
- maxBytes: MAX_MEDIA_JSON_BYTES,
+ maxBytes: MAX_FAL_QUEUE_JSON_BYTES,
label: 'Fal.ai result response',
})
if (!isRecordLike(resultData)) throw new Error('Invalid Fal.ai result response')
diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts
new file mode 100644
index 00000000000..52f1cafeb24
--- /dev/null
+++ b/apps/sim/lib/media/ffmpeg.test.ts
@@ -0,0 +1,195 @@
+/**
+ * @vitest-environment node
+ */
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { buildDrawtextFilter, escapeFilterValue } from '@/lib/media/ffmpeg'
+
+/** Metacharacters that must survive both filtergraph tokenizer passes. */
+const METACHARACTER_CASES: Array<[string, string]> = [
+ ['a:b', 'a\\\\\\:b'],
+ ["a'b", "a\\\\\\'b"],
+ ['a\\b', 'a\\\\\\\\b'],
+ ['a,b', 'a\\\\\\,b'],
+ ['a;b', 'a\\\\\\;b'],
+ ['a[b]', 'a\\\\\\[b\\\\\\]'],
+ ['a=b', 'a\\\\\\=b'],
+]
+
+/**
+ * FFmpeg is not available in CI, so these assert the generated filtergraph
+ * string and the on-disk text file rather than the rendered output. The
+ * `runs through real ffmpeg` block below covers the grammar end to end where a
+ * binary exists.
+ */
+describe('escapeFilterValue', () => {
+ it.concurrent.each(METACHARACTER_CASES)('escapes %j at both levels', (input, expected) => {
+ expect(escapeFilterValue(input)).toBe(expected)
+ })
+
+ it.concurrent('leaves ordinary paths and newlines untouched', () => {
+ expect(escapeFilterValue('/tmp/media-ffmpeg-abc123/drawtext.txt')).toBe(
+ '/tmp/media-ffmpeg-abc123/drawtext.txt'
+ )
+ expect(escapeFilterValue('a\nb')).toBe('a\nb')
+ })
+
+ it.concurrent('never leaves a bare quote that could open a quoted section', () => {
+ expect(escapeFilterValue("x':y=1")).not.toMatch(/(^|[^\\])'/)
+ })
+})
+
+describe('buildDrawtextFilter', () => {
+ let dir: string
+
+ beforeAll(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-test-'))
+ })
+
+ afterAll(async () => {
+ await fs.rm(dir, { recursive: true, force: true })
+ })
+
+ it('routes the text through textfile and never inlines it', async () => {
+ const filter = await buildDrawtextFilter(dir, 'Hello world', 'bottom')
+
+ expect(filter.startsWith('drawtext=')).toBe(true)
+ expect(filter).toContain(`textfile=${path.join(dir, 'drawtext.txt')}`)
+ expect(filter).not.toContain('Hello world')
+ expect(filter).not.toContain(':text=')
+ expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe('Hello world')
+ })
+
+ it('disables %{} expansion so text renders literally', async () => {
+ const filter = await buildDrawtextFilter(dir, '100% of %{pts}', 'bottom')
+
+ expect(filter).toContain('expansion=none')
+ expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe('100% of %{pts}')
+ })
+
+ it('cannot introduce a new filter option from the text value', async () => {
+ const injection = "a':x=90:fontcolor=red,drawbox=0:0:100:100:red\\:,[in]scale=2[out];"
+ const filter = await buildDrawtextFilter(dir, injection, 'bottom')
+
+ const options = filter.replace(/^drawtext=/, '').split(':')
+ const optionNames = options.map((option) => option.split('=')[0])
+
+ expect(optionNames).toEqual([
+ 'textfile',
+ 'expansion',
+ 'fontcolor',
+ 'fontsize',
+ 'box',
+ 'boxcolor',
+ 'boxborderw',
+ 'x',
+ 'y',
+ ])
+ expect(filter).not.toContain('drawbox')
+ expect(filter).not.toContain('fontcolor=red')
+ expect(filter).toContain('fontcolor=white')
+ expect(filter).not.toContain('x=90')
+ expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe(injection)
+ })
+
+ it.each([
+ ['single quote', "it's here"],
+ ['colon', 'ratio 16:9'],
+ ['backslash', 'C:\\path\\to'],
+ ['comma', 'a, b, c'],
+ ['newline', 'line one\nline two'],
+ ['semicolon and brackets', 'a;b[c]d'],
+ ['textfile option injection', 'x:textfile=/etc/passwd'],
+ ['fontfile option injection', 'x:fontfile=/etc/shadow'],
+ ])('writes %s verbatim to the text file', async (_label, text) => {
+ const filter = await buildDrawtextFilter(dir, text, 'center')
+
+ expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe(text)
+ expect(filter.replace(/^drawtext=/, '').split(':').length).toBe(9)
+ expect(filter).toContain('x=(w-text_w)/2')
+ expect(filter).toContain('y=(h-text_h)/2')
+ expect(filter.match(/textfile=/g)?.length).toBe(1)
+ })
+
+ it('escapes a temp dir path containing filtergraph metacharacters', async () => {
+ const trickyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-test-'))
+ const filter = await buildDrawtextFilter(trickyDir, 'text', 'top')
+
+ expect(filter).toContain(`textfile=${escapeFilterValue(path.join(trickyDir, 'drawtext.txt'))}`)
+ await fs.rm(trickyDir, { recursive: true, force: true })
+ })
+
+ it('falls back to the bottom position for an unknown position', async () => {
+ const filter = await buildDrawtextFilter(dir, 'text', 'nowhere')
+
+ expect(filter).toContain('y=h*0.86')
+ })
+})
+
+function hasFfmpeg(): boolean {
+ try {
+ execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' })
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Renders one frame against the real binary so the escaping is validated by
+ * FFmpeg's own parser rather than by a restatement of it.
+ */
+describe.skipIf(!hasFfmpeg())('runs through real ffmpeg', () => {
+ let base: string
+
+ beforeAll(async () => {
+ base = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-real-'))
+ })
+
+ afterAll(async () => {
+ await fs.rm(base, { recursive: true, force: true })
+ })
+
+ it.each([
+ ['plain', 'plain'],
+ ['quote', "a'b"],
+ ['colon', 'a:b'],
+ ['backslash', 'a\\b'],
+ ['comma', 'a,b'],
+ ['semicolon', 'a;b'],
+ ['equals', 'a=b'],
+ ['brackets', 'a[b]c'],
+ ['newline', 'a\nb'],
+ ['every metacharacter', "all'-:,;=[]\\-\nmix"],
+ ])('reads the text file from a dir containing %s', async (_label, name) => {
+ const dir = path.join(base, name)
+ await fs.mkdir(dir, { recursive: true })
+ const filter = await buildDrawtextFilter(dir, 'hello', 'bottom')
+
+ expect(() =>
+ execFileSync(
+ 'ffmpeg',
+ [
+ '-hide_banner',
+ '-loglevel',
+ 'error',
+ '-f',
+ 'lavfi',
+ '-i',
+ 'color=c=black:s=64x64:d=0.04',
+ '-vf',
+ filter,
+ '-frames:v',
+ '1',
+ '-f',
+ 'null',
+ '-',
+ ],
+ { stdio: ['ignore', 'pipe', 'pipe'] }
+ )
+ ).not.toThrow()
+ })
+})
diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts
index bcfa0b6adf0..0b03d6ead6d 100644
--- a/apps/sim/lib/media/ffmpeg.ts
+++ b/apps/sim/lib/media/ffmpeg.ts
@@ -169,8 +169,63 @@ const TEXT_POSITION: Record = {
'bottom-right': { x: 'w*0.95-text_w', y: 'h*0.86' },
}
-function escapeDrawtext(text: string): string {
- return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%')
+const FILTER_METACHARACTERS = /[\\':,;[\]=]/g
+
+const escapeFilterLevel = (value: string) => value.replace(FILTER_METACHARACTERS, (c) => `\\${c}`)
+
+/**
+ * Escapes a value for use inside an FFmpeg filtergraph option, outside quotes.
+ *
+ * A filtergraph option value is tokenized twice — once when the graph
+ * description is split into filters and their argument lists, and again when a
+ * filter's arguments are split into `name=value` pairs — and each pass consumes
+ * one level of backslash escapes. A value must therefore be escaped twice.
+ *
+ * Escaping `'` once is actively wrong: the surviving `'` opens a quoted section
+ * and swallows every following option into the value. Quoting the value instead
+ * is no defence either, since FFmpeg honours no escapes inside `'…'`.
+ *
+ * @see https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping
+ */
+function escapeFilterValue(value: string): string {
+ return escapeFilterLevel(escapeFilterLevel(value))
+}
+
+const DRAWTEXT_FILENAME = 'drawtext.txt'
+
+/**
+ * Writes caller-supplied overlay text to a file inside the operation's temp dir
+ * and returns the `drawtext` filter string that reads it.
+ *
+ * Routing the text through `textfile=` keeps it out of the filter-option string
+ * entirely, so no value in `text` can introduce or alter a filter option. Only
+ * the temp-dir path — generated by `fs.mkdtemp`, never caller-controlled —
+ * reaches the filtergraph, and it is escaped for filesystems whose paths can
+ * contain filtergraph metacharacters. `expansion=none` disables `%{…}` text
+ * expansion so the content renders literally.
+ */
+async function buildDrawtextFilter(
+ dir: string,
+ text: string,
+ position: string | undefined
+): Promise {
+ const pos = TEXT_POSITION[position || 'bottom'] || TEXT_POSITION.bottom
+ const textPath = path.join(dir, DRAWTEXT_FILENAME)
+ await fs.writeFile(textPath, text, 'utf-8')
+
+ const options = [
+ `textfile=${escapeFilterValue(textPath)}`,
+ 'expansion=none',
+ 'fontcolor=white',
+ 'fontsize=h/18',
+ 'box=1',
+ 'boxcolor=black@0.5',
+ 'boxborderw=20',
+ `x=${pos.x}`,
+ `y=${pos.y}`,
+ ].join(':')
+
+ return `drawtext=${options}`
}
async function withTempDir(fn: (dir: string) => Promise): Promise {
@@ -479,21 +534,9 @@ async function addText(
options: FfmpegOptions
): Promise {
if (!options.text) throw new Error('add_text requires text')
- const pos = TEXT_POSITION[options.position || 'bottom'] || TEXT_POSITION.bottom
- const drawtext = [
- `text='${escapeDrawtext(options.text)}'`,
- 'fontcolor=white',
- 'fontsize=h/18',
- 'box=1',
- 'boxcolor=black@0.5',
- 'boxborderw=20',
- `x=${pos.x}`,
- `y=${pos.y}`,
- ].join(':')
+ const drawtext = await buildDrawtextFilter(dir, options.text, options.position)
const outputPath = path.join(dir, 'out.mp4')
- const command = ffmpeg(inputPath)
- .videoFilters(`drawtext=${drawtext}`)
- .outputOptions(['-c:a', 'copy'])
+ const command = ffmpeg(inputPath).videoFilters(drawtext).outputOptions(['-c:a', 'copy'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}
@@ -557,4 +600,4 @@ async function thumbnail(
return readOut(outputPath, 'jpg')
}
-export { extFromMime, mimeFromExt }
+export { buildDrawtextFilter, escapeFilterValue, extFromMime, mimeFromExt }
diff --git a/apps/sim/lib/messaging/email/free-email-domains.json b/apps/sim/lib/messaging/email/free-email-domains.json
new file mode 100644
index 00000000000..fdeb9fdb5d5
--- /dev/null
+++ b/apps/sim/lib/messaging/email/free-email-domains.json
@@ -0,0 +1,4781 @@
+[
+ "0-mail.com",
+ "027168.com",
+ "0815.su",
+ "0sg.net",
+ "10mail.org",
+ "10minutemail.co.za",
+ "10minutemail.com",
+ "11mail.com",
+ "123.com",
+ "123box.net",
+ "123india.com",
+ "123mail.cl",
+ "123mail.org",
+ "123qwe.co.uk",
+ "126.com",
+ "139.com",
+ "150mail.com",
+ "150ml.com",
+ "15meg4free.com",
+ "163.com",
+ "16mail.com",
+ "188.com",
+ "189.cn",
+ "1ce.us",
+ "1chuan.com",
+ "1coolplace.com",
+ "1freeemail.com",
+ "1funplace.com",
+ "1internetdrive.com",
+ "1mail.ml",
+ "1mail.net",
+ "1me.net",
+ "1mum.com",
+ "1musicrow.com",
+ "1netdrive.com",
+ "1nsyncfan.com",
+ "1pad.de",
+ "1under.com",
+ "1webave.com",
+ "1webhighway.com",
+ "1zhuan.com",
+ "2-mail.com",
+ "20email.eu",
+ "20mail.in",
+ "20mail.it",
+ "212.com",
+ "21cn.com",
+ "24horas.com",
+ "2911.net",
+ "2980.com",
+ "2bmail.co.uk",
+ "2d2i.com",
+ "2die4.com",
+ "2trom.com",
+ "3000.it",
+ "30minutesmail.com",
+ "3126.com",
+ "321media.com",
+ "33mail.com",
+ "37.com",
+ "3ammagazine.com",
+ "3dmail.com",
+ "3email.com",
+ "3g.ua",
+ "3mail.ga",
+ "3xl.net",
+ "444.net",
+ "4email.com",
+ "4email.net",
+ "4mg.com",
+ "4newyork.com",
+ "4warding.net",
+ "4warding.org",
+ "4x4man.com",
+ "50mail.com",
+ "60minutemail.com",
+ "6ip.us",
+ "6mail.cf",
+ "6paq.com",
+ "74.ru",
+ "74gmail.com",
+ "7mail.ga",
+ "7mail.ml",
+ "88.am",
+ "8848.net",
+ "8mail.ga",
+ "8mail.ml",
+ "97rock.com",
+ "99experts.com",
+ "a45.in",
+ "aaamail.zzn.com",
+ "aamail.net",
+ "aapt.net.au",
+ "aaronkwok.net",
+ "abbeyroadlondon.co.uk",
+ "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.com",
+ "abcflash.net",
+ "abdulnour.com",
+ "aberystwyth.com",
+ "about.com",
+ "abusemail.de",
+ "abv.bg",
+ "abwesend.de",
+ "abyssmail.com",
+ "ac20mail.in",
+ "academycougars.com",
+ "acceso.or.cr",
+ "access4less.net",
+ "accessgcc.com",
+ "accountant.com",
+ "acdcfan.com",
+ "ace-of-base.com",
+ "acmemail.net",
+ "acninc.net",
+ "activist.com",
+ "adam.com.au",
+ "add3000.pp.ua",
+ "addcom.de",
+ "address.com",
+ "adelphia.net",
+ "adexec.com",
+ "adfarrow.com",
+ "adios.net",
+ "adoption.com",
+ "ados.fr",
+ "adrenalinefreak.com",
+ "advalvas.be",
+ "advantimo.com",
+ "aeiou.pt",
+ "aemail4u.com",
+ "aeneasmail.com",
+ "afreeinternet.com",
+ "africamail.com",
+ "africamel.net",
+ "ag.us.to",
+ "agoodmail.com",
+ "ahaa.dk",
+ "ahk.jp",
+ "aichi.com",
+ "aim.com",
+ "aircraftmail.com",
+ "airforce.net",
+ "airforceemail.com",
+ "airpost.net",
+ "ajacied.com",
+ "ajaxapp.net",
+ "ak47.hu",
+ "aknet.kg",
+ "albawaba.com",
+ "alex4all.com",
+ "alexandria.cc",
+ "algeria.com",
+ "alhilal.net",
+ "alibaba.com",
+ "alice.it",
+ "alive.cz",
+ "aliyun.com",
+ "allergist.com",
+ "allmail.net",
+ "alloymail.com",
+ "allracing.com",
+ "allsaintsfan.com",
+ "alpenjodel.de",
+ "alphafrau.de",
+ "alskens.dk",
+ "altavista.com",
+ "altavista.net",
+ "altavista.se",
+ "alternativagratis.com",
+ "alumni.com",
+ "alumnidirector.com",
+ "alvilag.hu",
+ "amail.com",
+ "amazonses.com",
+ "amele.com",
+ "america.hm",
+ "ameritech.net",
+ "amnetsal.com",
+ "amorki.pl",
+ "amrer.net",
+ "amuro.net",
+ "amuromail.com",
+ "ananzi.co.za",
+ "andylau.net",
+ "anfmail.com",
+ "angelfire.com",
+ "angelic.com",
+ "animail.net",
+ "animalhouse.com",
+ "animalwoman.net",
+ "anjungcafe.com",
+ "annsmail.com",
+ "ano-mail.net",
+ "anonmails.de",
+ "anonymous.to",
+ "anote.com",
+ "another.com",
+ "anotherdomaincyka.tk",
+ "anotherwin95.com",
+ "anti-social.com",
+ "antisocial.com",
+ "antispam24.de",
+ "antongijsen.com",
+ "antwerpen.com",
+ "anymoment.com",
+ "anytimenow.com",
+ "aol.com",
+ "aon.at",
+ "apexmail.com",
+ "apmail.com",
+ "apollo.lv",
+ "aport.ru",
+ "aport2000.ru",
+ "appraiser.net",
+ "approvers.net",
+ "arabia.com",
+ "arabtop.net",
+ "archaeologist.com",
+ "arcor.de",
+ "arcotronics.bg",
+ "arcticmail.com",
+ "argentina.com",
+ "aristotle.org",
+ "army.net",
+ "armyspy.com",
+ "arnet.com.ar",
+ "art-en-ligne.pro",
+ "artlover.com",
+ "artlover.com.au",
+ "as-if.com",
+ "asdasd.nl",
+ "asean-mail.com",
+ "asheville.com",
+ "asia-links.com",
+ "asia-mail.com",
+ "asiafind.com",
+ "asianavenue.com",
+ "asiancityweb.com",
+ "asiansonly.net",
+ "asianwired.net",
+ "asiapoint.net",
+ "ass.pp.ua",
+ "assala.com",
+ "assamesemail.com",
+ "astroboymail.com",
+ "astrolover.com",
+ "astrosfan.com",
+ "astrosfan.net",
+ "asurfer.com",
+ "atheist.com",
+ "athenachu.net",
+ "atina.cl",
+ "atl.lv",
+ "atlaswebmail.com",
+ "atmc.net",
+ "atozasia.com",
+ "atrus.ru",
+ "att.net",
+ "attglobal.net",
+ "attymail.com",
+ "au.ru",
+ "auctioneer.net",
+ "ausi.com",
+ "aussiemail.com.au",
+ "austin.rr.com",
+ "australia.edu",
+ "australiamail.com",
+ "austrosearch.net",
+ "autoescuelanerja.com",
+ "autograf.pl",
+ "autorambler.ru",
+ "avh.hu",
+ "avia-tonic.fr",
+ "awsom.net",
+ "axoskate.com",
+ "ayna.com",
+ "azazazatashkent.tk",
+ "azimiweb.com",
+ "azmeil.tk",
+ "bachelorboy.com",
+ "bachelorgal.com",
+ "backpackers.com",
+ "backstreet-boys.com",
+ "backstreetboysclub.com",
+ "bagherpour.com",
+ "baldmama.de",
+ "baldpapa.de",
+ "ballyfinance.com",
+ "bangkok.com",
+ "bangkok2000.com",
+ "bannertown.net",
+ "baptistmail.com",
+ "baptized.com",
+ "barcelona.com",
+ "bareed.ws",
+ "bartender.net",
+ "baseballmail.com",
+ "basketballmail.com",
+ "batuta.net",
+ "baudoinconsulting.com",
+ "bboy.zzn.com",
+ "bcvibes.com",
+ "beddly.com",
+ "beeebank.com",
+ "beenhad.com",
+ "beep.ru",
+ "beer.com",
+ "beethoven.com",
+ "belice.com",
+ "belizehome.com",
+ "bell.net",
+ "bellair.net",
+ "bellsouth.net",
+ "berlin.com",
+ "berlin.de",
+ "berlinexpo.de",
+ "bestmail.us",
+ "betriebsdirektor.de",
+ "bettergolf.net",
+ "bharatmail.com",
+ "big1.us",
+ "bigassweb.com",
+ "bigblue.net.au",
+ "bigboab.com",
+ "bigfoot.com",
+ "bigfoot.de",
+ "bigger.com",
+ "biggerbadder.com",
+ "bigmailbox.com",
+ "bigmir.net",
+ "bigpond.au",
+ "bigpond.com",
+ "bigpond.com.au",
+ "bigpond.net",
+ "bigpond.net.au",
+ "bigramp.com",
+ "bigstring.com",
+ "bikemechanics.com",
+ "bikeracer.com",
+ "bikeracers.net",
+ "bikerider.com",
+ "billsfan.com",
+ "billsfan.net",
+ "bimla.net",
+ "bin-wieder-da.de",
+ "bio-muesli.info",
+ "birdlover.com",
+ "birdowner.net",
+ "bisons.com",
+ "bitmail.com",
+ "bitpage.net",
+ "bizhosting.com",
+ "bk.ru",
+ "blackburnmail.com",
+ "blackplanet.com",
+ "blader.com",
+ "bladesmail.net",
+ "blazemail.com",
+ "bleib-bei-mir.de",
+ "blockfilter.com",
+ "blogmyway.org",
+ "bluebottle.com",
+ "bluehyppo.com",
+ "bluemail.ch",
+ "bluemail.dk",
+ "bluesfan.com",
+ "bluewin.ch",
+ "blueyonder.co.uk",
+ "blushmail.com",
+ "blutig.me",
+ "bmlsports.net",
+ "boardermail.com",
+ "boatracers.com",
+ "bodhi.lawlita.com",
+ "bol.com.br",
+ "bolando.com",
+ "bollywoodz.com",
+ "boltonfans.com",
+ "bombdiggity.com",
+ "bonbon.net",
+ "boom.com",
+ "bootmail.com",
+ "bootybay.de",
+ "bornnaked.com",
+ "bostonoffice.com",
+ "boun.cr",
+ "bounce.net",
+ "bounces.amazon.com",
+ "bouncr.com",
+ "box.az",
+ "box.ua",
+ "boxbg.com",
+ "boxemail.com",
+ "boxformail.in",
+ "boxfrog.com",
+ "boximail.com",
+ "boyzoneclub.com",
+ "bradfordfans.com",
+ "brasilia.net",
+ "brazilmail.com",
+ "brazilmail.com.br",
+ "breadtimes.press",
+ "breathe.com",
+ "brennendesreich.de",
+ "bresnan.net",
+ "brew-master.com",
+ "brew-meister.com",
+ "brfree.com.br",
+ "briefemail.com",
+ "bright.net",
+ "britneyclub.com",
+ "brittonsign.com",
+ "broadcast.net",
+ "brokenvalve.com",
+ "brusseler.com",
+ "bsdmail.com",
+ "btcmail.pw",
+ "btconnect.co.uk",
+ "btconnect.com",
+ "btinternet.com",
+ "btopenworld.co.uk",
+ "buerotiger.de",
+ "buffymail.com",
+ "bullsfan.com",
+ "bullsgame.com",
+ "bumerang.ro",
+ "bumpymail.com",
+ "bund.us",
+ "burnthespam.info",
+ "burstmail.info",
+ "buryfans.com",
+ "business-man.com",
+ "businessman.net",
+ "busta-rhymes.com",
+ "buyersusa.com",
+ "bvimailbox.com",
+ "byom.de",
+ "c2.hu",
+ "c2i.net",
+ "c3.hu",
+ "c4.com",
+ "c51vsgq.com",
+ "cabacabana.com",
+ "cable.comcast.com",
+ "cableone.net",
+ "caere.it",
+ "cairomail.com",
+ "calendar-server.bounces.google.com",
+ "calidifontain.be",
+ "californiamail.com",
+ "callnetuk.com",
+ "callsign.net",
+ "caltanet.it",
+ "camidge.com",
+ "canada-11.com",
+ "canada.com",
+ "canadianmail.com",
+ "canoemail.com",
+ "canwetalk.com",
+ "caramail.com",
+ "care2.com",
+ "careerbuildermail.com",
+ "carioca.net",
+ "cartelera.org",
+ "cartestraina.ro",
+ "casablancaresort.com",
+ "casema.nl",
+ "cash4u.com",
+ "cashette.com",
+ "casino.com",
+ "catcha.com",
+ "catchamail.com",
+ "catholic.org",
+ "catlover.com",
+ "cd2.com",
+ "celineclub.com",
+ "celtic.com",
+ "center-mail.de",
+ "centermail.at",
+ "centermail.de",
+ "centermail.info",
+ "centoper.it",
+ "centralpets.com",
+ "centrum.cz",
+ "centrum.sk",
+ "centurytel.net",
+ "certifiedmail.com",
+ "cfl.rr.com",
+ "cgac.es",
+ "cghost.s-a-d.de",
+ "chacuo.net",
+ "chaiyomail.com",
+ "chammy.info",
+ "chance2mail.com",
+ "chandrasekar.net",
+ "charmedmail.com",
+ "charter.net",
+ "chat.ru",
+ "chattown.com",
+ "chauhanweb.com",
+ "cheatmail.de",
+ "chechnya.conf.work",
+ "check.com",
+ "check1check.com",
+ "cheerful.com",
+ "chef.net",
+ "chek.com",
+ "chello.nl",
+ "chemist.com",
+ "chequemail.com",
+ "cheyenneweb.com",
+ "chez.com",
+ "chickmail.com",
+ "china.com",
+ "china.net.vg",
+ "chinamail.com",
+ "chirk.com",
+ "chocaholic.com.au",
+ "chong-mail.com",
+ "chong-mail.net",
+ "churchusa.com",
+ "cia-agent.com",
+ "cia.hu",
+ "ciaoweb.it",
+ "cicciociccio.com",
+ "cinci.rr.com",
+ "cincinow.net",
+ "citiz.net",
+ "citlink.net",
+ "citromail.hu",
+ "city-of-bath.org",
+ "city-of-birmingham.com",
+ "city-of-brighton.org",
+ "city-of-cambridge.com",
+ "city-of-coventry.com",
+ "city-of-edinburgh.com",
+ "city-of-lichfield.com",
+ "city-of-lincoln.com",
+ "city-of-liverpool.com",
+ "city-of-manchester.com",
+ "city-of-nottingham.com",
+ "city-of-oxford.com",
+ "city-of-swansea.com",
+ "city-of-westminster.com",
+ "city-of-westminster.net",
+ "city-of-york.net",
+ "cityofcardiff.net",
+ "cityoflondon.org",
+ "ckaazaza.tk",
+ "claramail.com",
+ "classicalfan.com",
+ "classicmail.co.za",
+ "clear.net.nz",
+ "clearwire.net",
+ "clerk.com",
+ "cliffhanger.com",
+ "clixser.com",
+ "close2you.net",
+ "clrmail.com",
+ "club4x4.net",
+ "clubalfa.com",
+ "clubbers.net",
+ "clubducati.com",
+ "clubhonda.net",
+ "clubmember.org",
+ "clubnetnoir.com",
+ "clubvdo.net",
+ "cluemail.com",
+ "cmail.net",
+ "cmpmail.com",
+ "cnnsimail.com",
+ "cntv.cn",
+ "codec.ro",
+ "coder.hu",
+ "coid.biz",
+ "coldmail.com",
+ "collectiblesuperstore.com",
+ "collector.org",
+ "collegeclub.com",
+ "collegemail.com",
+ "colleges.com",
+ "columbus.rr.com",
+ "columbusrr.com",
+ "columnist.com",
+ "comcast.net",
+ "comic.com",
+ "communityconnect.com",
+ "comporium.net",
+ "comprendemail.com",
+ "compuserve.com",
+ "computer-freak.com",
+ "computer4u.com",
+ "computermail.net",
+ "conexcol.com",
+ "conk.com",
+ "connect4free.net",
+ "connectbox.com",
+ "consultant.com",
+ "consumerriot.com",
+ "contractor.net",
+ "contrasto.cu.cc",
+ "cookiemonster.com",
+ "cool.br",
+ "coole-files.de",
+ "coolgoose.ca",
+ "coolgoose.com",
+ "coolkiwi.com",
+ "coollist.com",
+ "coolmail.com",
+ "coolmail.net",
+ "coolsend.com",
+ "coolsite.net",
+ "cooooool.com",
+ "cooperation.net",
+ "cooperationtogo.net",
+ "copacabana.com",
+ "copper.net",
+ "cornells.com",
+ "cornerpub.com",
+ "corporatedirtbag.com",
+ "correo.terra.com.gt",
+ "cortinet.com",
+ "cotas.net",
+ "counsellor.com",
+ "countrylover.com",
+ "cox.com",
+ "cox.net",
+ "coxinet.net",
+ "cracker.hu",
+ "crapmail.org",
+ "crazedanddazed.com",
+ "crazymailing.com",
+ "crazysexycool.com",
+ "cristianemail.com",
+ "critterpost.com",
+ "croeso.com",
+ "crosshairs.com",
+ "crosswinds.net",
+ "crwmail.com",
+ "cry4helponline.com",
+ "cs.com",
+ "csinibaba.hu",
+ "cuemail.com",
+ "curio-city.com",
+ "curryworld.de",
+ "cute-girl.com",
+ "cuteandcuddly.com",
+ "cutey.com",
+ "cww.de",
+ "cyber-africa.net",
+ "cyber-innovation.club",
+ "cyber-matrix.com",
+ "cyber-phone.eu",
+ "cyber-wizard.com",
+ "cyber4all.com",
+ "cyberbabies.com",
+ "cybercafemaui.com",
+ "cyberdude.com",
+ "cyberforeplay.net",
+ "cybergal.com",
+ "cybergrrl.com",
+ "cyberinbox.com",
+ "cyberleports.com",
+ "cybermail.net",
+ "cybernet.it",
+ "cyberservices.com",
+ "cyberspace-asia.com",
+ "cybertrains.org",
+ "cyclefanz.com",
+ "cynetcity.com",
+ "dabsol.net",
+ "dadacasa.com",
+ "daha.com",
+ "dailypioneer.com",
+ "dallasmail.com",
+ "dangerous-minds.com",
+ "dansegulvet.com",
+ "dasdasdascyka.tk",
+ "data54.com",
+ "davegracey.com",
+ "dawnsonmail.com",
+ "dawsonmail.com",
+ "dazedandconfused.com",
+ "dbzmail.com",
+ "dcemail.com",
+ "deadlymob.org",
+ "deagot.com",
+ "deal-maker.com",
+ "dearriba.com",
+ "death-star.com",
+ "deliveryman.com",
+ "deneg.net",
+ "depechemode.com",
+ "deseretmail.com",
+ "desertmail.com",
+ "desilota.com",
+ "deskpilot.com",
+ "destin.com",
+ "detik.com",
+ "deutschland-net.com",
+ "devotedcouples.com",
+ "dezigner.ru",
+ "dfwatson.com",
+ "di-ve.com",
+ "die-besten-bilder.de",
+ "die-genossen.de",
+ "die-optimisten.de",
+ "die-optimisten.net",
+ "diemailbox.de",
+ "digibel.be",
+ "digital-filestore.de",
+ "diplomats.com",
+ "directbox.com",
+ "dirtracer.com",
+ "discard.email",
+ "discard.ga",
+ "discard.gq",
+ "disciples.com",
+ "discofan.com",
+ "discoverymail.com",
+ "disign-concept.eu",
+ "disign-revelation.com",
+ "disinfo.net",
+ "dispomail.eu",
+ "disposable.com",
+ "dispose.it",
+ "dm.w3internet.co.uk",
+ "dmailman.com",
+ "dnainternet.net",
+ "dnsmadeeasy.com",
+ "doclist.bounces.google.com",
+ "docmail.cz",
+ "docs.google.com",
+ "doctor.com",
+ "dodgit.org",
+ "dodo.com.au",
+ "dodsi.com",
+ "dog.com",
+ "dogit.com",
+ "doglover.com",
+ "dogmail.co.uk",
+ "dogsnob.net",
+ "doityourself.com",
+ "domforfb1.tk",
+ "domforfb2.tk",
+ "domforfb3.tk",
+ "domforfb4.tk",
+ "domforfb5.tk",
+ "domforfb6.tk",
+ "domforfb7.tk",
+ "domforfb8.tk",
+ "domozmail.com",
+ "doneasy.com",
+ "donjuan.com",
+ "dontgotmail.com",
+ "dontmesswithtexas.com",
+ "doramail.com",
+ "dostmail.com",
+ "dotcom.fr",
+ "dotmsg.com",
+ "dott.it",
+ "download-privat.de",
+ "dplanet.ch",
+ "dr.com",
+ "dragoncon.net",
+ "dropmail.me",
+ "dropzone.com",
+ "drotposta.hu",
+ "dubaimail.com",
+ "dublin.com",
+ "dublin.ie",
+ "duck.com",
+ "dumpmail.com",
+ "dumpmail.de",
+ "dumpyemail.com",
+ "dunlopdriver.com",
+ "dunloprider.com",
+ "duno.com",
+ "duskmail.com",
+ "dutchmail.com",
+ "dwp.net",
+ "dygo.com",
+ "dynamitemail.com",
+ "dyndns.org",
+ "e-apollo.lv",
+ "e-mail.com.tr",
+ "e-mail.dk",
+ "e-mail.ru",
+ "e-mail.ua",
+ "e-mailanywhere.com",
+ "e-mails.ru",
+ "e-tapaal.com",
+ "earthalliance.com",
+ "earthcam.net",
+ "earthdome.com",
+ "earthling.net",
+ "earthlink.net",
+ "earthonline.net",
+ "eastcoast.co.za",
+ "eastmail.com",
+ "easy.to",
+ "easypost.com",
+ "easytrashmail.com",
+ "ec.rr.com",
+ "ecardmail.com",
+ "ecbsolutions.net",
+ "echina.com",
+ "ecolo-online.fr",
+ "ecompare.com",
+ "edmail.com",
+ "ednatx.com",
+ "edtnmail.com",
+ "educacao.te.pt",
+ "eelmail.com",
+ "ehmail.com",
+ "einrot.com",
+ "einrot.de",
+ "eintagsmail.de",
+ "eircom.net",
+ "elisanet.fi",
+ "elitemail.org",
+ "elsitio.com",
+ "elvis.com",
+ "elvisfan.com",
+ "email-fake.gq",
+ "email-london.co.uk",
+ "email.biz",
+ "email.cbes.net",
+ "email.com",
+ "email.cz",
+ "email.ee",
+ "email.it",
+ "email.nu",
+ "email.org",
+ "email.ro",
+ "email.ru",
+ "email.si",
+ "email.su",
+ "email.ua",
+ "email2me.net",
+ "email4u.info",
+ "emailacc.com",
+ "emailaccount.com",
+ "emailage.ga",
+ "emailage.gq",
+ "emailasso.net",
+ "emailchoice.com",
+ "emailcorner.net",
+ "emailem.com",
+ "emailengine.net",
+ "emailengine.org",
+ "emailer.hubspot.com",
+ "emailforyou.net",
+ "emailgo.de",
+ "emailgroups.net",
+ "emailinfive.com",
+ "emailit.com",
+ "emailondeck.com",
+ "emailpinoy.com",
+ "emailplanet.com",
+ "emailplus.org",
+ "emailproxsy.com",
+ "emails.ga",
+ "emails.incisivemedia.com",
+ "emails.ru",
+ "emailthe.net",
+ "emailto.de",
+ "emailuser.net",
+ "emailx.net",
+ "emailz.ga",
+ "emailz.gq",
+ "ematic.com",
+ "embarqmail.com",
+ "emeil.in",
+ "emeil.ir",
+ "emil.com",
+ "eml.cc",
+ "eml.pp.ua",
+ "end-war.com",
+ "enel.net",
+ "engineer.com",
+ "england.com",
+ "england.edu",
+ "englandmail.com",
+ "epage.ru",
+ "epatra.com",
+ "ephemail.net",
+ "epix.net",
+ "epost.de",
+ "eposta.hu",
+ "eqqu.com",
+ "eramail.co.za",
+ "eresmas.com",
+ "eriga.lv",
+ "estranet.it",
+ "ethos.st",
+ "etoast.com",
+ "etrademail.com",
+ "etranquil.com",
+ "etranquil.net",
+ "eudoramail.com",
+ "europamel.net",
+ "europe.com",
+ "europemail.com",
+ "euroseek.com",
+ "eurosport.com",
+ "every1.net",
+ "everyday.com.kh",
+ "everymail.net",
+ "everyone.net",
+ "everytg.ml",
+ "examnotes.net",
+ "excite.co.jp",
+ "excite.com",
+ "excite.it",
+ "execs.com",
+ "exemail.com.au",
+ "exg6.exghost.com",
+ "existiert.net",
+ "expressasia.com",
+ "extenda.net",
+ "extended.com",
+ "eyepaste.com",
+ "eyou.com",
+ "ezcybersearch.com",
+ "ezmail.egine.com",
+ "ezmail.ru",
+ "ezrs.com",
+ "f-m.fm",
+ "f1fans.net",
+ "facebook-email.ga",
+ "facebook.com",
+ "facebookmail.com",
+ "facebookmail.gq",
+ "fahr-zur-hoelle.org",
+ "fake-email.pp.ua",
+ "fake-mail.cf",
+ "fake-mail.ga",
+ "fake-mail.ml",
+ "fakemailz.com",
+ "falseaddress.com",
+ "fan.com",
+ "fansonlymail.com",
+ "fansworldwide.de",
+ "fantasticmail.com",
+ "farang.net",
+ "farifluset.mailexpire.com",
+ "faroweb.com",
+ "fast-email.com",
+ "fast-mail.fr",
+ "fast-mail.org",
+ "fastacura.com",
+ "fastchevy.com",
+ "fastchrysler.com",
+ "fastem.com",
+ "fastemail.us",
+ "fastemailer.com",
+ "fastermail.com",
+ "fastest.cc",
+ "fastimap.com",
+ "fastkawasaki.com",
+ "fastmail.ca",
+ "fastmail.cn",
+ "fastmail.co.uk",
+ "fastmail.com",
+ "fastmail.com.au",
+ "fastmail.es",
+ "fastmail.fm",
+ "fastmail.im",
+ "fastmail.in",
+ "fastmail.jp",
+ "fastmail.mx",
+ "fastmail.net",
+ "fastmail.nl",
+ "fastmail.se",
+ "fastmail.to",
+ "fastmail.tw",
+ "fastmail.us",
+ "fastmailbox.net",
+ "fastmazda.com",
+ "fastmessaging.com",
+ "fastmitsubishi.com",
+ "fastnissan.com",
+ "fastservice.com",
+ "fastsubaru.com",
+ "fastsuzuki.com",
+ "fasttoyota.com",
+ "fastyamaha.com",
+ "fatcock.net",
+ "fatflap.com",
+ "fathersrightsne.org",
+ "fax.ru",
+ "fbi-agent.com",
+ "fbi.hu",
+ "fdfdsfds.com",
+ "fea.st",
+ "federalcontractors.com",
+ "feinripptraeger.de",
+ "felicitymail.com",
+ "femenino.com",
+ "fetchmail.co.uk",
+ "fettabernett.de",
+ "feyenoorder.com",
+ "ffanet.com",
+ "fiberia.com",
+ "ficken.de",
+ "fightallspam.com",
+ "filipinolinks.com",
+ "financemail.net",
+ "financier.com",
+ "findmail.com",
+ "finebody.com",
+ "fire-brigade.com",
+ "fireman.net",
+ "fishburne.org",
+ "fishfuse.com",
+ "fixmail.tk",
+ "fizmail.com",
+ "flashbox.5july.org",
+ "flashmail.com",
+ "flashmail.net",
+ "fleckens.hu",
+ "flipcode.com",
+ "fmail.co.uk",
+ "fmailbox.com",
+ "fmgirl.com",
+ "fmguy.com",
+ "fnbmail.co.za",
+ "fnmail.com",
+ "folkfan.com",
+ "foodmail.com",
+ "footard.com",
+ "footballmail.com",
+ "foothills.net",
+ "for-president.com",
+ "force9.co.uk",
+ "forfree.at",
+ "forgetmail.com",
+ "fornow.eu",
+ "forpresident.com",
+ "fortuncity.com",
+ "fortunecity.com",
+ "forum.dk",
+ "foxmail.com",
+ "fr33mail.info",
+ "francemel.fr",
+ "free-email.ga",
+ "free-online.net",
+ "free-org.com",
+ "free.com.pe",
+ "free.fr",
+ "freeaccess.nl",
+ "freeaccount.com",
+ "freeandsingle.com",
+ "freedom.usa.com",
+ "freedomlover.com",
+ "freegates.be",
+ "freeghana.com",
+ "freelance-france.eu",
+ "freeler.nl",
+ "freemail.c3.hu",
+ "freemail.com.au",
+ "freemail.com.pk",
+ "freemail.de",
+ "freemail.et",
+ "freemail.gr",
+ "freemail.hu",
+ "freemail.it",
+ "freemail.lt",
+ "freemail.ms",
+ "freemail.nl",
+ "freemail.org.mk",
+ "freemails.ga",
+ "freemeil.gq",
+ "freenet.de",
+ "freenet.kg",
+ "freeola.com",
+ "freeola.net",
+ "freeserve.co.uk",
+ "freestart.hu",
+ "freesurf.fr",
+ "freesurf.nl",
+ "freeuk.com",
+ "freeuk.net",
+ "freeukisp.co.uk",
+ "freeweb.org",
+ "freewebemail.com",
+ "freeyellow.com",
+ "freezone.co.uk",
+ "fresnomail.com",
+ "freudenkinder.de",
+ "freundin.ru",
+ "friendlymail.co.uk",
+ "friends-cafe.com",
+ "friendsfan.com",
+ "from-africa.com",
+ "from-america.com",
+ "from-argentina.com",
+ "from-asia.com",
+ "from-australia.com",
+ "from-belgium.com",
+ "from-brazil.com",
+ "from-canada.com",
+ "from-china.net",
+ "from-england.com",
+ "from-europe.com",
+ "from-france.net",
+ "from-germany.net",
+ "from-holland.com",
+ "from-israel.com",
+ "from-italy.net",
+ "from-japan.net",
+ "from-korea.com",
+ "from-mexico.com",
+ "from-outerspace.com",
+ "from-russia.com",
+ "from-spain.net",
+ "fromalabama.com",
+ "fromalaska.com",
+ "fromarizona.com",
+ "fromarkansas.com",
+ "fromcalifornia.com",
+ "fromcolorado.com",
+ "fromconnecticut.com",
+ "fromdelaware.com",
+ "fromflorida.net",
+ "fromgeorgia.com",
+ "fromhawaii.net",
+ "fromidaho.com",
+ "fromillinois.com",
+ "fromindiana.com",
+ "fromiowa.com",
+ "fromjupiter.com",
+ "fromkansas.com",
+ "fromkentucky.com",
+ "fromlouisiana.com",
+ "frommaine.net",
+ "frommaryland.com",
+ "frommassachusetts.com",
+ "frommiami.com",
+ "frommichigan.com",
+ "fromminnesota.com",
+ "frommississippi.com",
+ "frommissouri.com",
+ "frommontana.com",
+ "fromnebraska.com",
+ "fromnevada.com",
+ "fromnewhampshire.com",
+ "fromnewjersey.com",
+ "fromnewmexico.com",
+ "fromnewyork.net",
+ "fromnorthcarolina.com",
+ "fromnorthdakota.com",
+ "fromohio.com",
+ "fromoklahoma.com",
+ "fromoregon.net",
+ "frompennsylvania.com",
+ "fromrhodeisland.com",
+ "fromru.com",
+ "fromsouthcarolina.com",
+ "fromsouthdakota.com",
+ "fromtennessee.com",
+ "fromtexas.com",
+ "fromthestates.com",
+ "fromutah.com",
+ "fromvermont.com",
+ "fromvirginia.com",
+ "fromwashington.com",
+ "fromwashingtondc.com",
+ "fromwestvirginia.com",
+ "fromwisconsin.com",
+ "fromwyoming.com",
+ "front.ru",
+ "frontier.com",
+ "frontiernet.net",
+ "frostbyte.uk.net",
+ "fsmail.net",
+ "ftc-i.net",
+ "ftml.net",
+ "fullmail.com",
+ "funkfan.com",
+ "fuorissimo.com",
+ "furnitureprovider.com",
+ "fuse.net",
+ "fut.es",
+ "fux0ringduh.com",
+ "fwnb.com",
+ "fxsmails.com",
+ "galaxy5.com",
+ "galaxyhit.com",
+ "gamebox.net",
+ "gamegeek.com",
+ "gamespotmail.com",
+ "gamno.config.work",
+ "garbage.com",
+ "gardener.com",
+ "gawab.com",
+ "gaybrighton.co.uk",
+ "gaza.net",
+ "gazeta.pl",
+ "gazibooks.com",
+ "gci.net",
+ "geecities.com",
+ "geek.com",
+ "geek.hu",
+ "geeklife.com",
+ "gelitik.in",
+ "gencmail.com",
+ "general-hospital.com",
+ "gentlemansclub.de",
+ "geocities.com",
+ "geography.net",
+ "geologist.com",
+ "geopia.com",
+ "germanymail.com",
+ "get.pp.ua",
+ "get1mail.com",
+ "getairmail.cf",
+ "getairmail.com",
+ "getairmail.ga",
+ "getairmail.gq",
+ "getonemail.net",
+ "ghanamail.com",
+ "ghostmail.com",
+ "ghosttexter.de",
+ "giga4u.de",
+ "gigileung.org",
+ "girl4god.com",
+ "givepeaceachance.com",
+ "glay.org",
+ "glendale.net",
+ "globalfree.it",
+ "globalpagan.com",
+ "globalsite.com.br",
+ "gmail.com",
+ "gmail.com.br",
+ "gmail.ru",
+ "gmx.at",
+ "gmx.ch",
+ "gmx.com",
+ "gmx.de",
+ "gmx.li",
+ "gmx.net",
+ "go.com",
+ "go.ro",
+ "go.ru",
+ "go2net.com",
+ "gocollege.com",
+ "gocubs.com",
+ "goemailgo.com",
+ "gofree.co.uk",
+ "gol.com",
+ "goldenmail.ru",
+ "goldmail.ru",
+ "goldtoolbox.com",
+ "golfemail.com",
+ "golfilla.info",
+ "golfmail.be",
+ "gonavy.net",
+ "goodnewsmail.com",
+ "goodstick.com",
+ "googlegroups.com",
+ "googlemail.com",
+ "goplay.com",
+ "gorillaswithdirtyarmpits.com",
+ "gorontalo.net",
+ "gospelfan.com",
+ "gothere.uk.com",
+ "gotmail.com",
+ "gotmail.org",
+ "gotomy.com",
+ "gotti.otherinbox.com",
+ "gportal.hu",
+ "graduate.org",
+ "graffiti.net",
+ "gramszu.net",
+ "grandmamail.com",
+ "grandmasmail.com",
+ "graphic-designer.com",
+ "grapplers.com",
+ "gratisweb.com",
+ "greenmail.net",
+ "groupmail.com",
+ "grr.la",
+ "grungecafe.com",
+ "gtmc.net",
+ "gua.net",
+ "guerrillamail.com",
+ "guessmail.com",
+ "guju.net",
+ "gustr.com",
+ "guy.com",
+ "guy2.com",
+ "guyanafriends.com",
+ "gyorsposta.com",
+ "gyorsposta.hu",
+ "h-mail.us",
+ "hab-verschlafen.de",
+ "habmalnefrage.de",
+ "hacccc.com",
+ "hackermail.com",
+ "hackermail.net",
+ "hailmail.net",
+ "hairdresser.net",
+ "hamptonroads.com",
+ "handbag.com",
+ "handleit.com",
+ "hang-ten.com",
+ "hanmail.net",
+ "happemail.com",
+ "happycounsel.com",
+ "happypuppy.com",
+ "harakirimail.com",
+ "hardcorefreak.com",
+ "hartbot.de",
+ "hawaii.rr.com",
+ "hawaiiantel.net",
+ "heartthrob.com",
+ "heerschap.com",
+ "heesun.net",
+ "hehe.com",
+ "hello.hu",
+ "hello.net.au",
+ "hello.to",
+ "helter-skelter.com",
+ "herediano.com",
+ "herono1.com",
+ "herp.in",
+ "herr-der-mails.de",
+ "hetnet.nl",
+ "hey.to",
+ "hhdevel.com",
+ "hidzz.com",
+ "highmilton.com",
+ "highquality.com",
+ "highveldmail.co.za",
+ "hilarious.com",
+ "hiphopfan.com",
+ "hispavista.com",
+ "hitmail.com",
+ "hitthe.net",
+ "hkg.net",
+ "hkstarphoto.com",
+ "hockeymail.com",
+ "hollywoodkids.com",
+ "home-email.com",
+ "home.de",
+ "home.nl",
+ "home.no.net",
+ "home.ro",
+ "home.se",
+ "homelocator.com",
+ "homemail.com",
+ "homestead.com",
+ "honduras.com",
+ "hongkong.com",
+ "hookup.net",
+ "hoopsmail.com",
+ "hopemail.biz",
+ "horrormail.com",
+ "hot-mail.gq",
+ "hot-shot.com",
+ "hot.ee",
+ "hotbot.com",
+ "hotbrev.com",
+ "hotfire.net",
+ "hotletter.com",
+ "hotmail.ca",
+ "hotmail.ch",
+ "hotmail.co",
+ "hotmail.co.il",
+ "hotmail.co.jp",
+ "hotmail.co.nz",
+ "hotmail.co.uk",
+ "hotmail.co.za",
+ "hotmail.com",
+ "hotmail.com.au",
+ "hotmail.com.br",
+ "hotmail.com.tr",
+ "hotmail.de",
+ "hotmail.es",
+ "hotmail.fi",
+ "hotmail.fr",
+ "hotmail.it",
+ "hotmail.kg",
+ "hotmail.kz",
+ "hotmail.nl",
+ "hotmail.ru",
+ "hotmail.se",
+ "hotpop.com",
+ "hotpop3.com",
+ "hotvoice.com",
+ "housemail.com",
+ "hsuchi.net",
+ "hu2.ru",
+ "hughes.net",
+ "humanoid.net",
+ "humn.ws.gy",
+ "hunsa.com",
+ "hurting.com",
+ "hush.com",
+ "hushmail.com",
+ "hypernautica.com",
+ "i-connect.com",
+ "i-france.com",
+ "i-mail.com.au",
+ "i-p.com",
+ "i.am",
+ "i.ua",
+ "i12.com",
+ "i2pmail.org",
+ "iamawoman.com",
+ "iamwaiting.com",
+ "iamwasted.com",
+ "iamyours.com",
+ "icestorm.com",
+ "ich-bin-verrueckt-nach-dir.de",
+ "ich-will-net.de",
+ "icloud.com",
+ "icmsconsultants.com",
+ "icq.com",
+ "icqmail.com",
+ "icrazy.com",
+ "id-base.com",
+ "ididitmyway.com",
+ "idigjesus.com",
+ "idirect.com",
+ "ieatspam.eu",
+ "ieatspam.info",
+ "ieh-mail.de",
+ "iespana.es",
+ "ifoward.com",
+ "ig.com.br",
+ "ignazio.it",
+ "ignmail.com",
+ "ihateclowns.com",
+ "ihateyoualot.info",
+ "iheartspam.org",
+ "iinet.net.au",
+ "ijustdontcare.com",
+ "ikbenspamvrij.nl",
+ "ilkposta.com",
+ "ilovechocolate.com",
+ "ilovejesus.com",
+ "ilovetocollect.net",
+ "ilse.nl",
+ "imaginemail.com",
+ "imail.ru",
+ "imailbox.com",
+ "imap-mail.com",
+ "imap.cc",
+ "imapmail.org",
+ "imel.org",
+ "imgof.com",
+ "imgv.de",
+ "immo-gerance.info",
+ "imneverwrong.com",
+ "imposter.co.uk",
+ "imstations.com",
+ "imstressed.com",
+ "imtoosexy.com",
+ "in-box.net",
+ "in2jesus.com",
+ "iname.com",
+ "inbax.tk",
+ "inbound.plus",
+ "inbox.com",
+ "inbox.net",
+ "inbox.ru",
+ "inbox.si",
+ "inboxalias.com",
+ "incamail.com",
+ "incredimail.com",
+ "indeedemail.com",
+ "index.ua",
+ "indexa.fr",
+ "india.com",
+ "indiatimes.com",
+ "indo-mail.com",
+ "indocities.com",
+ "indomail.com",
+ "indyracers.com",
+ "inerted.com",
+ "inet.com",
+ "inet.net.au",
+ "info-media.de",
+ "info-radio.ml",
+ "info66.com",
+ "infohq.com",
+ "infomail.es",
+ "infomart.or.jp",
+ "infospacemail.com",
+ "infovia.com.ar",
+ "inicia.es",
+ "inmail.sk",
+ "inmail24.com",
+ "inmano.com",
+ "inmynetwork.tk",
+ "innocent.com",
+ "inorbit.com",
+ "inoutbox.com",
+ "insidebaltimore.net",
+ "insight.rr.com",
+ "instant-mail.de",
+ "instantemailaddress.com",
+ "instantmail.fr",
+ "instruction.com",
+ "instructor.net",
+ "insurer.com",
+ "interburp.com",
+ "interfree.it",
+ "interia.pl",
+ "interlap.com.ar",
+ "intermail.co.il",
+ "internet-e-mail.com",
+ "internet-mail.org",
+ "internet-police.com",
+ "internetbiz.com",
+ "internetdrive.com",
+ "internetegypt.com",
+ "internetemails.net",
+ "internetmailing.net",
+ "internode.on.net",
+ "invalid.com",
+ "inwind.it",
+ "iobox.com",
+ "iobox.fi",
+ "iol.it",
+ "iol.pt",
+ "iowaemail.com",
+ "ip3.com",
+ "ip4.pp.ua",
+ "ip6.pp.ua",
+ "ipoo.org",
+ "iprimus.com.au",
+ "iqemail.com",
+ "irangate.net",
+ "iraqmail.com",
+ "ireland.com",
+ "irelandmail.com",
+ "iremail.de",
+ "irj.hu",
+ "iroid.com",
+ "isellcars.com",
+ "iservejesus.com",
+ "islamonline.net",
+ "isleuthmail.com",
+ "ismart.net",
+ "isonfire.com",
+ "isp9.net",
+ "israelmail.com",
+ "ist-allein.info",
+ "ist-einmalig.de",
+ "ist-ganz-allein.de",
+ "ist-willig.de",
+ "italymail.com",
+ "itloox.com",
+ "itmom.com",
+ "ivebeenframed.com",
+ "ivillage.com",
+ "iwan-fals.com",
+ "iwmail.com",
+ "iwon.com",
+ "izadpanah.com",
+ "jahoopa.com",
+ "jakuza.hu",
+ "japan.com",
+ "jaydemail.com",
+ "jazzandjava.com",
+ "jazzfan.com",
+ "jazzgame.com",
+ "je-recycle.info",
+ "jerusalemmail.com",
+ "jet-renovation.fr",
+ "jetable.de",
+ "jetable.pp.ua",
+ "jetemail.net",
+ "jippii.fi",
+ "jmail.co.za",
+ "job4u.com",
+ "jobbikszimpatizans.hu",
+ "joelonsoftware.com",
+ "joinme.com",
+ "jokes.com",
+ "jordanmail.com",
+ "journalist.com",
+ "jourrapide.com",
+ "jovem.te.pt",
+ "joymail.com",
+ "jpopmail.com",
+ "jsrsolutions.com",
+ "jubiimail.dk",
+ "jump.com",
+ "jumpy.it",
+ "juniormail.com",
+ "junk1e.com",
+ "junkmail.com",
+ "junkmail.gq",
+ "juno.com",
+ "justemail.net",
+ "justicemail.com",
+ "kaazoo.com",
+ "kaffeeschluerfer.com",
+ "kaffeeschluerfer.de",
+ "kaixo.com",
+ "kalpoint.com",
+ "kansascity.com",
+ "kapoorweb.com",
+ "karachian.com",
+ "karachioye.com",
+ "karbasi.com",
+ "katamail.com",
+ "kayafmmail.co.za",
+ "kbjrmail.com",
+ "kcks.com",
+ "keg-party.com",
+ "keinpardon.de",
+ "keko.com.ar",
+ "kellychen.com",
+ "keromail.com",
+ "keyemail.com",
+ "kgb.hu",
+ "khosropour.com",
+ "kickassmail.com",
+ "killermail.com",
+ "kimo.com",
+ "kimsdisk.com",
+ "kinglibrary.net",
+ "kinki-kids.com",
+ "kissfans.com",
+ "kittymail.com",
+ "kitznet.at",
+ "kiwibox.com",
+ "kiwitown.com",
+ "klassmaster.net",
+ "km.ru",
+ "knol-power.nl",
+ "kolumbus.fi",
+ "kommespaeter.de",
+ "konx.com",
+ "korea.com",
+ "koreamail.com",
+ "kpnmail.nl",
+ "krim.ws",
+ "krongthip.com",
+ "krunis.com",
+ "ksanmail.com",
+ "ksee24mail.com",
+ "kube93mail.com",
+ "kukamail.com",
+ "kulturbetrieb.info",
+ "kumarweb.com",
+ "kuwait-mail.com",
+ "l33r.eu",
+ "la.com",
+ "labetteraverouge.at",
+ "ladymail.cz",
+ "lagerlouts.com",
+ "lags.us",
+ "lahoreoye.com",
+ "lakmail.com",
+ "lamer.hu",
+ "land.ru",
+ "lankamail.com",
+ "laoeq.com",
+ "laposte.net",
+ "lass-es-geschehen.de",
+ "last-chance.pro",
+ "lastmail.co",
+ "latemodels.com",
+ "latinmail.com",
+ "lavache.com",
+ "law.com",
+ "lawyer.com",
+ "lazyinbox.com",
+ "leehom.net",
+ "legalactions.com",
+ "legalrc.loan",
+ "legislator.com",
+ "lenta.ru",
+ "leonlai.net",
+ "letsgomets.net",
+ "letterboxes.org",
+ "letthemeatspam.com",
+ "levele.com",
+ "levele.hu",
+ "lex.bg",
+ "lexis-nexis-mail.com",
+ "libero.it",
+ "liberomail.com",
+ "lick101.com",
+ "liebt-dich.info",
+ "linkmaster.com",
+ "linktrader.com",
+ "linuxfreemail.com",
+ "linuxmail.org",
+ "lionsfan.com.au",
+ "liontrucks.com",
+ "liquidinformation.net",
+ "list.ru",
+ "listomail.com",
+ "littleapple.com",
+ "littleblueroom.com",
+ "live.at",
+ "live.be",
+ "live.ca",
+ "live.cl",
+ "live.cn",
+ "live.co.uk",
+ "live.co.za",
+ "live.com",
+ "live.com.ar",
+ "live.com.au",
+ "live.com.mx",
+ "live.com.pt",
+ "live.com.sg",
+ "live.de",
+ "live.dk",
+ "live.fr",
+ "live.ie",
+ "live.in",
+ "live.it",
+ "live.jp",
+ "live.nl",
+ "live.no",
+ "live.ru",
+ "live.se",
+ "liveradio.tk",
+ "liverpoolfans.com",
+ "llandudno.com",
+ "llangollen.com",
+ "lmxmail.sk",
+ "lobbyist.com",
+ "localbar.com",
+ "locos.com",
+ "login-email.ga",
+ "loh.pp.ua",
+ "lolfreak.net",
+ "lolito.tk",
+ "london.com",
+ "looksmart.co.uk",
+ "looksmart.com",
+ "looksmart.com.au",
+ "lopezclub.com",
+ "louiskoo.com",
+ "love.cz",
+ "loveable.com",
+ "lovecat.com",
+ "lovefall.ml",
+ "lovefootball.com",
+ "lovelygirl.net",
+ "lovemail.com",
+ "lover-boy.com",
+ "lovergirl.com",
+ "lovesea.gq",
+ "lovethebroncos.com",
+ "lovethecowboys.com",
+ "loveyouforever.de",
+ "lovingjesus.com",
+ "lowandslow.com",
+ "lr7.us",
+ "lroid.com",
+ "luso.pt",
+ "luukku.com",
+ "luv2.us",
+ "lvie.com.sg",
+ "lycos.co.uk",
+ "lycos.com",
+ "lycos.es",
+ "lycos.it",
+ "lycos.ne.jp",
+ "lycosmail.com",
+ "m-a-i-l.com",
+ "m-hmail.com",
+ "m4.org",
+ "m4ilweb.info",
+ "mac.com",
+ "macbox.com",
+ "macfreak.com",
+ "machinecandy.com",
+ "macmail.com",
+ "madcreations.com",
+ "madonnafan.com",
+ "madrid.com",
+ "maennerversteherin.com",
+ "maennerversteherin.de",
+ "maffia.hu",
+ "magicmail.co.za",
+ "magspam.net",
+ "mahmoodweb.com",
+ "mail-awu.de",
+ "mail-box.cz",
+ "mail-center.com",
+ "mail-central.com",
+ "mail-easy.fr",
+ "mail-filter.com",
+ "mail-me.com",
+ "mail-page.com",
+ "mail-tester.com",
+ "mail.austria.com",
+ "mail.az",
+ "mail.be",
+ "mail.bg",
+ "mail.bulgaria.com",
+ "mail.by",
+ "mail.co.za",
+ "mail.com",
+ "mail.com.tr",
+ "mail.de",
+ "mail.ee",
+ "mail.entrepeneurmag.com",
+ "mail.freetown.com",
+ "mail.gr",
+ "mail.hitthebeach.com",
+ "mail.htl22.at",
+ "mail.md",
+ "mail.misterpinball.de",
+ "mail.nu",
+ "mail.org.uk",
+ "mail.pf",
+ "mail.pt",
+ "mail.r-o-o-t.com",
+ "mail.ru",
+ "mail.sisna.com",
+ "mail.svenz.eu",
+ "mail.tm",
+ "mail.usa.com",
+ "mail.vasarhely.hu",
+ "mail.wtf",
+ "mail114.net",
+ "mail15.com",
+ "mail2007.com",
+ "mail2aaron.com",
+ "mail2abby.com",
+ "mail2abc.com",
+ "mail2actor.com",
+ "mail2admiral.com",
+ "mail2adorable.com",
+ "mail2adoration.com",
+ "mail2adore.com",
+ "mail2adventure.com",
+ "mail2aeolus.com",
+ "mail2aether.com",
+ "mail2affection.com",
+ "mail2afghanistan.com",
+ "mail2africa.com",
+ "mail2agent.com",
+ "mail2aha.com",
+ "mail2ahoy.com",
+ "mail2aim.com",
+ "mail2air.com",
+ "mail2airbag.com",
+ "mail2airforce.com",
+ "mail2airport.com",
+ "mail2alabama.com",
+ "mail2alan.com",
+ "mail2alaska.com",
+ "mail2albania.com",
+ "mail2alcoholic.com",
+ "mail2alec.com",
+ "mail2alexa.com",
+ "mail2algeria.com",
+ "mail2alicia.com",
+ "mail2alien.com",
+ "mail2allan.com",
+ "mail2allen.com",
+ "mail2allison.com",
+ "mail2alpha.com",
+ "mail2alyssa.com",
+ "mail2amanda.com",
+ "mail2amazing.com",
+ "mail2amber.com",
+ "mail2america.com",
+ "mail2american.com",
+ "mail2andorra.com",
+ "mail2andrea.com",
+ "mail2andy.com",
+ "mail2anesthesiologist.com",
+ "mail2angela.com",
+ "mail2angola.com",
+ "mail2ann.com",
+ "mail2anna.com",
+ "mail2anne.com",
+ "mail2anthony.com",
+ "mail2anything.com",
+ "mail2aphrodite.com",
+ "mail2apollo.com",
+ "mail2april.com",
+ "mail2aquarius.com",
+ "mail2arabia.com",
+ "mail2arabic.com",
+ "mail2architect.com",
+ "mail2ares.com",
+ "mail2argentina.com",
+ "mail2aries.com",
+ "mail2arizona.com",
+ "mail2arkansas.com",
+ "mail2armenia.com",
+ "mail2army.com",
+ "mail2arnold.com",
+ "mail2art.com",
+ "mail2artemus.com",
+ "mail2arthur.com",
+ "mail2artist.com",
+ "mail2ashley.com",
+ "mail2ask.com",
+ "mail2astronomer.com",
+ "mail2athena.com",
+ "mail2athlete.com",
+ "mail2atlas.com",
+ "mail2atom.com",
+ "mail2attitude.com",
+ "mail2auction.com",
+ "mail2aunt.com",
+ "mail2australia.com",
+ "mail2austria.com",
+ "mail2azerbaijan.com",
+ "mail2baby.com",
+ "mail2bahamas.com",
+ "mail2bahrain.com",
+ "mail2ballerina.com",
+ "mail2ballplayer.com",
+ "mail2band.com",
+ "mail2bangladesh.com",
+ "mail2bank.com",
+ "mail2banker.com",
+ "mail2bankrupt.com",
+ "mail2baptist.com",
+ "mail2bar.com",
+ "mail2barbados.com",
+ "mail2barbara.com",
+ "mail2barter.com",
+ "mail2basketball.com",
+ "mail2batter.com",
+ "mail2beach.com",
+ "mail2beast.com",
+ "mail2beatles.com",
+ "mail2beauty.com",
+ "mail2becky.com",
+ "mail2beijing.com",
+ "mail2belgium.com",
+ "mail2belize.com",
+ "mail2ben.com",
+ "mail2bernard.com",
+ "mail2beth.com",
+ "mail2betty.com",
+ "mail2beverly.com",
+ "mail2beyond.com",
+ "mail2biker.com",
+ "mail2bill.com",
+ "mail2billionaire.com",
+ "mail2billy.com",
+ "mail2bio.com",
+ "mail2biologist.com",
+ "mail2black.com",
+ "mail2blackbelt.com",
+ "mail2blake.com",
+ "mail2blind.com",
+ "mail2blonde.com",
+ "mail2blues.com",
+ "mail2bob.com",
+ "mail2bobby.com",
+ "mail2bolivia.com",
+ "mail2bombay.com",
+ "mail2bonn.com",
+ "mail2bookmark.com",
+ "mail2boreas.com",
+ "mail2bosnia.com",
+ "mail2boston.com",
+ "mail2botswana.com",
+ "mail2bradley.com",
+ "mail2brazil.com",
+ "mail2breakfast.com",
+ "mail2brian.com",
+ "mail2bride.com",
+ "mail2brittany.com",
+ "mail2broker.com",
+ "mail2brook.com",
+ "mail2bruce.com",
+ "mail2brunei.com",
+ "mail2brunette.com",
+ "mail2brussels.com",
+ "mail2bryan.com",
+ "mail2bug.com",
+ "mail2bulgaria.com",
+ "mail2business.com",
+ "mail2buy.com",
+ "mail2ca.com",
+ "mail2california.com",
+ "mail2calvin.com",
+ "mail2cambodia.com",
+ "mail2cameroon.com",
+ "mail2canada.com",
+ "mail2cancer.com",
+ "mail2capeverde.com",
+ "mail2capricorn.com",
+ "mail2cardinal.com",
+ "mail2cardiologist.com",
+ "mail2care.com",
+ "mail2caroline.com",
+ "mail2carolyn.com",
+ "mail2casey.com",
+ "mail2cat.com",
+ "mail2caterer.com",
+ "mail2cathy.com",
+ "mail2catlover.com",
+ "mail2catwalk.com",
+ "mail2cell.com",
+ "mail2chad.com",
+ "mail2champaign.com",
+ "mail2charles.com",
+ "mail2chef.com",
+ "mail2chemist.com",
+ "mail2cherry.com",
+ "mail2chicago.com",
+ "mail2chile.com",
+ "mail2china.com",
+ "mail2chinese.com",
+ "mail2chocolate.com",
+ "mail2christian.com",
+ "mail2christie.com",
+ "mail2christmas.com",
+ "mail2christy.com",
+ "mail2chuck.com",
+ "mail2cindy.com",
+ "mail2clark.com",
+ "mail2classifieds.com",
+ "mail2claude.com",
+ "mail2cliff.com",
+ "mail2clinic.com",
+ "mail2clint.com",
+ "mail2close.com",
+ "mail2club.com",
+ "mail2coach.com",
+ "mail2coastguard.com",
+ "mail2colin.com",
+ "mail2college.com",
+ "mail2colombia.com",
+ "mail2color.com",
+ "mail2colorado.com",
+ "mail2columbia.com",
+ "mail2comedian.com",
+ "mail2composer.com",
+ "mail2computer.com",
+ "mail2computers.com",
+ "mail2concert.com",
+ "mail2congo.com",
+ "mail2connect.com",
+ "mail2connecticut.com",
+ "mail2consultant.com",
+ "mail2convict.com",
+ "mail2cook.com",
+ "mail2cool.com",
+ "mail2cory.com",
+ "mail2costarica.com",
+ "mail2country.com",
+ "mail2courtney.com",
+ "mail2cowboy.com",
+ "mail2cowgirl.com",
+ "mail2craig.com",
+ "mail2crave.com",
+ "mail2crazy.com",
+ "mail2create.com",
+ "mail2croatia.com",
+ "mail2cry.com",
+ "mail2crystal.com",
+ "mail2cuba.com",
+ "mail2culture.com",
+ "mail2curt.com",
+ "mail2customs.com",
+ "mail2cute.com",
+ "mail2cutey.com",
+ "mail2cynthia.com",
+ "mail2cyprus.com",
+ "mail2czechrepublic.com",
+ "mail2dad.com",
+ "mail2dale.com",
+ "mail2dallas.com",
+ "mail2dan.com",
+ "mail2dana.com",
+ "mail2dance.com",
+ "mail2dancer.com",
+ "mail2danielle.com",
+ "mail2danny.com",
+ "mail2darlene.com",
+ "mail2darling.com",
+ "mail2darren.com",
+ "mail2daughter.com",
+ "mail2dave.com",
+ "mail2dawn.com",
+ "mail2dc.com",
+ "mail2dealer.com",
+ "mail2deanna.com",
+ "mail2dearest.com",
+ "mail2debbie.com",
+ "mail2debby.com",
+ "mail2deer.com",
+ "mail2delaware.com",
+ "mail2delicious.com",
+ "mail2demeter.com",
+ "mail2democrat.com",
+ "mail2denise.com",
+ "mail2denmark.com",
+ "mail2dennis.com",
+ "mail2dentist.com",
+ "mail2derek.com",
+ "mail2desert.com",
+ "mail2devoted.com",
+ "mail2devotion.com",
+ "mail2diamond.com",
+ "mail2diana.com",
+ "mail2diane.com",
+ "mail2diehard.com",
+ "mail2dilemma.com",
+ "mail2dillon.com",
+ "mail2dinner.com",
+ "mail2dinosaur.com",
+ "mail2dionysos.com",
+ "mail2diplomat.com",
+ "mail2director.com",
+ "mail2dirk.com",
+ "mail2disco.com",
+ "mail2dive.com",
+ "mail2diver.com",
+ "mail2divorced.com",
+ "mail2djibouti.com",
+ "mail2doctor.com",
+ "mail2doglover.com",
+ "mail2dominic.com",
+ "mail2dominica.com",
+ "mail2dominicanrepublic.com",
+ "mail2don.com",
+ "mail2donald.com",
+ "mail2donna.com",
+ "mail2doris.com",
+ "mail2dorothy.com",
+ "mail2doug.com",
+ "mail2dough.com",
+ "mail2douglas.com",
+ "mail2dow.com",
+ "mail2downtown.com",
+ "mail2dream.com",
+ "mail2dreamer.com",
+ "mail2dude.com",
+ "mail2dustin.com",
+ "mail2dyke.com",
+ "mail2dylan.com",
+ "mail2earl.com",
+ "mail2earth.com",
+ "mail2eastend.com",
+ "mail2eat.com",
+ "mail2economist.com",
+ "mail2ecuador.com",
+ "mail2eddie.com",
+ "mail2edgar.com",
+ "mail2edwin.com",
+ "mail2egypt.com",
+ "mail2electron.com",
+ "mail2eli.com",
+ "mail2elizabeth.com",
+ "mail2ellen.com",
+ "mail2elliot.com",
+ "mail2elsalvador.com",
+ "mail2elvis.com",
+ "mail2emergency.com",
+ "mail2emily.com",
+ "mail2engineer.com",
+ "mail2english.com",
+ "mail2environmentalist.com",
+ "mail2eos.com",
+ "mail2eric.com",
+ "mail2erica.com",
+ "mail2erin.com",
+ "mail2erinyes.com",
+ "mail2eris.com",
+ "mail2eritrea.com",
+ "mail2ernie.com",
+ "mail2eros.com",
+ "mail2estonia.com",
+ "mail2ethan.com",
+ "mail2ethiopia.com",
+ "mail2eu.com",
+ "mail2europe.com",
+ "mail2eurus.com",
+ "mail2eva.com",
+ "mail2evan.com",
+ "mail2evelyn.com",
+ "mail2everything.com",
+ "mail2exciting.com",
+ "mail2expert.com",
+ "mail2fairy.com",
+ "mail2faith.com",
+ "mail2fanatic.com",
+ "mail2fancy.com",
+ "mail2fantasy.com",
+ "mail2farm.com",
+ "mail2farmer.com",
+ "mail2fashion.com",
+ "mail2fat.com",
+ "mail2feeling.com",
+ "mail2female.com",
+ "mail2fever.com",
+ "mail2fighter.com",
+ "mail2fiji.com",
+ "mail2filmfestival.com",
+ "mail2films.com",
+ "mail2finance.com",
+ "mail2finland.com",
+ "mail2fireman.com",
+ "mail2firm.com",
+ "mail2fisherman.com",
+ "mail2flexible.com",
+ "mail2florence.com",
+ "mail2florida.com",
+ "mail2floyd.com",
+ "mail2fly.com",
+ "mail2fond.com",
+ "mail2fondness.com",
+ "mail2football.com",
+ "mail2footballfan.com",
+ "mail2found.com",
+ "mail2france.com",
+ "mail2frank.com",
+ "mail2frankfurt.com",
+ "mail2franklin.com",
+ "mail2fred.com",
+ "mail2freddie.com",
+ "mail2free.com",
+ "mail2freedom.com",
+ "mail2french.com",
+ "mail2freudian.com",
+ "mail2friendship.com",
+ "mail2from.com",
+ "mail2fun.com",
+ "mail2gabon.com",
+ "mail2gabriel.com",
+ "mail2gail.com",
+ "mail2galaxy.com",
+ "mail2gambia.com",
+ "mail2games.com",
+ "mail2gary.com",
+ "mail2gavin.com",
+ "mail2gemini.com",
+ "mail2gene.com",
+ "mail2genes.com",
+ "mail2geneva.com",
+ "mail2george.com",
+ "mail2georgia.com",
+ "mail2gerald.com",
+ "mail2german.com",
+ "mail2germany.com",
+ "mail2ghana.com",
+ "mail2gilbert.com",
+ "mail2gina.com",
+ "mail2girl.com",
+ "mail2glen.com",
+ "mail2gloria.com",
+ "mail2goddess.com",
+ "mail2gold.com",
+ "mail2golfclub.com",
+ "mail2golfer.com",
+ "mail2gordon.com",
+ "mail2government.com",
+ "mail2grab.com",
+ "mail2grace.com",
+ "mail2graham.com",
+ "mail2grandma.com",
+ "mail2grandpa.com",
+ "mail2grant.com",
+ "mail2greece.com",
+ "mail2green.com",
+ "mail2greg.com",
+ "mail2grenada.com",
+ "mail2gsm.com",
+ "mail2guard.com",
+ "mail2guatemala.com",
+ "mail2guy.com",
+ "mail2hades.com",
+ "mail2haiti.com",
+ "mail2hal.com",
+ "mail2handhelds.com",
+ "mail2hank.com",
+ "mail2hannah.com",
+ "mail2harold.com",
+ "mail2harry.com",
+ "mail2hawaii.com",
+ "mail2headhunter.com",
+ "mail2heal.com",
+ "mail2heather.com",
+ "mail2heaven.com",
+ "mail2hebe.com",
+ "mail2hecate.com",
+ "mail2heidi.com",
+ "mail2helen.com",
+ "mail2hell.com",
+ "mail2help.com",
+ "mail2helpdesk.com",
+ "mail2henry.com",
+ "mail2hephaestus.com",
+ "mail2hera.com",
+ "mail2hercules.com",
+ "mail2herman.com",
+ "mail2hermes.com",
+ "mail2hespera.com",
+ "mail2hestia.com",
+ "mail2highschool.com",
+ "mail2hindu.com",
+ "mail2hip.com",
+ "mail2hiphop.com",
+ "mail2holland.com",
+ "mail2holly.com",
+ "mail2hollywood.com",
+ "mail2homer.com",
+ "mail2honduras.com",
+ "mail2honey.com",
+ "mail2hongkong.com",
+ "mail2hope.com",
+ "mail2horse.com",
+ "mail2hot.com",
+ "mail2hotel.com",
+ "mail2houston.com",
+ "mail2howard.com",
+ "mail2hugh.com",
+ "mail2human.com",
+ "mail2hungary.com",
+ "mail2hungry.com",
+ "mail2hygeia.com",
+ "mail2hyperspace.com",
+ "mail2hypnos.com",
+ "mail2ian.com",
+ "mail2ice-cream.com",
+ "mail2iceland.com",
+ "mail2idaho.com",
+ "mail2idontknow.com",
+ "mail2illinois.com",
+ "mail2imam.com",
+ "mail2in.com",
+ "mail2india.com",
+ "mail2indian.com",
+ "mail2indiana.com",
+ "mail2indonesia.com",
+ "mail2infinity.com",
+ "mail2intense.com",
+ "mail2iowa.com",
+ "mail2iran.com",
+ "mail2iraq.com",
+ "mail2ireland.com",
+ "mail2irene.com",
+ "mail2iris.com",
+ "mail2irresistible.com",
+ "mail2irving.com",
+ "mail2irwin.com",
+ "mail2isaac.com",
+ "mail2israel.com",
+ "mail2italian.com",
+ "mail2italy.com",
+ "mail2jackie.com",
+ "mail2jacob.com",
+ "mail2jail.com",
+ "mail2jaime.com",
+ "mail2jake.com",
+ "mail2jamaica.com",
+ "mail2james.com",
+ "mail2jamie.com",
+ "mail2jan.com",
+ "mail2jane.com",
+ "mail2janet.com",
+ "mail2janice.com",
+ "mail2japan.com",
+ "mail2japanese.com",
+ "mail2jasmine.com",
+ "mail2jason.com",
+ "mail2java.com",
+ "mail2jay.com",
+ "mail2jazz.com",
+ "mail2jed.com",
+ "mail2jeffrey.com",
+ "mail2jennifer.com",
+ "mail2jenny.com",
+ "mail2jeremy.com",
+ "mail2jerry.com",
+ "mail2jessica.com",
+ "mail2jessie.com",
+ "mail2jesus.com",
+ "mail2jew.com",
+ "mail2jeweler.com",
+ "mail2jim.com",
+ "mail2jimmy.com",
+ "mail2joan.com",
+ "mail2joann.com",
+ "mail2joanna.com",
+ "mail2jody.com",
+ "mail2joe.com",
+ "mail2joel.com",
+ "mail2joey.com",
+ "mail2john.com",
+ "mail2join.com",
+ "mail2jon.com",
+ "mail2jonathan.com",
+ "mail2jones.com",
+ "mail2jordan.com",
+ "mail2joseph.com",
+ "mail2josh.com",
+ "mail2joy.com",
+ "mail2juan.com",
+ "mail2judge.com",
+ "mail2judy.com",
+ "mail2juggler.com",
+ "mail2julian.com",
+ "mail2julie.com",
+ "mail2jumbo.com",
+ "mail2junk.com",
+ "mail2justin.com",
+ "mail2justme.com",
+ "mail2k.ru",
+ "mail2kansas.com",
+ "mail2karate.com",
+ "mail2karen.com",
+ "mail2karl.com",
+ "mail2karma.com",
+ "mail2kathleen.com",
+ "mail2kathy.com",
+ "mail2katie.com",
+ "mail2kay.com",
+ "mail2kazakhstan.com",
+ "mail2keen.com",
+ "mail2keith.com",
+ "mail2kelly.com",
+ "mail2kelsey.com",
+ "mail2ken.com",
+ "mail2kendall.com",
+ "mail2kennedy.com",
+ "mail2kenneth.com",
+ "mail2kenny.com",
+ "mail2kentucky.com",
+ "mail2kenya.com",
+ "mail2kerry.com",
+ "mail2kevin.com",
+ "mail2kim.com",
+ "mail2kimberly.com",
+ "mail2king.com",
+ "mail2kirk.com",
+ "mail2kiss.com",
+ "mail2kosher.com",
+ "mail2kristin.com",
+ "mail2kurt.com",
+ "mail2kuwait.com",
+ "mail2kyle.com",
+ "mail2kyrgyzstan.com",
+ "mail2la.com",
+ "mail2lacrosse.com",
+ "mail2lance.com",
+ "mail2lao.com",
+ "mail2larry.com",
+ "mail2latvia.com",
+ "mail2laugh.com",
+ "mail2laura.com",
+ "mail2lauren.com",
+ "mail2laurie.com",
+ "mail2lawrence.com",
+ "mail2lawyer.com",
+ "mail2lebanon.com",
+ "mail2lee.com",
+ "mail2leo.com",
+ "mail2leon.com",
+ "mail2leonard.com",
+ "mail2leone.com",
+ "mail2leslie.com",
+ "mail2letter.com",
+ "mail2liberia.com",
+ "mail2libertarian.com",
+ "mail2libra.com",
+ "mail2libya.com",
+ "mail2liechtenstein.com",
+ "mail2life.com",
+ "mail2linda.com",
+ "mail2linux.com",
+ "mail2lionel.com",
+ "mail2lipstick.com",
+ "mail2liquid.com",
+ "mail2lisa.com",
+ "mail2lithuania.com",
+ "mail2litigator.com",
+ "mail2liz.com",
+ "mail2lloyd.com",
+ "mail2lois.com",
+ "mail2lola.com",
+ "mail2london.com",
+ "mail2looking.com",
+ "mail2lori.com",
+ "mail2lost.com",
+ "mail2lou.com",
+ "mail2louis.com",
+ "mail2louisiana.com",
+ "mail2lovable.com",
+ "mail2love.com",
+ "mail2lucky.com",
+ "mail2lucy.com",
+ "mail2lunch.com",
+ "mail2lust.com",
+ "mail2luxembourg.com",
+ "mail2luxury.com",
+ "mail2lyle.com",
+ "mail2lynn.com",
+ "mail2madagascar.com",
+ "mail2madison.com",
+ "mail2madrid.com",
+ "mail2maggie.com",
+ "mail2mail4.com",
+ "mail2maine.com",
+ "mail2malawi.com",
+ "mail2malaysia.com",
+ "mail2maldives.com",
+ "mail2mali.com",
+ "mail2malta.com",
+ "mail2mambo.com",
+ "mail2man.com",
+ "mail2mandy.com",
+ "mail2manhunter.com",
+ "mail2mankind.com",
+ "mail2many.com",
+ "mail2marc.com",
+ "mail2marcia.com",
+ "mail2margaret.com",
+ "mail2margie.com",
+ "mail2marhaba.com",
+ "mail2maria.com",
+ "mail2marilyn.com",
+ "mail2marines.com",
+ "mail2mark.com",
+ "mail2marriage.com",
+ "mail2married.com",
+ "mail2marries.com",
+ "mail2mars.com",
+ "mail2marsha.com",
+ "mail2marshallislands.com",
+ "mail2martha.com",
+ "mail2martin.com",
+ "mail2marty.com",
+ "mail2marvin.com",
+ "mail2mary.com",
+ "mail2maryland.com",
+ "mail2mason.com",
+ "mail2massachusetts.com",
+ "mail2matt.com",
+ "mail2matthew.com",
+ "mail2maurice.com",
+ "mail2mauritania.com",
+ "mail2mauritius.com",
+ "mail2max.com",
+ "mail2maxwell.com",
+ "mail2maybe.com",
+ "mail2mba.com",
+ "mail2me4u.com",
+ "mail2mechanic.com",
+ "mail2medieval.com",
+ "mail2megan.com",
+ "mail2mel.com",
+ "mail2melanie.com",
+ "mail2melissa.com",
+ "mail2melody.com",
+ "mail2member.com",
+ "mail2memphis.com",
+ "mail2methodist.com",
+ "mail2mexican.com",
+ "mail2mexico.com",
+ "mail2mgz.com",
+ "mail2miami.com",
+ "mail2michael.com",
+ "mail2michelle.com",
+ "mail2michigan.com",
+ "mail2mike.com",
+ "mail2milan.com",
+ "mail2milano.com",
+ "mail2mildred.com",
+ "mail2milkyway.com",
+ "mail2millennium.com",
+ "mail2millionaire.com",
+ "mail2milton.com",
+ "mail2mime.com",
+ "mail2mindreader.com",
+ "mail2mini.com",
+ "mail2minister.com",
+ "mail2minneapolis.com",
+ "mail2minnesota.com",
+ "mail2miracle.com",
+ "mail2missionary.com",
+ "mail2mississippi.com",
+ "mail2missouri.com",
+ "mail2mitch.com",
+ "mail2model.com",
+ "mail2moldova.commail2molly.com",
+ "mail2mom.com",
+ "mail2monaco.com",
+ "mail2money.com",
+ "mail2mongolia.com",
+ "mail2monica.com",
+ "mail2montana.com",
+ "mail2monty.com",
+ "mail2moon.com",
+ "mail2morocco.com",
+ "mail2morpheus.com",
+ "mail2mors.com",
+ "mail2moscow.com",
+ "mail2moslem.com",
+ "mail2mouseketeer.com",
+ "mail2movies.com",
+ "mail2mozambique.com",
+ "mail2mp3.com",
+ "mail2mrright.com",
+ "mail2msright.com",
+ "mail2museum.com",
+ "mail2music.com",
+ "mail2musician.com",
+ "mail2muslim.com",
+ "mail2my.com",
+ "mail2myboat.com",
+ "mail2mycar.com",
+ "mail2mycell.com",
+ "mail2mygsm.com",
+ "mail2mylaptop.com",
+ "mail2mymac.com",
+ "mail2mypager.com",
+ "mail2mypalm.com",
+ "mail2mypc.com",
+ "mail2myphone.com",
+ "mail2myplane.com",
+ "mail2namibia.com",
+ "mail2nancy.com",
+ "mail2nasdaq.com",
+ "mail2nathan.com",
+ "mail2nauru.com",
+ "mail2navy.com",
+ "mail2neal.com",
+ "mail2nebraska.com",
+ "mail2ned.com",
+ "mail2neil.com",
+ "mail2nelson.com",
+ "mail2nemesis.com",
+ "mail2nepal.com",
+ "mail2netherlands.com",
+ "mail2network.com",
+ "mail2nevada.com",
+ "mail2newhampshire.com",
+ "mail2newjersey.com",
+ "mail2newmexico.com",
+ "mail2newyork.com",
+ "mail2newzealand.com",
+ "mail2nicaragua.com",
+ "mail2nick.com",
+ "mail2nicole.com",
+ "mail2niger.com",
+ "mail2nigeria.com",
+ "mail2nike.com",
+ "mail2no.com",
+ "mail2noah.com",
+ "mail2noel.com",
+ "mail2noelle.com",
+ "mail2normal.com",
+ "mail2norman.com",
+ "mail2northamerica.com",
+ "mail2northcarolina.com",
+ "mail2northdakota.com",
+ "mail2northpole.com",
+ "mail2norway.com",
+ "mail2notus.com",
+ "mail2noway.com",
+ "mail2nowhere.com",
+ "mail2nuclear.com",
+ "mail2nun.com",
+ "mail2ny.com",
+ "mail2oasis.com",
+ "mail2oceanographer.com",
+ "mail2ohio.com",
+ "mail2ok.com",
+ "mail2oklahoma.com",
+ "mail2oliver.com",
+ "mail2oman.com",
+ "mail2one.com",
+ "mail2onfire.com",
+ "mail2online.com",
+ "mail2oops.com",
+ "mail2open.com",
+ "mail2ophthalmologist.com",
+ "mail2optometrist.com",
+ "mail2oregon.com",
+ "mail2oscars.com",
+ "mail2oslo.com",
+ "mail2painter.com",
+ "mail2pakistan.com",
+ "mail2palau.com",
+ "mail2pan.com",
+ "mail2panama.com",
+ "mail2paraguay.com",
+ "mail2paralegal.com",
+ "mail2paris.com",
+ "mail2park.com",
+ "mail2parker.com",
+ "mail2party.com",
+ "mail2passion.com",
+ "mail2pat.com",
+ "mail2patricia.com",
+ "mail2patrick.com",
+ "mail2patty.com",
+ "mail2paul.com",
+ "mail2paula.com",
+ "mail2pay.com",
+ "mail2peace.com",
+ "mail2pediatrician.com",
+ "mail2peggy.com",
+ "mail2pennsylvania.com",
+ "mail2perry.com",
+ "mail2persephone.com",
+ "mail2persian.com",
+ "mail2peru.com",
+ "mail2pete.com",
+ "mail2peter.com",
+ "mail2pharmacist.com",
+ "mail2phil.com",
+ "mail2philippines.com",
+ "mail2phoenix.com",
+ "mail2phonecall.com",
+ "mail2phyllis.com",
+ "mail2pickup.com",
+ "mail2pilot.com",
+ "mail2pisces.com",
+ "mail2planet.com",
+ "mail2platinum.com",
+ "mail2plato.com",
+ "mail2pluto.com",
+ "mail2pm.com",
+ "mail2podiatrist.com",
+ "mail2poet.com",
+ "mail2poland.com",
+ "mail2policeman.com",
+ "mail2policewoman.com",
+ "mail2politician.com",
+ "mail2pop.com",
+ "mail2pope.com",
+ "mail2popular.com",
+ "mail2portugal.com",
+ "mail2poseidon.com",
+ "mail2potatohead.com",
+ "mail2power.com",
+ "mail2presbyterian.com",
+ "mail2president.com",
+ "mail2priest.com",
+ "mail2prince.com",
+ "mail2princess.com",
+ "mail2producer.com",
+ "mail2professor.com",
+ "mail2protect.com",
+ "mail2psychiatrist.com",
+ "mail2psycho.com",
+ "mail2psychologist.com",
+ "mail2qatar.com",
+ "mail2queen.com",
+ "mail2rabbi.com",
+ "mail2race.com",
+ "mail2racer.com",
+ "mail2rachel.com",
+ "mail2rage.com",
+ "mail2rainmaker.com",
+ "mail2ralph.com",
+ "mail2randy.com",
+ "mail2rap.com",
+ "mail2rare.com",
+ "mail2rave.com",
+ "mail2ray.com",
+ "mail2raymond.com",
+ "mail2realtor.com",
+ "mail2rebecca.com",
+ "mail2recruiter.com",
+ "mail2recycle.com",
+ "mail2redhead.com",
+ "mail2reed.com",
+ "mail2reggie.com",
+ "mail2register.com",
+ "mail2rent.com",
+ "mail2republican.com",
+ "mail2resort.com",
+ "mail2rex.com",
+ "mail2rhodeisland.com",
+ "mail2rich.com",
+ "mail2richard.com",
+ "mail2ricky.com",
+ "mail2ride.com",
+ "mail2riley.com",
+ "mail2rita.com",
+ "mail2rob.com",
+ "mail2robert.com",
+ "mail2roberta.com",
+ "mail2robin.com",
+ "mail2rock.com",
+ "mail2rocker.com",
+ "mail2rod.com",
+ "mail2rodney.com",
+ "mail2romania.com",
+ "mail2rome.com",
+ "mail2ron.com",
+ "mail2ronald.com",
+ "mail2ronnie.com",
+ "mail2rose.com",
+ "mail2rosie.com",
+ "mail2roy.com",
+ "mail2rss.org",
+ "mail2rudy.com",
+ "mail2rugby.com",
+ "mail2runner.com",
+ "mail2russell.com",
+ "mail2russia.com",
+ "mail2russian.com",
+ "mail2rusty.com",
+ "mail2ruth.com",
+ "mail2rwanda.com",
+ "mail2ryan.com",
+ "mail2sa.com",
+ "mail2sabrina.com",
+ "mail2safe.com",
+ "mail2sagittarius.com",
+ "mail2sail.com",
+ "mail2sailor.com",
+ "mail2sal.com",
+ "mail2salaam.com",
+ "mail2sam.com",
+ "mail2samantha.com",
+ "mail2samoa.com",
+ "mail2samurai.com",
+ "mail2sandra.com",
+ "mail2sandy.com",
+ "mail2sanfrancisco.com",
+ "mail2sanmarino.com",
+ "mail2santa.com",
+ "mail2sara.com",
+ "mail2sarah.com",
+ "mail2sat.com",
+ "mail2saturn.com",
+ "mail2saudi.com",
+ "mail2saudiarabia.com",
+ "mail2save.com",
+ "mail2savings.com",
+ "mail2school.com",
+ "mail2scientist.com",
+ "mail2scorpio.com",
+ "mail2scott.com",
+ "mail2sean.com",
+ "mail2search.com",
+ "mail2seattle.com",
+ "mail2secretagent.com",
+ "mail2senate.com",
+ "mail2senegal.com",
+ "mail2sensual.com",
+ "mail2seth.com",
+ "mail2sevenseas.com",
+ "mail2sexy.com",
+ "mail2seychelles.com",
+ "mail2shane.com",
+ "mail2sharon.com",
+ "mail2shawn.com",
+ "mail2ship.com",
+ "mail2shirley.com",
+ "mail2shoot.com",
+ "mail2shuttle.com",
+ "mail2sierraleone.com",
+ "mail2simon.com",
+ "mail2singapore.com",
+ "mail2single.com",
+ "mail2site.com",
+ "mail2skater.com",
+ "mail2skier.com",
+ "mail2sky.com",
+ "mail2sleek.com",
+ "mail2slim.com",
+ "mail2slovakia.com",
+ "mail2slovenia.com",
+ "mail2smile.com",
+ "mail2smith.com",
+ "mail2smooth.com",
+ "mail2soccer.com",
+ "mail2soccerfan.com",
+ "mail2socialist.com",
+ "mail2soldier.com",
+ "mail2somalia.com",
+ "mail2son.com",
+ "mail2song.com",
+ "mail2sos.com",
+ "mail2sound.com",
+ "mail2southafrica.com",
+ "mail2southamerica.com",
+ "mail2southcarolina.com",
+ "mail2southdakota.com",
+ "mail2southkorea.com",
+ "mail2southpole.com",
+ "mail2spain.com",
+ "mail2spanish.com",
+ "mail2spare.com",
+ "mail2spectrum.com",
+ "mail2splash.com",
+ "mail2sponsor.com",
+ "mail2sports.com",
+ "mail2srilanka.com",
+ "mail2stacy.com",
+ "mail2stan.com",
+ "mail2stanley.com",
+ "mail2star.com",
+ "mail2state.com",
+ "mail2stephanie.com",
+ "mail2steve.com",
+ "mail2steven.com",
+ "mail2stewart.com",
+ "mail2stlouis.com",
+ "mail2stock.com",
+ "mail2stockholm.com",
+ "mail2stockmarket.com",
+ "mail2storage.com",
+ "mail2store.com",
+ "mail2strong.com",
+ "mail2student.com",
+ "mail2studio.com",
+ "mail2studio54.com",
+ "mail2stuntman.com",
+ "mail2subscribe.com",
+ "mail2sudan.com",
+ "mail2superstar.com",
+ "mail2surfer.com",
+ "mail2suriname.com",
+ "mail2susan.com",
+ "mail2suzie.com",
+ "mail2swaziland.com",
+ "mail2sweden.com",
+ "mail2sweetheart.com",
+ "mail2swim.com",
+ "mail2swimmer.com",
+ "mail2swiss.com",
+ "mail2switzerland.com",
+ "mail2sydney.com",
+ "mail2sylvia.com",
+ "mail2syria.com",
+ "mail2taboo.com",
+ "mail2taiwan.com",
+ "mail2tajikistan.com",
+ "mail2tammy.com",
+ "mail2tango.com",
+ "mail2tanya.com",
+ "mail2tanzania.com",
+ "mail2tara.com",
+ "mail2taurus.com",
+ "mail2taxi.com",
+ "mail2taxidermist.com",
+ "mail2taylor.com",
+ "mail2taz.com",
+ "mail2teacher.com",
+ "mail2technician.com",
+ "mail2ted.com",
+ "mail2telephone.com",
+ "mail2teletubbie.com",
+ "mail2tenderness.com",
+ "mail2tennessee.com",
+ "mail2tennis.com",
+ "mail2tennisfan.com",
+ "mail2terri.com",
+ "mail2terry.com",
+ "mail2test.com",
+ "mail2texas.com",
+ "mail2thailand.com",
+ "mail2therapy.com",
+ "mail2think.com",
+ "mail2tickets.com",
+ "mail2tiffany.com",
+ "mail2tim.com",
+ "mail2time.com",
+ "mail2timothy.com",
+ "mail2tina.com",
+ "mail2titanic.com",
+ "mail2toby.com",
+ "mail2todd.com",
+ "mail2togo.com",
+ "mail2tom.com",
+ "mail2tommy.com",
+ "mail2tonga.com",
+ "mail2tony.com",
+ "mail2touch.com",
+ "mail2tourist.com",
+ "mail2tracey.com",
+ "mail2tracy.com",
+ "mail2tramp.com",
+ "mail2travel.com",
+ "mail2traveler.com",
+ "mail2travis.com",
+ "mail2trekkie.com",
+ "mail2trex.com",
+ "mail2triallawyer.com",
+ "mail2trick.com",
+ "mail2trillionaire.com",
+ "mail2troy.com",
+ "mail2truck.com",
+ "mail2trump.com",
+ "mail2try.com",
+ "mail2tunisia.com",
+ "mail2turbo.com",
+ "mail2turkey.com",
+ "mail2turkmenistan.com",
+ "mail2tv.com",
+ "mail2tycoon.com",
+ "mail2tyler.com",
+ "mail2u4me.com",
+ "mail2uae.com",
+ "mail2uganda.com",
+ "mail2uk.com",
+ "mail2ukraine.com",
+ "mail2uncle.com",
+ "mail2unsubscribe.com",
+ "mail2uptown.com",
+ "mail2uruguay.com",
+ "mail2usa.com",
+ "mail2utah.com",
+ "mail2uzbekistan.com",
+ "mail2v.com",
+ "mail2vacation.com",
+ "mail2valentines.com",
+ "mail2valerie.com",
+ "mail2valley.com",
+ "mail2vamoose.com",
+ "mail2vanessa.com",
+ "mail2vanuatu.com",
+ "mail2venezuela.com",
+ "mail2venous.com",
+ "mail2venus.com",
+ "mail2vermont.com",
+ "mail2vickie.com",
+ "mail2victor.com",
+ "mail2victoria.com",
+ "mail2vienna.com",
+ "mail2vietnam.com",
+ "mail2vince.com",
+ "mail2virginia.com",
+ "mail2virgo.com",
+ "mail2visionary.com",
+ "mail2vodka.com",
+ "mail2volleyball.com",
+ "mail2waiter.com",
+ "mail2wallstreet.com",
+ "mail2wally.com",
+ "mail2walter.com",
+ "mail2warren.com",
+ "mail2washington.com",
+ "mail2wave.com",
+ "mail2way.com",
+ "mail2waycool.com",
+ "mail2wayne.com",
+ "mail2webmaster.com",
+ "mail2webtop.com",
+ "mail2webtv.com",
+ "mail2weird.com",
+ "mail2wendell.com",
+ "mail2wendy.com",
+ "mail2westend.com",
+ "mail2westvirginia.com",
+ "mail2whether.com",
+ "mail2whip.com",
+ "mail2white.com",
+ "mail2whitehouse.com",
+ "mail2whitney.com",
+ "mail2why.com",
+ "mail2wilbur.com",
+ "mail2wild.com",
+ "mail2willard.com",
+ "mail2willie.com",
+ "mail2wine.com",
+ "mail2winner.com",
+ "mail2wired.com",
+ "mail2wisconsin.com",
+ "mail2woman.com",
+ "mail2wonder.com",
+ "mail2world.com",
+ "mail2worship.com",
+ "mail2wow.com",
+ "mail2www.com",
+ "mail2wyoming.com",
+ "mail2xfiles.com",
+ "mail2xox.com",
+ "mail2yachtclub.com",
+ "mail2yahalla.com",
+ "mail2yemen.com",
+ "mail2yes.com",
+ "mail2yugoslavia.com",
+ "mail2zack.com",
+ "mail2zambia.com",
+ "mail2zenith.com",
+ "mail2zephir.com",
+ "mail2zeus.com",
+ "mail2zipper.com",
+ "mail2zoo.com",
+ "mail2zoologist.com",
+ "mail2zurich.com",
+ "mail3000.com",
+ "mail333.com",
+ "mail4trash.com",
+ "mail4u.info",
+ "mailandftp.com",
+ "mailandnews.com",
+ "mailas.com",
+ "mailasia.com",
+ "mailbolt.com",
+ "mailbomb.net",
+ "mailboom.com",
+ "mailbox.as",
+ "mailbox.co.za",
+ "mailbox.gr",
+ "mailbox.hu",
+ "mailbox72.biz",
+ "mailbox80.biz",
+ "mailbr.com.br",
+ "mailc.net",
+ "mailcan.com",
+ "mailcat.biz",
+ "mailcc.com",
+ "mailchoose.co",
+ "mailcity.com",
+ "mailclub.fr",
+ "mailclub.net",
+ "maildrop.cc",
+ "maildrop.gq",
+ "maildx.com",
+ "mailed.ro",
+ "mailexcite.com",
+ "mailfa.tk",
+ "mailfence.com",
+ "mailforce.net",
+ "mailforspam.com",
+ "mailfree.gq",
+ "mailfs.com",
+ "mailftp.com",
+ "mailgenie.net",
+ "mailguard.me",
+ "mailhaven.com",
+ "mailhood.com",
+ "mailimate.com",
+ "mailinator.com",
+ "mailinator.org",
+ "mailinator.us",
+ "mailinblack.com",
+ "mailingaddress.org",
+ "mailingweb.com",
+ "mailisent.com",
+ "mailismagic.com",
+ "mailite.com",
+ "mailmate.com",
+ "mailme.dk",
+ "mailme.gq",
+ "mailme24.com",
+ "mailmight.com",
+ "mailmij.nl",
+ "mailnator.com",
+ "mailnew.com",
+ "mailops.com",
+ "mailoye.com",
+ "mailpanda.com",
+ "mailpick.biz",
+ "mailpokemon.com",
+ "mailpost.zzn.com",
+ "mailpride.com",
+ "mailproxsy.com",
+ "mailpuppy.com",
+ "mailquack.com",
+ "mailrock.biz",
+ "mailroom.com",
+ "mailru.com",
+ "mailsac.com",
+ "mailseal.de",
+ "mailsent.net",
+ "mailservice.ms",
+ "mailshuttle.com",
+ "mailslapping.com",
+ "mailstart.com",
+ "mailstartplus.com",
+ "mailsurf.com",
+ "mailtag.com",
+ "mailtemp.info",
+ "mailto.de",
+ "mailtothis.com",
+ "mailueberfall.de",
+ "mailup.net",
+ "mailwire.com",
+ "mailworks.org",
+ "mailzi.ru",
+ "mailzilla.org",
+ "maktoob.com",
+ "malayalamtelevision.net",
+ "maltesemail.com",
+ "mamber.net",
+ "manager.de",
+ "mancity.net",
+ "mantrafreenet.com",
+ "mantramail.com",
+ "mantraonline.com",
+ "manybrain.com",
+ "marchmail.com",
+ "mariahc.com",
+ "marijuana.com",
+ "marijuana.nl",
+ "married-not.com",
+ "marsattack.com",
+ "martindalemail.com",
+ "mash4077.com",
+ "masrawy.com",
+ "matmail.com",
+ "mauimail.com",
+ "mauritius.com",
+ "maxleft.com",
+ "maxmail.co.uk",
+ "mbox.com.au",
+ "mchsi.com",
+ "me-mail.hu",
+ "me.com",
+ "medical.net.au",
+ "medscape.com",
+ "meetingmall.com",
+ "megago.com",
+ "megamail.pt",
+ "megapoint.com",
+ "mehrani.com",
+ "mehtaweb.com",
+ "meine-dateien.info",
+ "meine-diashow.de",
+ "meine-fotos.info",
+ "meine-urlaubsfotos.de",
+ "mekhong.com",
+ "melodymail.com",
+ "meloo.com",
+ "merda.flu.cc",
+ "merda.igg.biz",
+ "merda.nut.cc",
+ "merda.usa.cc",
+ "message.hu",
+ "message.myspace.com",
+ "messages.to",
+ "metacrawler.com",
+ "metalfan.com",
+ "metaping.com",
+ "metta.lk",
+ "mexicomail.com",
+ "mezimages.net",
+ "mfsa.ru",
+ "mierdamail.com",
+ "miesto.sk",
+ "mighty.co.za",
+ "migmail.net",
+ "migmail.pl",
+ "migumail.com",
+ "miho-nakayama.com",
+ "mikrotamanet.com",
+ "millionaireintraining.com",
+ "millionairemail.com",
+ "milmail.com",
+ "mindless.com",
+ "mindspring.com",
+ "minister.com",
+ "misery.net",
+ "mittalweb.com",
+ "mixmail.com",
+ "mjfrogmail.com",
+ "ml1.net",
+ "mlb.bounce.ed10.net",
+ "mm.st",
+ "mns.ru",
+ "moakt.com",
+ "mobilbatam.com",
+ "mobileninja.co.uk",
+ "mochamail.com",
+ "mohammed.com",
+ "mohmal.com",
+ "moldova.cc",
+ "moldova.com",
+ "moldovacc.com",
+ "momslife.com",
+ "monemail.com",
+ "money.net",
+ "montevideo.com.uy",
+ "monumentmail.com",
+ "moonman.com",
+ "moose-mail.com",
+ "mor19.uu.gl",
+ "mortaza.com",
+ "mosaicfx.com",
+ "moscowmail.com",
+ "most-wanted.com",
+ "mostlysunny.com",
+ "motormania.com",
+ "movemail.com",
+ "movieluver.com",
+ "mox.pp.ua",
+ "mp4.it",
+ "mr-potatohead.com",
+ "mscold.com",
+ "msgbox.com",
+ "msn.cn",
+ "msn.com",
+ "msn.nl",
+ "mt2015.com",
+ "mt2016.com",
+ "mttestdriver.com",
+ "muehlacker.tk",
+ "muell.icu",
+ "muellemail.com",
+ "muellmail.com",
+ "mundomail.net",
+ "munich.com",
+ "music.com",
+ "musician.org",
+ "musicscene.org",
+ "muskelshirt.de",
+ "muslim.com",
+ "muslimsonline.com",
+ "mutantweb.com",
+ "mvrht.com",
+ "my.com",
+ "my10minutemail.com",
+ "mybox.it",
+ "mycabin.com",
+ "mycity.com",
+ "mycool.com",
+ "mydomain.com",
+ "mydotcomaddress.com",
+ "myfamily.com",
+ "myfastmail.com",
+ "mygo.com",
+ "myiris.com",
+ "mymacmail.com",
+ "mynamedot.com",
+ "mynet.com",
+ "mynetaddress.com",
+ "mynetstore.de",
+ "myownemail.com",
+ "myownfriends.com",
+ "mypacks.net",
+ "mypad.com",
+ "mypersonalemail.com",
+ "myplace.com",
+ "myrambler.ru",
+ "myrealbox.com",
+ "myremarq.com",
+ "myself.com",
+ "myspaceinc.net",
+ "myspamless.com",
+ "mystupidjob.com",
+ "mytemp.email",
+ "mythirdage.com",
+ "myway.com",
+ "myworldmail.com",
+ "n2.com",
+ "n2baseball.com",
+ "n2business.com",
+ "n2mail.com",
+ "n2soccer.com",
+ "n2software.com",
+ "nabc.biz",
+ "nafe.com",
+ "nagpal.net",
+ "nakedgreens.com",
+ "name.com",
+ "nameplanet.com",
+ "nandomail.com",
+ "naplesnews.net",
+ "naseej.com",
+ "nativestar.net",
+ "nativeweb.net",
+ "naui.net",
+ "naver.com",
+ "navigator.lv",
+ "navy.org",
+ "naz.com",
+ "nc.rr.com",
+ "nchoicemail.com",
+ "neeva.net",
+ "nemra1.com",
+ "nenter.com",
+ "neo.rr.com",
+ "nervhq.org",
+ "net-c.be",
+ "net-c.ca",
+ "net-c.cat",
+ "net-c.com",
+ "net-c.es",
+ "net-c.fr",
+ "net-c.it",
+ "net-c.lu",
+ "net-c.nl",
+ "net-c.pl",
+ "net-pager.net",
+ "net-shopping.com",
+ "net4b.pt",
+ "net4you.at",
+ "netbounce.com",
+ "netbroadcaster.com",
+ "netby.dk",
+ "netc.eu",
+ "netc.fr",
+ "netc.it",
+ "netc.lu",
+ "netc.pl",
+ "netcenter-vn.net",
+ "netcmail.com",
+ "netcourrier.com",
+ "netexecutive.com",
+ "netexpressway.com",
+ "netgenie.com",
+ "netian.com",
+ "netizen.com.ar",
+ "netlane.com",
+ "netlimit.com",
+ "netmongol.com",
+ "netnet.com.sg",
+ "netnoir.net",
+ "netpiper.com",
+ "netposta.net",
+ "netralink.com",
+ "netscape.net",
+ "netscapeonline.co.uk",
+ "netspace.net.au",
+ "netspeedway.com",
+ "netsquare.com",
+ "netster.com",
+ "nettaxi.com",
+ "nettemail.com",
+ "netterchef.de",
+ "netti.fi",
+ "netzero.com",
+ "netzero.net",
+ "netzidiot.de",
+ "neue-dateien.de",
+ "neuro.md",
+ "newmail.com",
+ "newmail.net",
+ "newmail.ru",
+ "newsboysmail.com",
+ "newyork.com",
+ "nextmail.ru",
+ "nexxmail.com",
+ "nfmail.com",
+ "nicebush.com",
+ "nicegal.com",
+ "nicholastse.net",
+ "nicolastse.com",
+ "nightmail.com",
+ "nikopage.com",
+ "nimail.com",
+ "ninfan.com",
+ "nirvanafan.com",
+ "nmail.cf",
+ "noavar.com",
+ "nonpartisan.com",
+ "nonspam.eu",
+ "nonspammer.de",
+ "norika-fujiwara.com",
+ "norikomail.com",
+ "northgates.net",
+ "nospammail.net",
+ "nospamthanks.info",
+ "nowhere.org",
+ "ntelos.net",
+ "ntlhelp.net",
+ "ntlworld.com",
+ "ntscan.com",
+ "null.net",
+ "nullbox.info",
+ "nur-fuer-spam.de",
+ "nus.edu.sg",
+ "nwldx.com",
+ "nwytg.net",
+ "nxt.ru",
+ "ny.com",
+ "nybella.com",
+ "nyc.com",
+ "nycmail.com",
+ "nzoomail.com",
+ "o-tay.com",
+ "o2.co.uk",
+ "oaklandas-fan.com",
+ "oath.com",
+ "oceanfree.net",
+ "odaymail.com",
+ "oddpost.com",
+ "odmail.com",
+ "office-dateien.de",
+ "office-email.com",
+ "offroadwarrior.com",
+ "oicexchange.com",
+ "oida.icu",
+ "oikrach.com",
+ "okbank.com",
+ "okhuman.com",
+ "okmad.com",
+ "okmagic.com",
+ "okname.net",
+ "okuk.com",
+ "oldies104mail.com",
+ "ole.com",
+ "olemail.com",
+ "olympist.net",
+ "olypmall.ru",
+ "omaninfo.com",
+ "omen.ru",
+ "onebox.com",
+ "onenet.com.ar",
+ "oneoffmail.com",
+ "onet.com.pl",
+ "onet.eu",
+ "onet.pl",
+ "oninet.pt",
+ "online.ie",
+ "online.ms",
+ "online.nl",
+ "onlinewiz.com",
+ "onmilwaukee.com",
+ "onobox.com",
+ "op.pl",
+ "opayq.com",
+ "openmailbox.org",
+ "operafan.com",
+ "operamail.com",
+ "opoczta.pl",
+ "optician.com",
+ "optonline.net",
+ "optusnet.com.au",
+ "orange.fr",
+ "orbitel.bg",
+ "orgmail.net",
+ "orthodontist.net",
+ "osite.com.br",
+ "oso.com",
+ "otakumail.com",
+ "our-computer.com",
+ "our-office.com",
+ "our.st",
+ "ourbrisbane.com",
+ "ourklips.com",
+ "ournet.md",
+ "outgun.com",
+ "outlawspam.com",
+ "outlook.at",
+ "outlook.be",
+ "outlook.cl",
+ "outlook.co.id",
+ "outlook.co.il",
+ "outlook.co.nz",
+ "outlook.co.th",
+ "outlook.com",
+ "outlook.com.au",
+ "outlook.com.br",
+ "outlook.com.gr",
+ "outlook.com.pe",
+ "outlook.com.tr",
+ "outlook.com.vn",
+ "outlook.cz",
+ "outlook.de",
+ "outlook.dk",
+ "outlook.es",
+ "outlook.fr",
+ "outlook.hu",
+ "outlook.ie",
+ "outlook.in",
+ "outlook.it",
+ "outlook.jp",
+ "outlook.kr",
+ "outlook.lv",
+ "outlook.my",
+ "outlook.nl",
+ "outlook.ph",
+ "outlook.pt",
+ "outlook.sa",
+ "outlook.sg",
+ "outlook.sk",
+ "over-the-rainbow.com",
+ "ownmail.net",
+ "ozbytes.net.au",
+ "ozemail.com.au",
+ "pacbell.net",
+ "pacific-ocean.com",
+ "pacific-re.com",
+ "pacificwest.com",
+ "packersfan.com",
+ "pagina.de",
+ "pagons.org",
+ "pakistanmail.com",
+ "pakistanoye.com",
+ "palestinemail.com",
+ "pandora.be",
+ "papierkorb.me",
+ "parkjiyoon.com",
+ "parsmail.com",
+ "partlycloudy.com",
+ "partybombe.de",
+ "partyheld.de",
+ "partynight.at",
+ "parvazi.com",
+ "passwordmail.com",
+ "pathfindermail.com",
+ "pconnections.net",
+ "pcpostal.com",
+ "pcsrock.com",
+ "pcusers.otherinbox.com",
+ "pediatrician.com",
+ "penpen.com",
+ "peoplepc.com",
+ "peopleweb.com",
+ "pepbot.com",
+ "perfectmail.com",
+ "perso.be",
+ "personal.ro",
+ "personales.com",
+ "petlover.com",
+ "petml.com",
+ "pettypool.com",
+ "pezeshkpour.com",
+ "pfui.ru",
+ "phayze.com",
+ "phone.net",
+ "photo-impact.eu",
+ "photographer.net",
+ "phpbb.uu.gl",
+ "phreaker.net",
+ "phus8kajuspa.cu.cc",
+ "physicist.net",
+ "pianomail.com",
+ "pickupman.com",
+ "picusnet.com",
+ "pigpig.net",
+ "pinoymail.com",
+ "piracha.net",
+ "pisem.net",
+ "pjjkp.com",
+ "planet.nl",
+ "planetaccess.com",
+ "planetarymotion.net",
+ "planetearthinter.net",
+ "planetmail.com",
+ "planetmail.net",
+ "planetout.com",
+ "plasa.com",
+ "playersodds.com",
+ "playful.com",
+ "playstation.sony.com",
+ "plus.com",
+ "plus.google.com",
+ "plusmail.com.br",
+ "pm.me",
+ "pmail.net",
+ "pobox.hu",
+ "pobox.sk",
+ "pochta.ru",
+ "poczta.fm",
+ "poczta.onet.pl",
+ "poetic.com",
+ "pokemail.net",
+ "pokemonpost.com",
+ "pokepost.com",
+ "polandmail.com",
+ "polbox.com",
+ "policeoffice.com",
+ "politician.com",
+ "polizisten-duzer.de",
+ "polyfaust.com",
+ "pool-sharks.com",
+ "poond.com",
+ "popaccount.com",
+ "popmail.com",
+ "popsmail.com",
+ "popstar.com",
+ "portugalmail.com",
+ "portugalmail.pt",
+ "portugalnet.com",
+ "positive-thinking.com",
+ "post.com",
+ "post.cz",
+ "post.sk",
+ "posta.ro",
+ "postaccesslite.com",
+ "postafree.com",
+ "postaweb.com",
+ "posteo.at",
+ "posteo.be",
+ "posteo.ch",
+ "posteo.cl",
+ "posteo.co",
+ "posteo.de",
+ "posteo.dk",
+ "posteo.es",
+ "posteo.gl",
+ "posteo.net",
+ "posteo.no",
+ "posteo.us",
+ "postfach.cc",
+ "postinbox.com",
+ "postino.ch",
+ "postmark.net",
+ "postmaster.co.uk",
+ "postmaster.twitter.com",
+ "postpro.net",
+ "pousa.com",
+ "powerfan.com",
+ "pp.inet.fi",
+ "praize.com",
+ "premium-mail.fr",
+ "premiumservice.com",
+ "presidency.com",
+ "press.co.jp",
+ "priest.com",
+ "primposta.com",
+ "primposta.hu",
+ "privy-mail.com",
+ "privymail.de",
+ "pro.hu",
+ "probemail.com",
+ "prodigy.net",
+ "progetplus.it",
+ "programist.ru",
+ "programmer.net",
+ "programozo.hu",
+ "proinbox.com",
+ "project2k.com",
+ "promessage.com",
+ "prontomail.com",
+ "protestant.com",
+ "proton.me",
+ "protonmail.ch",
+ "protonmail.com",
+ "prydirect.info",
+ "psv-supporter.com",
+ "ptd.net",
+ "public-files.de",
+ "public.usa.com",
+ "publicist.com",
+ "pulp-fiction.com",
+ "punkass.com",
+ "purpleturtle.com",
+ "put2.net",
+ "pwrby.com",
+ "q.com",
+ "qatarmail.com",
+ "qmail.com",
+ "qprfans.com",
+ "qq.com",
+ "qrio.com",
+ "quackquack.com",
+ "quakemail.com",
+ "qualityservice.com",
+ "quantentunnel.de",
+ "qudsmail.com",
+ "quepasa.com",
+ "quickhosts.com",
+ "quickmail.nl",
+ "quicknet.nl",
+ "quickwebmail.com",
+ "quiklinks.com",
+ "quikmail.com",
+ "qv7.info",
+ "qwest.net",
+ "qwestoffice.net",
+ "r-o-o-t.com",
+ "raakim.com",
+ "racedriver.com",
+ "racefanz.com",
+ "racingfan.com.au",
+ "racingmail.com",
+ "radicalz.com",
+ "radiku.ye.vc",
+ "radiologist.net",
+ "ragingbull.com",
+ "ralib.com",
+ "rambler.ru",
+ "ranmamail.com",
+ "rastogi.net",
+ "ratt-n-roll.com",
+ "rattle-snake.com",
+ "raubtierbaendiger.de",
+ "ravearena.com",
+ "ravemail.com",
+ "razormail.com",
+ "rccgmail.org",
+ "rcn.com",
+ "realemail.net",
+ "reality-concept.club",
+ "reallyfast.biz",
+ "reallyfast.info",
+ "reallymymail.com",
+ "realradiomail.com",
+ "realtyagent.com",
+ "reborn.com",
+ "reconmail.com",
+ "recycler.com",
+ "recyclermail.com",
+ "rediff.com",
+ "rediffmail.com",
+ "rediffmailpro.com",
+ "rednecks.com",
+ "redseven.de",
+ "redsfans.com",
+ "regbypass.com",
+ "reggaefan.com",
+ "registerednurses.com",
+ "regspaces.tk",
+ "reincarnate.com",
+ "religious.com",
+ "remail.ga",
+ "renren.com",
+ "repairman.com",
+ "reply.hu",
+ "reply.ticketmaster.com",
+ "representative.com",
+ "rescueteam.com",
+ "resgedvgfed.tk",
+ "resource.calendar.google.com",
+ "resumemail.com",
+ "rezai.com",
+ "rhyta.com",
+ "richmondhill.com",
+ "rickymail.com",
+ "rin.ru",
+ "riopreto.com.br",
+ "rklips.com",
+ "rn.com",
+ "ro.ru",
+ "roadrunner.com",
+ "roanokemail.com",
+ "rock.com",
+ "rocketmail.com",
+ "rocketship.com",
+ "rockfan.com",
+ "rodrun.com",
+ "rogers.com",
+ "rome.com",
+ "roosh.com",
+ "rootprompt.org",
+ "roughnet.com",
+ "royal.net",
+ "rr.com",
+ "rrohio.com",
+ "rsub.com",
+ "rubyridge.com",
+ "runbox.com",
+ "rushpost.com",
+ "ruttolibero.com",
+ "rvshop.com",
+ "s-mail.com",
+ "sabreshockey.com",
+ "sacbeemail.com",
+ "saeuferleber.de",
+ "safe-mail.net",
+ "safrica.com",
+ "sagra.lu",
+ "sags-per-mail.de",
+ "sailormoon.com",
+ "saintly.com",
+ "saintmail.net",
+ "sale-sale-sale.com",
+ "salehi.net",
+ "salesperson.net",
+ "samerica.com",
+ "samilan.net",
+ "sammimail.com",
+ "sandelf.de",
+ "sanfranmail.com",
+ "sanook.com",
+ "sapo.pt",
+ "saudia.com",
+ "savelife.ml",
+ "sayhi.net",
+ "saynotospams.com",
+ "sbcglbal.net",
+ "sbcglobal.com",
+ "sbcglobal.net",
+ "scandalmail.com",
+ "scarlet.nl",
+ "schafmail.de",
+ "schizo.com",
+ "schmusemail.de",
+ "schoolemail.com",
+ "schoolmail.com",
+ "schoolsucks.com",
+ "schreib-doch-mal-wieder.de",
+ "schweiz.org",
+ "sci.fi",
+ "scientist.com",
+ "scifianime.com",
+ "scotland.com",
+ "scotlandmail.com",
+ "scottishmail.co.uk",
+ "scottsboro.org",
+ "scubadiving.com",
+ "seanet.com",
+ "search.ua",
+ "searchwales.com",
+ "sebil.com",
+ "seckinmail.com",
+ "secret-police.com",
+ "secretary.net",
+ "secretservices.net",
+ "secure-mail.biz",
+ "secure-mail.cc",
+ "seductive.com",
+ "seekstoyboy.com",
+ "seguros.com.br",
+ "selfdestructingmail.com",
+ "send.hu",
+ "sendme.cz",
+ "sendspamhere.com",
+ "sent.as",
+ "sent.at",
+ "sent.com",
+ "sentrismail.com",
+ "serga.com.ar",
+ "servemymail.com",
+ "servermaps.net",
+ "sesmail.com",
+ "sexmagnet.com",
+ "seznam.cz",
+ "shahweb.net",
+ "shaniastuff.com",
+ "shared-files.de",
+ "sharedmailbox.org",
+ "sharklasers.com",
+ "sharmaweb.com",
+ "shaw.ca",
+ "she.com",
+ "shieldedmail.com",
+ "shinedyoureyes.com",
+ "shitaway.cf",
+ "shitaway.cu.cc",
+ "shitaway.ga",
+ "shitaway.gq",
+ "shitaway.ml",
+ "shitaway.tk",
+ "shitaway.usa.cc",
+ "shitmail.de",
+ "shitmail.org",
+ "shitware.nl",
+ "shockinmytown.cu.cc",
+ "shootmail.com",
+ "shortmail.com",
+ "shotgun.hu",
+ "showslow.de",
+ "shuf.com",
+ "sialkotcity.com",
+ "sialkotian.com",
+ "sialkotoye.com",
+ "sify.com",
+ "silkroad.net",
+ "sina.cn",
+ "sina.com",
+ "sinamail.com",
+ "singapore.com",
+ "singles4jesus.com",
+ "singmail.com",
+ "singnet.com.sg",
+ "sinnlos-mail.de",
+ "siteposter.net",
+ "skafan.com",
+ "skeefmail.com",
+ "skim.com",
+ "skizo.hu",
+ "skrx.tk",
+ "sky.com",
+ "skynet.be",
+ "slamdunkfan.com",
+ "slave-auctions.net",
+ "slingshot.com",
+ "slippery.email",
+ "slipry.net",
+ "slo.net",
+ "slotter.com",
+ "smap.4nmv.ru",
+ "smapxsmap.net",
+ "smashmail.de",
+ "smellrear.com",
+ "smileyface.comsmithemail.net",
+ "smoothmail.com",
+ "sms.at",
+ "snail-mail.net",
+ "snakebite.com",
+ "snakemail.com",
+ "sndt.net",
+ "sneakemail.com",
+ "snet.net",
+ "sniper.hu",
+ "snkmail.com",
+ "snoopymail.com",
+ "snowboarding.com",
+ "snowdonia.net",
+ "socamail.com",
+ "socceramerica.net",
+ "soccermail.com",
+ "soccermomz.com",
+ "social-mailer.tk",
+ "socialworker.net",
+ "sociologist.com",
+ "sofort-mail.de",
+ "sofortmail.de",
+ "softhome.net",
+ "sogou.com",
+ "sohu.com",
+ "sol.dk",
+ "solar-impact.pro",
+ "solcon.nl",
+ "soldier.hu",
+ "solution4u.com",
+ "solvemail.info",
+ "songwriter.net",
+ "sonnenkinder.org",
+ "soodomail.com",
+ "soon.com",
+ "soulfoodcookbook.com",
+ "sp.nl",
+ "space-bank.com",
+ "space-man.com",
+ "space-ship.com",
+ "space-travel.com",
+ "space.com",
+ "spacemart.com",
+ "spacetowns.com",
+ "spacewar.com",
+ "spainmail.com",
+ "spam.2012-2016.ru",
+ "spam.care",
+ "spamavert.com",
+ "spambob.com",
+ "spambob.org",
+ "spambog.net",
+ "spambooger.com",
+ "spambox.xyz",
+ "spamcero.com",
+ "spamdecoy.net",
+ "spameater.com",
+ "spameater.org",
+ "spamex.com",
+ "spamfree24.info",
+ "spamfree24.net",
+ "spamgoes.in",
+ "spaminator.de",
+ "spamkill.info",
+ "spaml.com",
+ "spamoff.de",
+ "spamstack.net",
+ "spartapiet.com",
+ "spazmail.com",
+ "speedemail.net",
+ "speedpost.net",
+ "speedrules.com",
+ "speedrulz.com",
+ "speedymail.org",
+ "sperke.net",
+ "spils.com",
+ "spinfinder.com",
+ "spl.at",
+ "spoko.pl",
+ "spoofmail.de",
+ "sportemail.com",
+ "sportsmail.com",
+ "sporttruckdriver.com",
+ "spray.no",
+ "spray.se",
+ "spybox.de",
+ "spymac.com",
+ "sraka.xyz",
+ "srilankan.net",
+ "ssl-mail.com",
+ "st-davids.net",
+ "stade.fr",
+ "stalag13.com",
+ "stargateradio.com",
+ "starmail.com",
+ "starmail.org",
+ "starmedia.com",
+ "starplace.com",
+ "starspath.com",
+ "start.com.au",
+ "startkeys.com",
+ "stinkefinger.net",
+ "stipte.nl",
+ "stoned.com",
+ "stones.com",
+ "stop-my-spam.pp.ua",
+ "stopdropandroll.com",
+ "storksite.com",
+ "streber24.de",
+ "streetwisemail.com",
+ "stribmail.com",
+ "strompost.com",
+ "strongguy.com",
+ "student.su",
+ "studentcenter.org",
+ "stuffmail.de",
+ "subram.com",
+ "sudanmail.net",
+ "sudolife.me",
+ "sudolife.net",
+ "sudomail.biz",
+ "sudomail.com",
+ "sudomail.net",
+ "sudoverse.com",
+ "sudoverse.net",
+ "sudoweb.net",
+ "sudoworld.com",
+ "sudoworld.net",
+ "suhabi.com",
+ "suisse.org",
+ "sukhumvit.net",
+ "sunpoint.net",
+ "sunrise-sunset.com",
+ "sunsgame.com",
+ "sunumail.sn",
+ "suomi24.fi",
+ "superdada.com",
+ "supereva.it",
+ "supermail.ru",
+ "superrito.com",
+ "superstachel.de",
+ "surat.com",
+ "surf3.net",
+ "surfree.com",
+ "surfy.net",
+ "surgical.net",
+ "surimail.com",
+ "survivormail.com",
+ "susi.ml",
+ "svk.jp",
+ "swbell.net",
+ "sweb.cz",
+ "swedenmail.com",
+ "sweetville.net",
+ "sweetxxx.de",
+ "swift-mail.com",
+ "swiftdesk.com",
+ "swingeasyhithard.com",
+ "swingfan.com",
+ "swipermail.zzn.com",
+ "swirve.com",
+ "swissinfo.org",
+ "swissmail.com",
+ "swissmail.net",
+ "switchboardmail.com",
+ "switzerland.org",
+ "sx172.com",
+ "syom.com",
+ "syriamail.com",
+ "t-online.de",
+ "t.psh.me",
+ "t2mail.com",
+ "tafmail.com",
+ "takuyakimura.com",
+ "talk21.com",
+ "talkcity.com",
+ "talkinator.com",
+ "tamil.com",
+ "tampabay.rr.com",
+ "tankpolice.com",
+ "tatanova.com",
+ "tbwt.com",
+ "tcc.on.ca",
+ "tds.net",
+ "teachermail.net",
+ "teachers.org",
+ "teamdiscovery.com",
+ "teamtulsa.net",
+ "tech-center.com",
+ "tech4peace.org",
+ "techemail.com",
+ "techie.com",
+ "technisamail.co.za",
+ "technologist.com",
+ "techscout.com",
+ "techspot.com",
+ "teenagedirtbag.com",
+ "tele2.nl",
+ "telebot.com",
+ "telefonica.net",
+ "teleline.es",
+ "telenet.be",
+ "telepac.pt",
+ "telerymd.com",
+ "teleworm.us",
+ "telfort.nl",
+ "telfortglasvezel.nl",
+ "telinco.net",
+ "telkom.net",
+ "telpage.net",
+ "telstra.com",
+ "telstra.com.au",
+ "temp-mail.com",
+ "temp-mail.de",
+ "temp-mail.org",
+ "temp.headstrong.de",
+ "tempail.com",
+ "tempemail.biz",
+ "tempmail.net",
+ "tempmail.us",
+ "tempmail2.com",
+ "tempmaildemo.com",
+ "tempmailer.com",
+ "temporarioemail.com.br",
+ "temporaryemail.us",
+ "tempthe.net",
+ "tempymail.com",
+ "temtulsa.net",
+ "tenchiclub.com",
+ "tenderkiss.com",
+ "tennismail.com",
+ "terminverpennt.de",
+ "terra.cl",
+ "terra.com",
+ "terra.com.ar",
+ "terra.com.br",
+ "terra.es",
+ "test.com",
+ "test.de",
+ "tfanus.com.er",
+ "tfz.net",
+ "thai.com",
+ "thaimail.com",
+ "thaimail.net",
+ "thanksnospam.info",
+ "the-african.com",
+ "the-airforce.com",
+ "the-aliens.com",
+ "the-american.com",
+ "the-animal.com",
+ "the-army.com",
+ "the-astronaut.com",
+ "the-beauty.com",
+ "the-big-apple.com",
+ "the-biker.com",
+ "the-boss.com",
+ "the-brazilian.com",
+ "the-canadian.com",
+ "the-canuck.com",
+ "the-captain.com",
+ "the-chinese.com",
+ "the-country.com",
+ "the-cowboy.com",
+ "the-davis-home.com",
+ "the-dutchman.com",
+ "the-eagles.com",
+ "the-englishman.com",
+ "the-fastest.net",
+ "the-fool.com",
+ "the-frenchman.com",
+ "the-galaxy.net",
+ "the-genius.com",
+ "the-gentleman.com",
+ "the-german.com",
+ "the-gremlin.com",
+ "the-hooligan.com",
+ "the-italian.com",
+ "the-japanese.com",
+ "the-lair.com",
+ "the-madman.com",
+ "the-mailinglist.com",
+ "the-marine.com",
+ "the-master.com",
+ "the-mexican.com",
+ "the-ministry.com",
+ "the-monkey.com",
+ "the-newsletter.net",
+ "the-pentagon.com",
+ "the-police.com",
+ "the-prayer.com",
+ "the-professional.com",
+ "the-quickest.com",
+ "the-russian.com",
+ "the-snake.com",
+ "the-spaceman.com",
+ "the-stock-market.com",
+ "the-student.net",
+ "the-whitehouse.net",
+ "the-wild-west.com",
+ "the18th.com",
+ "thecoolguy.com",
+ "thecriminals.com",
+ "thedoghousemail.com",
+ "thedorm.com",
+ "theend.hu",
+ "theglobe.com",
+ "thegolfcourse.com",
+ "thegooner.com",
+ "theheadoffice.com",
+ "theinternetemail.com",
+ "thelanddownunder.com",
+ "themail.com",
+ "themillionare.net",
+ "theoffice.net",
+ "theplate.com",
+ "thepokerface.com",
+ "thepostmaster.net",
+ "theraces.com",
+ "theracetrack.com",
+ "therapist.net",
+ "thestreetfighter.com",
+ "theteebox.com",
+ "thewatercooler.com",
+ "thewebpros.co.uk",
+ "thewizzard.com",
+ "thewizzkid.com",
+ "thezhangs.net",
+ "thirdage.com",
+ "thisgirl.com",
+ "thraml.com",
+ "throwam.com",
+ "thundermail.com",
+ "tidni.com",
+ "timein.net",
+ "tiscali.at",
+ "tiscali.be",
+ "tiscali.co.uk",
+ "tiscali.it",
+ "tiscali.lu",
+ "tiscali.se",
+ "tkcity.com",
+ "tmail.ws",
+ "toast.com",
+ "toke.com",
+ "tom.com",
+ "toolsource.com",
+ "toomail.biz",
+ "toothfairy.com",
+ "topchat.com",
+ "topgamers.co.uk",
+ "topletter.com",
+ "topmail-files.de",
+ "topmail.com.ar",
+ "topsurf.com",
+ "torchmail.com",
+ "torontomail.com",
+ "tortenboxer.de",
+ "totalmail.de",
+ "totalmusic.net",
+ "townisp.com",
+ "tpg.com.au",
+ "trash-amil.com",
+ "trash-mail.ga",
+ "trash-mail.ml",
+ "trash2010.com",
+ "trash2011.com",
+ "trashdevil.de",
+ "trashymail.net",
+ "travel.li",
+ "trayna.com",
+ "trialbytrivia.com",
+ "trickmail.net",
+ "trimix.cn",
+ "tritium.net",
+ "trmailbox.com",
+ "tropicalstorm.com",
+ "truckerz.com",
+ "truckracer.com",
+ "truckracers.com",
+ "trust-me.com",
+ "truthmail.com",
+ "tsamail.co.za",
+ "ttml.co.in",
+ "tunisiamail.com",
+ "turboprinz.de",
+ "turboprinzessin.de",
+ "turkey.com",
+ "turual.com",
+ "tut.by",
+ "tvstar.com",
+ "twc.com",
+ "twcny.com",
+ "twinstarsmail.com",
+ "tx.rr.com",
+ "tycoonmail.com",
+ "typemail.com",
+ "u14269.ml",
+ "u2club.com",
+ "ua.fm",
+ "uae.ac",
+ "uaemail.com",
+ "ubbi.com",
+ "ubbi.com.br",
+ "uboot.com",
+ "uk2.net",
+ "uk2k.com",
+ "uk2net.com",
+ "uk7.net",
+ "uk8.net",
+ "ukbuilder.com",
+ "ukcool.com",
+ "ukdreamcast.com",
+ "ukmail.org",
+ "ukmax.com",
+ "ukr.net",
+ "uku.co.uk",
+ "ultapulta.com",
+ "ultra.fyi",
+ "ultrapostman.com",
+ "ummah.org",
+ "umpire.com",
+ "unbounded.com",
+ "unforgettable.com",
+ "uni.de",
+ "unican.es",
+ "unihome.com",
+ "unitybox.de",
+ "universal.pt",
+ "uno.ee",
+ "uno.it",
+ "unofree.it",
+ "unterderbruecke.de",
+ "uol.com.ar",
+ "uol.com.br",
+ "uol.com.co",
+ "uol.com.mx",
+ "uol.com.ve",
+ "uole.com",
+ "uole.com.ve",
+ "uolmail.com",
+ "uomail.com",
+ "upc.nl",
+ "upcmail.nl",
+ "upf.org",
+ "uplipht.com",
+ "ureach.com",
+ "urgentmail.biz",
+ "urhen.com",
+ "uroid.com",
+ "usa.com",
+ "usa.net",
+ "usaaccess.net",
+ "usanetmail.com",
+ "used-product.fr",
+ "usermail.com",
+ "username.e4ward.com",
+ "usma.net",
+ "usmc.net",
+ "uswestmail.net",
+ "uymail.com",
+ "uyuyuy.com",
+ "v-sexi.com",
+ "vaasfc4.tk",
+ "vahoo.com",
+ "valemail.net",
+ "vampirehunter.com",
+ "varbizmail.com",
+ "vcmail.com",
+ "velnet.co.uk",
+ "velocall.com",
+ "verizon.net",
+ "verizonmail.com",
+ "verlass-mich-nicht.de",
+ "versatel.nl",
+ "veryfast.biz",
+ "veryrealemail.com",
+ "veryspeedy.net",
+ "vfemail.net",
+ "vickaentb.tk",
+ "videotron.ca",
+ "viditag.com",
+ "viewcastmedia.com",
+ "viewcastmedia.net",
+ "vinbazar.com",
+ "violinmakers.co.uk",
+ "vip.126.com",
+ "vip.21cn.com",
+ "vip.citiz.net",
+ "vip.gr",
+ "vip.onet.pl",
+ "vip.qq.com",
+ "vip.sina.com",
+ "vipmail.ru",
+ "virgilio.it",
+ "virgin.net",
+ "virginbroadband.com.au",
+ "virginmedia.com",
+ "virtualmail.com",
+ "visitmail.com",
+ "visitweb.com",
+ "visto.com",
+ "visualcities.com",
+ "vivavelocity.com",
+ "vivianhsu.net",
+ "vjtimail.com",
+ "vkcode.ru",
+ "vnet.citiz.net",
+ "vnn.vn",
+ "vodafone.nl",
+ "vodafonethuis.nl",
+ "volcanomail.com",
+ "vollbio.de",
+ "volloeko.de",
+ "vomoto.com",
+ "vorsicht-bissig.de",
+ "vorsicht-scharf.de",
+ "vote-democrats.com",
+ "vote-hillary.com",
+ "vote-republicans.com",
+ "vote4gop.org",
+ "votenet.com",
+ "vp.pl",
+ "vr9.com",
+ "vubby.com",
+ "w3.to",
+ "wahoye.com",
+ "walala.org",
+ "wales2000.net",
+ "walkmail.net",
+ "walkmail.ru",
+ "wam.co.za",
+ "wanadoo.es",
+ "wanadoo.fr",
+ "war-im-urlaub.de",
+ "warmmail.com",
+ "warpmail.net",
+ "warrior.hu",
+ "waumail.com",
+ "wazabi.club",
+ "wbdet.com",
+ "wearab.net",
+ "web-contact.info",
+ "web-emailbox.eu",
+ "web-ideal.fr",
+ "web-mail.com.ar",
+ "web-mail.pp.ua",
+ "web-police.com",
+ "web.de",
+ "webave.com",
+ "webcammail.com",
+ "webcity.ca",
+ "webcontact-france.eu",
+ "webdream.com",
+ "webindia123.com",
+ "webjump.com",
+ "webm4il.info",
+ "webmail.co.yu",
+ "webmail.co.za",
+ "webmail.hu",
+ "webmails.com",
+ "webname.com",
+ "webprogramming.com",
+ "webstation.com",
+ "websurfer.co.za",
+ "webtopmail.com",
+ "webuser.in",
+ "wee.my",
+ "weedmail.com",
+ "weekmail.com",
+ "weekonline.com",
+ "wefjo.grn.cc",
+ "weg-werf-email.de",
+ "wegas.ru",
+ "wegwerf-emails.de",
+ "wegwerfmail.info",
+ "wegwerpmailadres.nl",
+ "wehshee.com",
+ "weibsvolk.de",
+ "weibsvolk.org",
+ "weinenvorglueck.de",
+ "welsh-lady.com",
+ "westnet.com",
+ "westnet.com.au",
+ "wetrainbayarea.com",
+ "wfgdfhj.tk",
+ "whale-mail.com",
+ "whartontx.com",
+ "whatiaas.com",
+ "whatpaas.com",
+ "wheelweb.com",
+ "whipmail.com",
+ "whoever.com",
+ "whoopymail.com",
+ "whtjddn.33mail.com",
+ "wi.rr.com",
+ "wi.twcbc.com",
+ "wickmail.net",
+ "wideopenwest.com",
+ "wildmail.com",
+ "wilemail.com",
+ "will-hier-weg.de",
+ "windowslive.com",
+ "windrivers.net",
+ "windstream.net",
+ "wingnutz.com",
+ "winmail.com.au",
+ "winning.com",
+ "wir-haben-nachwuchs.de",
+ "wir-sind-cool.org",
+ "wirsindcool.de",
+ "witty.com",
+ "wiz.cc",
+ "wkbwmail.com",
+ "wmail.cf",
+ "wo.com.cn",
+ "woh.rr.com",
+ "wolf-web.com",
+ "wolke7.net",
+ "wollan.info",
+ "wombles.com",
+ "women-at-work.org",
+ "wongfaye.com",
+ "wooow.it",
+ "worker.com",
+ "workmail.com",
+ "worldemail.com",
+ "worldnet.att.net",
+ "wormseo.cn",
+ "wosaddict.com",
+ "wouldilie.com",
+ "wovz.cu.cc",
+ "wowgirl.com",
+ "wowmail.com",
+ "wowway.com",
+ "wp.pl",
+ "wptamail.com",
+ "wrexham.net",
+ "writeme.com",
+ "writemeback.com",
+ "wrongmail.com",
+ "wtvhmail.com",
+ "wwdg.com",
+ "www.com",
+ "www.e4ward.com",
+ "www2000.net",
+ "wx88.net",
+ "wxs.net",
+ "x-mail.net",
+ "x-networks.net",
+ "x5g.com",
+ "xagloo.com",
+ "xaker.ru",
+ "xing886.uu.gl",
+ "xmastime.com",
+ "xms.nl",
+ "xmsg.com",
+ "xoom.com",
+ "xoxox.cc",
+ "xpressmail.zzn.com",
+ "xs4all.nl",
+ "xsecurity.org",
+ "xsmail.com",
+ "xtra.co.nz",
+ "xuno.com",
+ "xww.ro",
+ "xy9ce.tk",
+ "xyzfree.net",
+ "xzapmail.com",
+ "y7mail.com",
+ "ya.ru",
+ "yada-yada.com",
+ "yaho.com",
+ "yahoo.ae",
+ "yahoo.at",
+ "yahoo.be",
+ "yahoo.ca",
+ "yahoo.ch",
+ "yahoo.cn",
+ "yahoo.co",
+ "yahoo.co.id",
+ "yahoo.co.il",
+ "yahoo.co.in",
+ "yahoo.co.jp",
+ "yahoo.co.kr",
+ "yahoo.co.nz",
+ "yahoo.co.th",
+ "yahoo.co.uk",
+ "yahoo.co.za",
+ "yahoo.com",
+ "yahoo.com.ar",
+ "yahoo.com.au",
+ "yahoo.com.br",
+ "yahoo.com.cn",
+ "yahoo.com.co",
+ "yahoo.com.hk",
+ "yahoo.com.is",
+ "yahoo.com.mx",
+ "yahoo.com.my",
+ "yahoo.com.ph",
+ "yahoo.com.ru",
+ "yahoo.com.sg",
+ "yahoo.com.tr",
+ "yahoo.com.tw",
+ "yahoo.com.vn",
+ "yahoo.cz",
+ "yahoo.de",
+ "yahoo.dk",
+ "yahoo.es",
+ "yahoo.fi",
+ "yahoo.fr",
+ "yahoo.gr",
+ "yahoo.hu",
+ "yahoo.ie",
+ "yahoo.in",
+ "yahoo.it",
+ "yahoo.jp",
+ "yahoo.nl",
+ "yahoo.no",
+ "yahoo.pl",
+ "yahoo.pt",
+ "yahoo.ro",
+ "yahoo.ru",
+ "yahoo.se",
+ "yahoofs.com",
+ "yalla.com",
+ "yalla.com.lb",
+ "yalook.com",
+ "yam.com",
+ "yandex.com",
+ "yandex.pl",
+ "yandex.ru",
+ "yandex.ua",
+ "yapost.com",
+ "yapped.net",
+ "yawmail.com",
+ "yeah.net",
+ "yebox.com",
+ "yehey.com",
+ "yemenmail.com",
+ "yepmail.net",
+ "yert.ye.vc",
+ "yesey.net",
+ "yifan.net",
+ "ymail.com",
+ "yogotemail.com",
+ "yomail.info",
+ "yopmail.com",
+ "yopmail.pp.ua",
+ "yopolis.com",
+ "yopweb.com",
+ "youareadork.com",
+ "youmailr.com",
+ "your-house.com",
+ "your-mail.com",
+ "yourinbox.com",
+ "yourlifesucks.cu.cc",
+ "yourlover.net",
+ "yourname.freeservers.com",
+ "yournightmare.com",
+ "yours.com",
+ "yourssincerely.com",
+ "yoursubdomain.zzn.com",
+ "yourteacher.net",
+ "yourwap.com",
+ "yuuhuu.net",
+ "yyhmail.com",
+ "z1p.biz",
+ "za.com",
+ "zahadum.com",
+ "zaktouni.fr",
+ "zeepost.nl",
+ "zetmail.com",
+ "zhaowei.net",
+ "zhouemail.510520.org",
+ "ziggo.nl",
+ "zionweb.org",
+ "zip.net",
+ "zipido.com",
+ "ziplip.com",
+ "zipmail.com",
+ "zipmail.com.br",
+ "zipmax.com",
+ "zmail.ru",
+ "zoemail.com",
+ "zoemail.org",
+ "zoho.com",
+ "zohomail.com",
+ "zomg.info",
+ "zonnet.nl",
+ "zoominternet.net",
+ "zubee.com",
+ "zuvio.com",
+ "zuzzurello.com",
+ "zwallet.com",
+ "zweb.in",
+ "zxcv.com",
+ "zxcvbnm.com",
+ "zybermail.com",
+ "zydecofan.com",
+ "zzn.com",
+ "zzom.co.uk",
+ "zzz.com",
+ "zzz.pl"
+]
diff --git a/apps/sim/lib/messaging/email/free-email.ts b/apps/sim/lib/messaging/email/free-email.ts
index 07a2f2b5002..3190553bdef 100644
--- a/apps/sim/lib/messaging/email/free-email.ts
+++ b/apps/sim/lib/messaging/email/free-email.ts
@@ -1,6 +1,6 @@
-import freeEmailDomains from 'free-email-domains'
+import freeEmailDomains from '@/lib/messaging/email/free-email-domains.json'
-const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
+const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
/**
* True when the email's domain is a known free/personal provider (Gmail, Yahoo,
@@ -10,6 +10,11 @@ const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
* Isolated in its own module (not `validation.ts`) so the sizable domain list
* only enters bundles that need the work-email check, not every consumer of
* {@link quickValidateEmail}.
+ *
+ * Vendored rather than taken from the `free-email-domains` package, whose `postinstall`
+ * downloads a CDN CSV and overwrites its own `domains.json` — so the lockfile hash covers
+ * the tarball but not the installed data. Refresh from
+ * https://github.com/Kikobeats/free-email-domains (MIT) and review the diff.
*/
export function isFreeEmailDomain(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase()
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 1ee1b2389e9..e0621cef65d 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -129,7 +129,7 @@ const nextConfig: NextConfig = {
'isolated-vm',
'@e2b/code-interpreter',
'e2b',
- '@daytonaio/sdk',
+ '@daytona/sdk',
'@earendil-works/pi-ai',
'@earendil-works/pi-coding-agent',
],
diff --git a/apps/sim/package.json b/apps/sim/package.json
index fd137cb32ef..95429efbd64 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -66,7 +66,7 @@
"@browserbasehq/stagehand": "^3.2.1",
"@calcom/embed-react": "1.5.3",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
- "@daytonaio/sdk": "0.197.0",
+ "@daytona/sdk": "0.200.0",
"@e2b/code-interpreter": "^2.7.0",
"@earendil-works/pi-ai": "0.80.10",
"@earendil-works/pi-coding-agent": "0.80.10",
@@ -102,8 +102,8 @@
"@radix-ui/react-slot": "1.2.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
- "@react-email/components": "0.5.7",
- "@react-email/render": "2.0.8",
+ "@react-email/components": "1.0.12",
+ "@react-email/render": "2.1.0",
"@sim/audit": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
@@ -153,7 +153,6 @@
"es-toolkit": "1.45.1",
"fluent-ffmpeg": "2.1.3",
"framer-motion": "^12.5.0",
- "free-email-domains": "1.2.25",
"google-auth-library": "10.5.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
@@ -214,7 +213,6 @@
"streamdown": "2.5.0",
"stripe": "18.5.0",
"svix": "1.88.0",
- "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "0.177.0",
"tldts": "7.0.30",
@@ -249,10 +247,9 @@
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.1.0",
- "autoprefixer": "10.4.21",
"jsdom": "^26.0.0",
"postcss": "^8",
- "react-email": "4.3.2",
+ "react-email": "6.9.0",
"tailwindcss": "^3.4.1",
"typescript": "^7.0.2",
"vite-tsconfig-paths": "^5.1.4",
diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts
index 57846b155ea..fea96ac6673 100644
--- a/apps/sim/scripts/build-pi-daytona-snapshot.ts
+++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts
@@ -23,7 +23,7 @@
* DAYTONA_PI_SNAPSHOT_ID=
*/
-import { Daytona, Image } from '@daytonaio/sdk'
+import { Daytona, Image } from '@daytona/sdk'
import { getErrorMessage } from '@sim/utils/errors'
import {
PI_APT,
diff --git a/bun.lock b/bun.lock
index 908fcf16e20..79db5a4d446 100644
--- a/bun.lock
+++ b/bun.lock
@@ -141,7 +141,7 @@
"@browserbasehq/stagehand": "^3.2.1",
"@calcom/embed-react": "1.5.3",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
- "@daytonaio/sdk": "0.197.0",
+ "@daytona/sdk": "0.200.0",
"@e2b/code-interpreter": "^2.7.0",
"@earendil-works/pi-ai": "0.80.10",
"@earendil-works/pi-coding-agent": "0.80.10",
@@ -177,8 +177,8 @@
"@radix-ui/react-slot": "1.2.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
- "@react-email/components": "0.5.7",
- "@react-email/render": "2.0.8",
+ "@react-email/components": "1.0.12",
+ "@react-email/render": "2.1.0",
"@sim/audit": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
@@ -228,7 +228,6 @@
"es-toolkit": "1.45.1",
"fluent-ffmpeg": "2.1.3",
"framer-motion": "^12.5.0",
- "free-email-domains": "1.2.25",
"google-auth-library": "10.5.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
@@ -289,7 +288,6 @@
"streamdown": "2.5.0",
"stripe": "18.5.0",
"svix": "1.88.0",
- "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "0.177.0",
"tldts": "7.0.30",
@@ -324,10 +322,9 @@
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.1.0",
- "autoprefixer": "10.4.21",
"jsdom": "^26.0.0",
"postcss": "^8",
- "react-email": "4.3.2",
+ "react-email": "6.9.0",
"tailwindcss": "^3.4.1",
"typescript": "^7.0.2",
"vite-tsconfig-paths": "^5.1.4",
@@ -889,7 +886,7 @@
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
- "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
@@ -899,7 +896,7 @@
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
- "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
@@ -983,13 +980,13 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
- "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-8S8JBVwIhErhDv22kCtifonfMnpQXtoAqz3migT53u7LCnjnVkqOeUFB/xN4SD7LN+Bhbh6AMVkvNwZA2BkIfw=="],
+ "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-0pTP2js+zjd5Q23YdV2e0pOFHJYVYTo9jS0IOcRCsQlqyaoeSbzGjr6M38w3Cjw81m3dW1vchw316eOkkaVGdA=="],
- "@daytona/api-client": ["@daytona/api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-O7BF07FOmmbNrp/An3EIx/Vq99wSHvxxWl4nUttw0eHpe7oKdYaUVlM2MmdJ/AbrFnYDLHfrQZGZfeUOEeRjpw=="],
+ "@daytona/api-client": ["@daytona/api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-gIM87vCrdGe1xvw9XcYv/PFREMk5+hYHY/epAvxswC+3fe/h4TMRbroUE4FMz/HydB7qAQiTz5EC4vZ2MoVTWQ=="],
- "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-uBcbIAPcqeJUOpessqf2Za6Jid/Negn0x3wJlTcby391gsPUYDgsGzOmDZMs/rFKQ+/xtz/DAd7VThJZC5fnIQ=="],
+ "@daytona/sdk": ["@daytona/sdk@0.200.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytona/analytics-api-client": "0.200.0", "@daytona/api-client": "0.200.0", "@daytona/toolbox-api-client": "0.200.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-http": "^0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.40.0", "axios": "^1.15.2", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "socket.io-client": "^4.8.1", "tar": "^7.5.11" } }, "sha512-0THBDtSUvMSCRTK11gc5CWDobwBlMm5Sz+UiyWplW6f/BMnAlhN/yyqLQPpVfWEtY3llPm2kXkzDwaR2fXQarA=="],
- "@daytonaio/sdk": ["@daytonaio/sdk@0.197.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytona/analytics-api-client": "0.197.0", "@daytona/api-client": "0.197.0", "@daytona/toolbox-api-client": "0.197.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-http": "^0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.40.0", "axios": "^1.15.2", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^7.5.11" } }, "sha512-RFrK/TLZy8S0VyQcXOzOv70VKNUoUyJ45u/HlCJjmXu5vyK3TH0pQJv9yJn8uT49W+4ypMSODcz0YJIRjH16VA=="],
+ "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-yj4u7wApHz53ayHRNX408nuazfo4AT+GlFjx35LPyxyC2ffOF4TPrVR0pQxYWQt+lg61GFNI9xr9Ig0oXaKT8w=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
@@ -1167,7 +1164,7 @@
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
- "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
+ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -1493,47 +1490,47 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
- "@react-email/body": ["@react-email/body@0.1.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ=="],
+ "@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="],
- "@react-email/button": ["@react-email/button@0.2.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ=="],
+ "@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
- "@react-email/code-block": ["@react-email/code-block@0.1.0", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww=="],
+ "@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="],
- "@react-email/code-inline": ["@react-email/code-inline@0.0.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA=="],
+ "@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="],
- "@react-email/column": ["@react-email/column@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ=="],
+ "@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="],
- "@react-email/components": ["@react-email/components@0.5.7", "", { "dependencies": { "@react-email/body": "0.1.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.1.0", "@react-email/code-inline": "0.0.5", "@react-email/column": "0.0.13", "@react-email/container": "0.0.15", "@react-email/font": "0.0.9", "@react-email/head": "0.0.12", "@react-email/heading": "0.0.15", "@react-email/hr": "0.0.11", "@react-email/html": "0.0.11", "@react-email/img": "0.0.11", "@react-email/link": "0.0.12", "@react-email/markdown": "0.0.16", "@react-email/preview": "0.0.13", "@react-email/render": "1.4.0", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", "@react-email/tailwind": "1.2.2", "@react-email/text": "0.1.5" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ECyVoyDcev2FSQ7C0buXaIJ0+6MRDXNUbCOZwBRrlLdCCRjap2b4+MHrYSTXFzo5kqfjjRoyo/2PbJXFQni67g=="],
+ "@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="],
- "@react-email/container": ["@react-email/container@0.0.15", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg=="],
+ "@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="],
- "@react-email/font": ["@react-email/font@0.0.9", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw=="],
+ "@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="],
- "@react-email/head": ["@react-email/head@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA=="],
+ "@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="],
- "@react-email/heading": ["@react-email/heading@0.0.15", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg=="],
+ "@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="],
- "@react-email/hr": ["@react-email/hr@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw=="],
+ "@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="],
- "@react-email/html": ["@react-email/html@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA=="],
+ "@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="],
- "@react-email/img": ["@react-email/img@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ=="],
+ "@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="],
- "@react-email/link": ["@react-email/link@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ=="],
+ "@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="],
- "@react-email/markdown": ["@react-email/markdown@0.0.16", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KSUHmoBMYhvc6iGwlIDkm0DRGbGQ824iNjLMCJsBVUoKHGQYs7F/N3b1tnS1YzRUX+GwHIexSsHuIUEi1m+8OQ=="],
+ "@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="],
- "@react-email/preview": ["@react-email/preview@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w=="],
+ "@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="],
- "@react-email/render": ["@react-email/render@2.0.8", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ=="],
+ "@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="],
- "@react-email/row": ["@react-email/row@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ=="],
+ "@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="],
- "@react-email/section": ["@react-email/section@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w=="],
+ "@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="],
- "@react-email/tailwind": ["@react-email/tailwind@1.2.2", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw=="],
+ "@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="],
- "@react-email/text": ["@react-email/text@0.1.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg=="],
+ "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="],
"@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="],
@@ -2201,7 +2198,7 @@
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
- "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
+ "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
@@ -2317,7 +2314,7 @@
"chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="],
- "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
+ "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
@@ -2327,8 +2324,6 @@
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
- "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
-
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -2363,6 +2358,8 @@
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
+ "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
+
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
@@ -2409,6 +2406,8 @@
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
+ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
+
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
@@ -2505,6 +2504,8 @@
"debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="],
+ "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -2569,6 +2570,8 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+ "dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
+
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
@@ -2615,7 +2618,9 @@
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
- "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
+ "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
@@ -2761,12 +2766,8 @@
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
- "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
-
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
- "free-email-domains": ["free-email-domains@1.2.25", "", {}, "sha512-Uf2rJUjo/agIgQzt6od9XcHrR6rfIMD6TwsNVSJVJCHzjPWWsqjCb+EaQ2VVVY9M55+JB3V0k6ru5sHTGx/ZfA=="],
-
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -2895,6 +2896,8 @@
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+ "html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="],
+
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -2971,8 +2974,6 @@
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
- "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
-
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
@@ -3007,9 +3008,9 @@
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
- "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
+ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
- "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
@@ -3205,6 +3206,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
+
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
@@ -3393,8 +3396,6 @@
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
- "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
-
"notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
@@ -3405,7 +3406,7 @@
"nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="],
- "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="],
+ "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -3445,8 +3446,6 @@
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
- "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
-
"orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -3509,6 +3508,8 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+ "picospinner": ["picospinner@3.0.0", "", {}, "sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg=="],
+
"pidtree": ["pidtree@0.6.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -3639,7 +3640,7 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
- "react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="],
+ "react-email": ["react-email@6.9.0", "", { "dependencies": { "@babel/parser": "7.29.2", "@babel/traverse": "7.29.0", "@react-email/render": ">=2.1.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "conf": "^15.0.2", "css-tree": "3.2.1", "debounce": "^2.0.0", "esbuild": "^0.28.0", "glob": "^13.0.6", "jiti": "2.6.1", "log-symbols": "^7.0.0", "marked": "^15.0.12", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.6", "picospinner": "^3.0.0", "prismjs": "^1.30.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tailwindcss": "^4.1.18", "tsconfig-paths": "4.2.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" }, "bin": { "email": "./dist/cli/index.mjs" } }, "sha512-72jV+VkeeXgNWDycNDn2tIlHFLg4Hevi3pC77g63FAY1+bDgTs4b56jbZfHF1t5d+GVGb02sGS8bOwoptgvI1w=="],
"react-hook-form": ["react-hook-form@7.79.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw=="],
@@ -3891,8 +3892,6 @@
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
- "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
-
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
"stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="],
@@ -3937,6 +3936,10 @@
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
+ "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
+
+ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
+
"stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
@@ -3961,6 +3964,8 @@
"systeminformation": ["systeminformation@5.23.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ=="],
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
@@ -3969,7 +3974,7 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
+ "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
@@ -4047,6 +4052,8 @@
"twilio": ["twilio@5.9.0", "", { "dependencies": { "axios": "^1.11.0", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.2", "qs": "^6.9.4", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" } }, "sha512-Ij+xT9MZZSjP64lsy+x6vYsCCb5m2Db9KffkMXBrN3zWbG3rbkXxl+MZVVzrvpwEdSbQD0vMuin+TTlQ6kR6Xg=="],
+ "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
+
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
@@ -4059,6 +4066,8 @@
"uid2": ["uid2@1.0.0", "", {}, "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ=="],
+ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
+
"ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
@@ -4153,6 +4162,8 @@
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
+ "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
+
"which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -4251,12 +4262,26 @@
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/core/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
+ "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
+ "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
"@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -4269,13 +4294,13 @@
"@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="],
- "@daytonaio/sdk/@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="],
+ "@daytona/sdk/@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="],
+ "@daytona/sdk/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="],
- "@daytonaio/sdk/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="],
"@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
@@ -4303,6 +4328,10 @@
"@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
+ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+
+ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+
"@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -4487,7 +4516,7 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="],
- "@react-email/components/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="],
+ "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="],
"@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -4575,6 +4604,10 @@
"@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+ "@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
"@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
@@ -4619,6 +4652,8 @@
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
+ "c12/jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
+
"c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
@@ -4635,6 +4670,8 @@
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+ "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
@@ -4653,16 +4690,12 @@
"docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="],
- "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
-
"duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"e2b/@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="],
"e2b/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
- "e2b/tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="],
-
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
@@ -4723,6 +4756,10 @@
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
+ "giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
+
+ "giget/nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="],
+
"google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
@@ -4761,6 +4798,8 @@
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
"make-dir/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -4791,14 +4830,8 @@
"nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
- "nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
-
- "nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
-
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
-
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -4835,7 +4868,11 @@
"react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
- "react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
+ "react-email/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
+
+ "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
+
+ "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
@@ -4851,6 +4888,8 @@
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+ "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
+
"sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
@@ -4917,6 +4956,10 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
"@better-auth/sso/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
"@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
@@ -4925,53 +4968,53 @@
"@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
+ "@daytona/sdk/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/configuration": ["@opentelemetry/configuration@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/configuration": ["@opentelemetry/configuration@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
+ "@daytona/sdk/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
"@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
@@ -5025,6 +5068,8 @@
"@google-cloud/storage/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
+ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -5229,6 +5274,10 @@
"gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
+ "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
+
+ "giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+
"google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -5237,8 +5286,6 @@
"gtoken/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
- "html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
-
"jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
@@ -5303,12 +5350,8 @@
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
- "nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
-
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
- "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
-
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="],
@@ -5327,7 +5370,57 @@
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
- "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+ "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
+
+ "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
+
+ "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
+
+ "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
+
+ "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
+
+ "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
+
+ "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
+
+ "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
+
+ "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
+
+ "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
+
+ "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
+
+ "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
+
+ "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
+
+ "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
+
+ "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
+
+ "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
+
+ "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
+
+ "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
+
+ "react-email/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
+
+ "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
+
+ "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
+
+ "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
+
+ "react-email/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
+
+ "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
+
+ "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
+
+ "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
@@ -5415,27 +5508,27 @@
"@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="],
- "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="],
+ "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
- "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
+ "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
"@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
@@ -5455,14 +5548,14 @@
"@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "giget/nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
+
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
- "rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
-
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -5473,12 +5566,6 @@
"@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
- "rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
-
- "rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
-
"sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
-
- "rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
}
}
From 7567216f87ad9f8c03ac32173cdf12cb88b7478f Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Tue, 28 Jul 2026 16:36:49 -0700
Subject: [PATCH 2/2] fix(security): close glob translation fail-opens and zip
guard bypasses
Glob matcher (RE2 translation layer):
- reject picomatch escape passthrough (\A, \z, \p{L}) that RE2 reads as
anchors and Unicode classes, bypassing the dot:false guarantee
- reject character-class ranges straddling the private-use segment markers
- restore negated (!) glob semantics instead of silently matching nothing
- parse zero-padded repeat bounds numerically
Zip guard:
- scan the whole buffer for EOCD candidates, matching JSZip and SheetJS
rather than the spec window
- treat a resolvable-but-empty central directory as unverifiable
- keep stray EOCD byte sequences in non-ZIP documents a no-op so the
OLE2 and plaintext fallbacks still run
- bound central-directory scanning with a shared record budget
Redaction:
- fix quadratic acronym-boundary backtracking (73s to 1ms at 400k chars)
---
apps/sim/app/api/tools/video/route.test.ts | 200 ++++++++++++++
apps/sim/app/api/tools/video/route.ts | 19 +-
.../components/file-viewer/file-viewer.tsx | 2 +-
.../file-viewer/preview-shared.test.tsx | 14 +
.../components/file-viewer/preview-shared.tsx | 11 +-
apps/sim/executor/orchestrators/loop.ts | 6 +-
.../lib/copilot/vfs/document-style.test.ts | 44 +++-
.../vfs/operations.glob-semantics.test.ts | 99 ++++++-
apps/sim/lib/copilot/vfs/operations.ts | 110 +++++++-
apps/sim/lib/core/security/redaction.test.ts | 108 +++++++-
apps/sim/lib/core/security/redaction.ts | 92 +++++--
.../secure-fetch-redirect.server.test.ts | 2 +
apps/sim/lib/file-parsers/zip-guard.test.ts | 154 ++++++++++-
apps/sim/lib/file-parsers/zip-guard.ts | 247 +++++++++++++-----
apps/sim/lib/media/falai.test.ts | 29 +-
apps/sim/lib/media/ffmpeg.test.ts | 44 ++--
apps/sim/lib/media/ffmpeg.ts | 18 +-
17 files changed, 1057 insertions(+), 142 deletions(-)
diff --git a/apps/sim/app/api/tools/video/route.test.ts b/apps/sim/app/api/tools/video/route.test.ts
index 576b487a0ae..5e120b8b947 100644
--- a/apps/sim/app/api/tools/video/route.test.ts
+++ b/apps/sim/app/api/tools/video/route.test.ts
@@ -57,6 +57,17 @@ function videoResponse() {
}
}
+function errorResponse(status: number) {
+ return {
+ ok: false,
+ status,
+ headers: { get: () => null },
+ body: null,
+ text: async () => 'denied',
+ arrayBuffer: async () => new ArrayBuffer(0),
+ }
+}
+
const baseBody = {
provider: 'falai',
apiKey: 'fal-key',
@@ -134,3 +145,192 @@ describe('POST /api/tools/video (Fal.ai queue)', () => {
])
})
})
+
+/**
+ * Runway, Veo, Luma and MiniMax all download the finished asset through
+ * `downloadVideoFromUrl` (the SSRF-guarded client), each with its own label and
+ * error prefix. These cover that plumbing per provider.
+ */
+describe('POST /api/tools/video (provider download paths)', () => {
+ const fetchMock = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', fetchMock)
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' })
+ mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' })
+ })
+
+ function apiResponse(payload: unknown) {
+ return new Response(JSON.stringify(payload), { status: 200 })
+ }
+
+ function veoFetches(uri: string) {
+ fetchMock.mockResolvedValueOnce(apiResponse({ name: 'operations/op-1' })).mockResolvedValueOnce(
+ apiResponse({
+ done: true,
+ response: { generateVideoResponse: { generatedSamples: [{ video: { uri } }] } },
+ })
+ )
+ }
+
+ it('downloads the Runway asset through the guarded client', async () => {
+ fetchMock
+ .mockResolvedValueOnce(apiResponse({ id: 'task-1' }))
+ .mockResolvedValueOnce(
+ apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] })
+ )
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'runway',
+ apiKey: 'runway-key',
+ model: 'gen-4',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.runwayml.test/a.mp4')
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
+ })
+
+ it('surfaces the default download error prefix when the Runway asset fetch fails', async () => {
+ fetchMock
+ .mockResolvedValueOnce(apiResponse({ id: 'task-1' }))
+ .mockResolvedValueOnce(
+ apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] })
+ )
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401))
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'runway',
+ apiKey: 'runway-key',
+ model: 'gen-4',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(500)
+ expect((await response.json()).error).toBe('Failed to download video: 401')
+ })
+
+ it('downloads the Luma asset through the guarded client', async () => {
+ fetchMock
+ .mockResolvedValueOnce(apiResponse({ id: 'gen-1' }))
+ .mockResolvedValueOnce(
+ apiResponse({ state: 'completed', assets: { video: 'https://cdn.lumalabs.test/a.mp4' } })
+ )
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'luma',
+ apiKey: 'luma-key',
+ model: 'ray-2',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.lumalabs.test/a.mp4')
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
+ })
+
+ it('keeps the MiniMax-specific download error prefix', async () => {
+ fetchMock
+ .mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' }))
+ .mockResolvedValueOnce(
+ apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' })
+ )
+ .mockResolvedValueOnce(
+ apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } })
+ )
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401))
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'minimax',
+ apiKey: 'minimax-key',
+ model: 'hailuo-2.3',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(500)
+ expect((await response.json()).error).toBe('Failed to download video from URL: 401')
+ })
+
+ it('downloads the MiniMax asset through the guarded client', async () => {
+ fetchMock
+ .mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' }))
+ .mockResolvedValueOnce(
+ apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' })
+ )
+ .mockResolvedValueOnce(
+ apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } })
+ )
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'minimax',
+ apiKey: 'minimax-key',
+ model: 'hailuo-2.3',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.minimax.test/a.mp4')
+ })
+
+ it('attaches the Veo API key only for a genuine https Google API host', async () => {
+ veoFetches('https://generativelanguage.googleapis.com/v1beta/files/a:download')
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'veo',
+ apiKey: 'veo-key',
+ model: 'veo-3',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toEqual({
+ 'x-goog-api-key': 'veo-key',
+ })
+ })
+
+ it.each([
+ ['a suffix-spoofed host', 'https://evil.googleapis.com.attacker.test/a.mp4'],
+ ['a prefix-spoofed host', 'https://xgoogleapis.com/a.mp4'],
+ ['plaintext http', 'http://generativelanguage.googleapis.com/a.mp4'],
+ ])('withholds the Veo API key for %s', async (_label, uri) => {
+ veoFetches(uri)
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
+
+ const response = await POST(
+ createMockRequest('POST', {
+ provider: 'veo',
+ apiKey: 'veo-key',
+ model: 'veo-3',
+ prompt: 'a cat riding a bike',
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe(uri)
+ expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
+ })
+})
diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts
index 7f832dea74b..e3a2553d815 100644
--- a/apps/sim/app/api/tools/video/route.ts
+++ b/apps/sim/app/api/tools/video/route.ts
@@ -550,6 +550,15 @@ async function generateWithRunway(
throw new Error('Runway generation timed out')
}
+/** Host of a provider-supplied URI for logging, without leaking the path or query. */
+function safeHostname(url: string): string {
+ try {
+ return new URL(url).host
+ } catch {
+ return 'unparseable'
+ }
+}
+
/**
* True when a provider-supplied URI is served by Google. The Veo download URI comes out of
* the operation status body, so the `x-goog-api-key` credential is only attached when the
@@ -662,9 +671,17 @@ async function generateWithVeo(
throw new Error('No video URI in response')
}
+ const isGoogleHosted = isGoogleApiHost(videoUri)
+ if (!isGoogleHosted) {
+ logger.warn(
+ `[${requestId}] Veo download URI is not a Google API host; sending the request unauthenticated. A 401 here means the URI host is wrong, not the API key.`,
+ { host: safeHostname(videoUri) }
+ )
+ }
+
return {
buffer: await downloadVideoFromUrl(videoUri, 'Veo video', {
- headers: isGoogleApiHost(videoUri) ? { 'x-goog-api-key': apiKey } : undefined,
+ headers: isGoogleHosted ? { 'x-goog-api-key': apiKey } : undefined,
}),
width: dimensions.width,
height: dimensions.height,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index daf99a09dac..44114a8bef4 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -37,7 +37,7 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
* `lib/pptx-renderer/renderer/chart-renderer`) stays out of every bundle that
* statically imports this viewer - the Files page, the Home view and the public
* share page - instead of only the visitors who open a PowerPoint file. The
- * fallback matches {@link PptxPreview}'s own pre-fetch frame, so the chunk load
+ * fallback matches the frame `./pptx-preview` renders while it fetches, so the chunk load
* and the binary fetch look like one continuous loading state. Rendered inside a
* {@link PreviewErrorBoundary} so a rejected chunk load degrades to the preview
* fallback — which offers a page reload, the only way to refetch a chunk whose
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
index ce059eab717..8367b9cdc63 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx
@@ -84,6 +84,20 @@ describe('PreviewErrorBoundary', () => {
expect(actionLabels()).toEqual(['Reload page'])
})
+ it('offers a page reload for a failed CSS chunk, whose message omits "Loading chunk"', async () => {
+ const Broken = lazy(() => Promise.reject(new Error('Loading CSS chunk 4821 failed')))
+
+ await render(
+
+ loading}>
+
+
+
+ )
+
+ expect(actionLabels()).toEqual(['Reload page'])
+ })
+
it('renders children when nothing throws', async () => {
await render(
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
index fe8ab0c7bbb..f35a331aebe 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
@@ -28,7 +28,7 @@ export function PreviewError({ label, error, action }: PreviewErrorProps) {
{error}
{action ? (
-
+
{action.label}
) : null}
@@ -36,6 +36,13 @@ export function PreviewError({ label, error, action }: PreviewErrorProps) {
)
}
+/**
+ * webpack's chunk-load failure messages. The JS form is `Loading chunk 5 failed`
+ * and the stylesheet form is `Loading CSS chunk 5 failed`, so a plain
+ * `Loading chunk` substring test misses every CSS chunk 404.
+ */
+const CHUNK_LOAD_MESSAGE = /Loading (?:CSS )?chunk/
+
/**
* A `next/dynamic` / `React.lazy` chunk fetch that rejected. The module system
* caches the rejection on the lazy component itself, so re-rendering it throws
@@ -45,7 +52,7 @@ function isChunkLoadError(error: Error | undefined): boolean {
if (!error) return false
if (error.name === 'ChunkLoadError') return true
return (
- error.message.includes('Loading chunk') || error.message.includes('dynamically imported module')
+ CHUNK_LOAD_MESSAGE.test(error.message) || error.message.includes('dynamically imported module')
)
}
diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts
index a3feb87ca51..ccba48f98f2 100644
--- a/apps/sim/executor/orchestrators/loop.ts
+++ b/apps/sim/executor/orchestrators/loop.ts
@@ -732,10 +732,8 @@ export class LoopOrchestrator {
if (lower === 'true' || lower === 'false') {
return lower
}
- /**
- * Serialized rather than hand-quoted: the value is a block output, so a `"` or
- * newline would close the literal early and run as code in the condition VM.
- */
+ // Serialized rather than hand-quoted: the value is a block output, so a `"` or
+ // newline would close the literal early and run as code in the condition VM.
return JSON.stringify(resolved)
}
return JSON.stringify(resolved)
diff --git a/apps/sim/lib/copilot/vfs/document-style.test.ts b/apps/sim/lib/copilot/vfs/document-style.test.ts
index ca344da35b7..4f6575a9818 100644
--- a/apps/sim/lib/copilot/vfs/document-style.test.ts
+++ b/apps/sim/lib/copilot/vfs/document-style.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import JSZip from 'jszip'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
@@ -37,14 +37,56 @@ function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number)
}
describe('extractDocumentStyle zip-bomb guard', () => {
+ /**
+ * `extractDocumentStyle` swallows every failure and returns `null`, and a
+ * forged archive is one JSZip rejects on its own — so asserting `null` passes
+ * with the guard deleted. Spying on the decompressor is what actually proves
+ * the buffer never reached it.
+ */
+ let loadAsync: ReturnType>
+
+ beforeEach(() => {
+ loadAsync = vi.spyOn(JSZip, 'loadAsync')
+ })
+
+ afterEach(() => {
+ loadAsync.mockRestore()
+ })
+
it('refuses an archive whose declared expansion exceeds the limit', async () => {
const bomb = forgeDeclaredUncompressedSize(await buildDocxArchive(), 0xfffffff0)
+
await expect(extractDocumentStyle(bomb, 'docx')).resolves.toBeNull()
+ expect(loadAsync).not.toHaveBeenCalled()
+ })
+
+ it('refuses a bomb whose EOCD lies about the entry count behind an empty-EOCD decoy', async () => {
+ const bomb = forgeDeclaredUncompressedSize(await buildDocxArchive(), 0xfffffff0)
+ const eocdOffset = bomb.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
+ const cdOffset = bomb.readUInt32LE(eocdOffset + 16)
+ const decoy = Buffer.alloc(22)
+ decoy.writeUInt32LE(0x06054b50, 0)
+
+ const realEocd = Buffer.from(bomb.subarray(eocdOffset))
+ realEocd.writeUInt16LE(bomb.readUInt16LE(eocdOffset + 10) + 1, 8)
+ realEocd.writeUInt16LE(bomb.readUInt16LE(eocdOffset + 10) + 1, 10)
+ realEocd.writeUInt32LE(cdOffset + decoy.length, 16)
+
+ const attack = Buffer.concat([
+ bomb.subarray(0, cdOffset),
+ decoy,
+ bomb.subarray(cdOffset, eocdOffset),
+ realEocd,
+ ])
+
+ await expect(extractDocumentStyle(attack, 'docx')).resolves.toBeNull()
+ expect(loadAsync).not.toHaveBeenCalled()
})
it('still extracts style from a well-formed archive', async () => {
const summary = await extractDocumentStyle(await buildDocxArchive(), 'docx')
+ expect(loadAsync).toHaveBeenCalledTimes(1)
expect(summary).not.toBeNull()
expect(summary?.theme?.fonts.minor).toBe('Calibri')
expect(summary?.theme?.colors.accent1).toBe('4472C4')
diff --git a/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
index a8ae7b5c290..617055f372a 100644
--- a/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
+++ b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts
@@ -3,7 +3,12 @@
*/
import micromatch from 'micromatch'
import { describe, expect, it } from 'vitest'
-import { compileGlobMatcher, VFS_GLOB_OPTIONS } from '@/lib/copilot/vfs/operations'
+import {
+ compileGlobMatcher,
+ glob,
+ pathWithinGrepScope,
+ VFS_GLOB_OPTIONS,
+} from '@/lib/copilot/vfs/operations'
/**
* Differential test: {@link compileGlobMatcher} runs micromatch's own compiled pattern on
@@ -24,6 +29,9 @@ const ASTRAL = '\u{1F600}'
const HIGH_SURROGATE = '\uD83D'
const LOW_SURROGATE = '\uDE00'
+/** A class range wide enough to bracket the private-use markers the translation reserves. */
+const WIDE_CLASS = '[\u{2000}-\u{F000}]'
+
/**
* Atoms for the exhaustive grid. Negated classes and parentheses are included because
* picomatch treats `[^…]` and `(` specially even under `noext`, and neither goes through
@@ -31,7 +39,10 @@ const LOW_SURROGATE = '\uDE00'
* marker slot opened inside one branch of a group is not the one the next branch or the
* position after the group needs. The astral and lone-surrogate atoms are included because
* picomatch counts UTF-16 code units and RE2 counts code points, so every `?` and `[^…]`
- * next to one is a place the two engines can disagree.
+ * next to one is a place the two engines can disagree. The escape atoms and the wide class
+ * range are included because picomatch passes a caller's `\X` and `[x-y]` through verbatim and
+ * RE2 reads several of those differently than ECMAScript does, and `!` because it is the one
+ * glob feature whose compiled shape is a lookahead.
*/
const ATOMS = [
'a',
@@ -60,6 +71,11 @@ const ATOMS = [
'(',
')',
'|',
+ '!',
+ '\\a',
+ '\\A',
+ '\\p{L}',
+ WIDE_CLASS,
ASTRAL,
HIGH_SURROGATE,
LOW_SURROGATE,
@@ -228,6 +244,18 @@ function buildPatterns(): string[] {
return Array.from(patterns)
}
+/**
+ * Atoms RE2 cannot be handed at all: it reads `\A` and `\z` as anchors, `\a` as BEL, `\p{…}`
+ * as a Unicode class, and a range spanning the private-use block would consume a segment
+ * marker. They are compared like every other pattern but held out of the unrepresentable
+ * ratio, which measures how often a pattern someone might type is refused.
+ */
+const REFUSED_ATOMS = ['\\a', '\\A', '\\p{L}', WIDE_CLASS] as const
+
+function containsRefusedAtom(pattern: string): boolean {
+ return REFUSED_ATOMS.some((atom) => pattern.includes(atom))
+}
+
function expectAgreement(pattern: string, path: string) {
const matcher = compileGlobMatcher(pattern)
const expected = micromatch.isMatch(path, pattern, GLOB_OPTIONS)
@@ -241,12 +269,15 @@ describe('glob matcher equivalence with micromatch', () => {
const patterns = buildPatterns()
const mismatches: string[] = []
let unrepresentable = 0
+ let translatable = 0
let compared = 0
for (const pattern of patterns) {
const matcher = compileGlobMatcher(pattern)
+ const held = containsRefusedAtom(pattern)
+ if (!held) translatable++
if (!matcher) {
- unrepresentable++
+ if (!held) unrepresentable++
continue
}
for (const path of PATHS) {
@@ -263,7 +294,7 @@ describe('glob matcher equivalence with micromatch', () => {
expect(mismatches).toEqual([])
expect(compared).toBeGreaterThan(6_000_000)
- expect(unrepresentable / patterns.length).toBeLessThan(0.08)
+ expect(unrepresentable / translatable).toBeLessThan(0.08)
}, 120_000)
it('covers the shapes the translation keys on', () => {
@@ -337,6 +368,66 @@ describe('glob matcher equivalence with micromatch', () => {
expect(compileGlobMatcher('(a|b)?*')?.matches('.hidden')).toBe(true)
})
+ it('refuses the escape alphabet picomatch never emits', () => {
+ // picomatch escapes only punctuation, so `\A`, `\z`, `\a` and `\p{…}` are caller text it
+ // passes through. RE2 reads them as anchors, BEL and a Unicode class — and `\p{Any}`
+ // consumes the segment markers, taking `dot: false` with it.
+ for (const [pattern, path] of [
+ ['\\A**/**', 'secrets/prod.key'],
+ ['\\A**/**', 'README.md'],
+ ['\\p{Any}**/**', '.git/config'],
+ ['*\\z', 'README.md'],
+ ['[^\\a]', 'a'],
+ ] as const) {
+ expectAgreement(pattern, path)
+ }
+ // `[\a-z]` is the fail-safe half of the same rule: ECMAScript reads `\a` as a literal `a`
+ // and matches, and refusing costs a match rather than granting one.
+ expect(compileGlobMatcher('[\\a-z]')).toBeNull()
+ expect(compileGlobMatcher('\\A**/**')).toBeNull()
+ expect(pathWithinGrepScope('secrets/prod.key', '\\A**/**')).toBe(false)
+ })
+
+ it('refuses a class range spanning the reserved code points', () => {
+ // The range brackets the segment markers and the surrogate escape block, so the class
+ // consumes a marker where micromatch requires a real character and every later atom
+ // matches one position to the left.
+ for (const [pattern, path] of [
+ [`${WIDE_CLASS}.env`, '.env'],
+ [`${WIDE_CLASS}*`, '.env'],
+ [`${WIDE_CLASS}.git/config`, '.git/config'],
+ [`${WIDE_CLASS}..`, '..'],
+ [`[^a${WIDE_CLASS.slice(1)}`, '.env'],
+ ] as const) {
+ expectAgreement(pattern, path)
+ }
+ // A pair of such classes spans both halves of a remapped astral character, which is the
+ // fail-safe half of the rule: micromatch matches it and refusing costs that match.
+ expect(compileGlobMatcher(`${WIDE_CLASS}${WIDE_CLASS}`)).toBeNull()
+ expect(compileGlobMatcher(`${WIDE_CLASS}.env`)).toBeNull()
+ expect(pathWithinGrepScope('.env', `${WIDE_CLASS}*`)).toBe(false)
+ })
+
+ it('matches negated patterns instead of silently matching nothing', () => {
+ const files = new Map([
+ ['a.ts', ''],
+ ['b.js', ''],
+ ])
+ expect(glob(files, '!*.ts')).toEqual(['b.js'])
+ for (const pattern of ['!*.ts', '!.env', '!**/*.md', '!a', '!', '!!a', '!!*.ts', '!(a)']) {
+ for (const path of PATHS) {
+ expectAgreement(pattern, path)
+ }
+ }
+ })
+
+ it('refuses a zero-padded repeat bound RE2 reads as literal text', () => {
+ for (const pattern of ['a{00}b', 'a{00,2}b', '.{00,}*', 'a{1,02}b']) {
+ expect(compileGlobMatcher(pattern), pattern).toBeNull()
+ }
+ expect(compileGlobMatcher('a{0,2}b'), 'a{0,2}b').not.toBeNull()
+ })
+
it('rejects the empty path the way micromatch does', () => {
for (const pattern of ['**', '**/**', '**/', '**/**/**', '\\']) {
expectAgreement(pattern, '')
diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts
index 2f12df94afc..2d19741d809 100644
--- a/apps/sim/lib/copilot/vfs/operations.ts
+++ b/apps/sim/lib/copilot/vfs/operations.ts
@@ -171,6 +171,18 @@ const SURROGATE_CHAR = /[\uD800-\uDFFF]/
*/
const RESERVED_CHAR = /[\u{E000}-\u{E002}\u{E800}-\u{EFFF}]/u
+/** The bounds of {@link RESERVED_CHAR}, for rejecting a class range that spans them. */
+const RESERVED_FIRST = 0xe000
+const RESERVED_LAST = 0xefff
+
+/**
+ * Escapes picomatch never emits — it escapes only punctuation. A `\` before a word character
+ * is therefore caller text passed through verbatim, and RE2 reads several of those as
+ * something ECMAScript does not: `\A` and `\z` are zero-width anchors rather than literals,
+ * and `\p{…}` is a Unicode class that would consume a segment marker.
+ */
+const WORD_ESCAPE = /[A-Za-z0-9]/
+
/** Rewrite every surrogate code unit to its {@link SURROGATE_ESCAPE_BASE} counterpart. */
function escapeSurrogates(text: string): string {
if (!SURROGATE_CHAR.test(text)) return text
@@ -293,6 +305,48 @@ function classEnd(source: string, at: number): number {
return i < source.length ? i + 1 : -1
}
+/**
+ * True when a character-class body can be handed to RE2 unchanged.
+ *
+ * Two shapes cannot. A `\` escape is caller passthrough RE2 reinterprets (see
+ * {@link WORD_ESCAPE}). A range whose endpoints bracket the reserved block lets the class
+ * consume a segment marker or half a remapped astral character, which shifts every later
+ * atom one position left — `[\u{2000}-\u{F000}].env` would match `.env` by eating its
+ * {@link SEG_DOT}, bypassing `dot: false` entirely.
+ */
+function classBodyIsRepresentable(body: string): boolean {
+ let previous = -1
+ let i = 0
+ while (i < body.length) {
+ if (body[i] === '-' && previous >= 0 && i + 1 < body.length) {
+ i += 1
+ let high: number
+ if (body[i] === '\\') {
+ const next = body[i + 1]
+ if (next === undefined || WORD_ESCAPE.test(next)) return false
+ high = next.charCodeAt(0)
+ i += 2
+ } else {
+ high = body.charCodeAt(i)
+ i += 1
+ }
+ if (previous <= RESERVED_LAST && high >= RESERVED_FIRST) return false
+ previous = -1
+ continue
+ }
+ if (body[i] === '\\') {
+ const next = body[i + 1]
+ if (next === undefined || WORD_ESCAPE.test(next)) return false
+ previous = next.charCodeAt(0)
+ i += 2
+ continue
+ }
+ previous = body.charCodeAt(i)
+ i += 1
+ }
+ return true
+}
+
/** Index of the `)` closing the group opened at `at`, or -1. */
function groupEnd(source: string, at: number): number {
let depth = 0
@@ -329,14 +383,20 @@ function readQuantifier(source: string, at: number): string {
return ''
}
+/**
+ * True when a `{n,m}` bound is zero-padded. RE2 reads `a{00}` as the literal text `a{00}`
+ * where ECMAScript reads a repeat, so the two engines cannot be reconciled and the source is
+ * refused rather than emitted with a silently different meaning.
+ */
+function hasPaddedBound(quantifier: string): boolean {
+ return /^\{0\d|,0\d/.test(quantifier)
+}
+
/** True when `quantifier` lets the atom it follows match nothing. */
function isOptionalQuantifier(quantifier: string): boolean {
- return (
- quantifier.startsWith('?') ||
- quantifier.startsWith('*') ||
- quantifier.startsWith('{0,') ||
- quantifier === '{0}'
- )
+ if (quantifier.startsWith('?') || quantifier.startsWith('*')) return true
+ const bounded = /^\{(\d+)(?:,\d*)?\}$/.exec(quantifier)
+ return bounded !== null && Number(bounded[1]) === 0
}
/** True when every match of the balanced token run `[start, end)` consumes at least one character. */
@@ -667,6 +727,7 @@ function rewriteGlobSource(generated: string): string | null {
i += 1
const quantifier = readQuantifier(source, i)
if (quantifier) {
+ if (hasPaddedBound(quantifier)) return null
parts.push(quantifier)
i += quantifier.length
}
@@ -716,7 +777,7 @@ function rewriteGlobSource(generated: string): string | null {
let isSlash = false
if (char === '\\') {
const next = source[i + 1]
- if (next === undefined) return null
+ if (next === undefined || WORD_ESCAPE.test(next)) return null
atom = `\\${next}`
atomEnd = i + 2
isSlash = next === '/'
@@ -725,6 +786,7 @@ function rewriteGlobSource(generated: string): string | null {
if (end === -1) return null
const negated = source[i + 1] === '^'
const body = source.slice(negated ? i + 2 : i + 1, end - 1)
+ if (!classBodyIsRepresentable(body)) return null
atom = negated ? `[^${body}${ALL_MARKERS_RE}]` : `[${body}]`
atomEnd = end
} else if (char === '.') {
@@ -739,6 +801,7 @@ function rewriteGlobSource(generated: string): string | null {
}
const quantifier = readQuantifier(source, atomEnd)
+ if (hasPaddedBound(quantifier)) return null
const optional = isOptionalQuantifier(quantifier)
const atBoundary = boundary
// A class picomatch did not exclude `/` from (its POSIX expansions, and the `.` those
@@ -834,6 +897,23 @@ function markPathSegments(path: string): string | null {
.join('/')
}
+/**
+ * The inner regex of picomatch's negation wrapper `^(?!^(?:X)$).*$` as a standalone
+ * `^(?:X)$`, or `null` when `source` is not one.
+ *
+ * A `!`-prefixed glob is a documented micromatch feature, and the wrapper's lookahead is the
+ * one shape RE2 cannot take. Reading the inner pattern off the generated source rather than
+ * off the pattern text inherits picomatch's own `!` rules exactly — leading position only,
+ * `!!` cancelling, `!(` not counting — instead of restating them here.
+ */
+function negatedInnerSource(source: string): string | null {
+ const prefix = '^(?!^(?:'
+ const suffix = ')$).*$'
+ if (source.length < prefix.length + suffix.length) return null
+ if (!source.startsWith(prefix) || !source.endsWith(suffix)) return null
+ return groupEnd(source, 1) === source.length - 4 ? source.slice(4, source.length - 4) : null
+}
+
/**
* Compile a caller-supplied glob into a matcher that cannot backtrack.
*
@@ -842,9 +922,9 @@ function markPathSegments(path: string): string | null {
* and paths come from an authenticated caller's tool call.
*
* The regex is picomatch's own from `makeRe`, rewritten by {@link rewriteGlobSource}, so the
- * semantics of {@link VFS_GLOB_OPTIONS} are unchanged. Returns `null` when the pattern is over
- * the safety caps or is not RE2-representable; callers treat that as "matches nothing" rather
- * than falling back to the backtracking engine.
+ * semantics of {@link VFS_GLOB_OPTIONS} are unchanged. Returns `null` when the pattern is not
+ * RE2-representable; callers treat that as "matches nothing" rather than falling back to the
+ * backtracking engine. Throws {@link GlobPatternError} when the pattern is over the safety caps.
*/
export function compileGlobMatcher(pattern: string): GlobMatcher | null {
if (pattern.length > MAX_GLOB_PATTERN_LENGTH) {
@@ -869,7 +949,8 @@ export function compileGlobMatcher(pattern: string): GlobMatcher | null {
return null
}
- const source = rewriteGlobSource(generated.source)
+ const negatedBody = negatedInnerSource(generated.source)
+ const source = rewriteGlobSource(negatedBody ?? generated.source)
const linear = source === null ? null : compileLinearRegex(source)
if (!linear) {
logger.warn('Glob pattern is not RE2-representable; matching nothing', { pattern })
@@ -883,8 +964,12 @@ export function compileGlobMatcher(pattern: string): GlobMatcher | null {
// `+(a)` with `noext`. Both are kept so behaviour is unchanged.
if (path === '') return false
if (path === pattern) return true
+ // A negated pattern's wrapper ends in `.*$`, which under no `s` flag also requires the
+ // whole path to be free of line terminators.
+ if (negatedBody !== null && LINE_TERMINATOR.test(path)) return false
const marked = markPathSegments(path)
- return marked !== null && linear.test(marked)
+ if (marked === null) return false
+ return negatedBody !== null ? !linear.test(marked) : linear.test(marked)
},
}
}
@@ -903,6 +988,7 @@ function splitLinesForGrep(content: string): string[] {
* same matcher {@link glob} uses, so it cannot backtrack either. Other characters (including
* `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not parsed as
* glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under `files/...`.
+ * Throws {@link GlobPatternError} for a scope outside the safety caps, which both callers catch.
*
* Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider,
* keeping "what we materialize" identical to "what grep filters in".
diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts
index faab8688951..2df7e22d152 100644
--- a/apps/sim/lib/core/security/redaction.test.ts
+++ b/apps/sim/lib/core/security/redaction.test.ts
@@ -789,7 +789,16 @@ describe('originally-missed secret keys', () => {
'stripeKey',
'signingKey',
'privateKeyPem',
- 'session_id',
+ 'secretKey',
+ 'secretValue',
+ 'tokenValue',
+ 'secrets',
+ 'passwords',
+ 'accessTokens',
+ 'refreshTokens',
+ 'authTokens',
+ 'sessionTokens',
+ 'clientSecrets',
'ssn',
'connectionString',
'serviceAccountJson',
@@ -815,6 +824,30 @@ describe('originally-missed secret keys', () => {
expect(isSensitiveKey(key)).toBe(true)
expect(redactApiKeys({ [key]: 'https://example.com/x' })[key]).toBe(REDACTED_MARKER)
})
+
+ /** One-shot links whose path segment embeds the same token as the sibling `*Token`. */
+ const ONE_SHOT_LOCATORS = [
+ 'activationUrl',
+ 'inviteLink',
+ 'inviteUrl',
+ 'invitationLink',
+ 'magicLink',
+ 'resetLink',
+ 'verificationUrl',
+ 'confirmationLink',
+ ]
+
+ it.concurrent.each(ONE_SHOT_LOCATORS)('treats %s as sensitive', (key) => {
+ expect(isSensitiveKey(key)).toBe(true)
+ expect(redactApiKeys({ [key]: 'https://example.com/x/tok' })[key]).toBe(REDACTED_MARKER)
+ })
+
+ it.concurrent('redacts the value half of a secrets-manager record', () => {
+ const result = redactApiKeys({ secretKey: 'DB_URL', secretValue: 'postgres://u:p@h/db' })
+
+ expect(result.secretKey).toBe(REDACTED_MARKER)
+ expect(result.secretValue).toBe(REDACTED_MARKER)
+ })
})
describe('non-secret keys that must stay readable', () => {
@@ -835,7 +868,6 @@ describe('non-secret keys that must stay readable', () => {
'partitionKey',
'sortKey',
'keySkills',
- 'hasApiKey',
'apiKeyId',
'keyName',
'publicKey',
@@ -850,6 +882,20 @@ describe('non-secret keys that must stay readable', () => {
'session_recording_opt_in',
'private',
'activeSessions',
+ 'cursor',
+ 'nextCursor',
+ 'sessionId',
+ 'session_id',
+ 'subagentSessionId',
+ 'mcpSessionId',
+ 'authorizationUrl',
+ 'authorizationEndpoint',
+ 'SSO_OIDC_AUTHORIZATION_ENDPOINT',
+ 'BETTER_AUTH_URL',
+ 'profileUrl',
+ 'callbackUrl',
+ 'webhookUrl',
+ 'loginUrl',
]
it.concurrent.each(MUST_KEEP)('keeps %s', (key) => {
@@ -871,6 +917,29 @@ describe('non-secret keys that must stay readable', () => {
expect(result.withCredentials).toBe(true)
expect(result.password).toBe(REDACTED_MARKER)
})
+
+ it.concurrent('exempts `has…`/`is…` flags by value type, not by name', () => {
+ const result = redactApiKeys({
+ hasApiKey: true,
+ isPrivate: false,
+ hasSecret: 'sk-supersecret-value',
+ hasPassword: 'hunter2',
+ })
+
+ expect(result.hasApiKey).toBe(true)
+ expect(result.isPrivate).toBe(false)
+ expect(result.hasSecret).toBe(REDACTED_MARKER)
+ expect(result.hasPassword).toBe(REDACTED_MARKER)
+ expect(isSensitiveKey('hasSecret')).toBe(true)
+ })
+})
+
+describe('normalizeKey is linear in key length', () => {
+ it.concurrent('classifies a 400k-character uppercase key in bounded time', () => {
+ const start = performance.now()
+ expect(isSensitiveKey('A'.repeat(400_000))).toBe(false)
+ expect(performance.now() - start).toBeLessThan(1000)
+ })
})
describe('credentials container carve-out', () => {
@@ -920,8 +989,8 @@ describe('array elements are redacted like scalars', () => {
})
})
-describe('persisted-log path matches the analytics path', () => {
- it.concurrent('redacts Bearer tokens embedded in error strings', () => {
+describe('credential literals embedded in string values', () => {
+ it.concurrent('redacts Bearer tokens on both the persisted-log and analytics paths', () => {
const event = { error: `failed: Authorization: Bearer ${['abc123', 'xyz456789'].join('')}` }
expect(JSON.stringify(redactApiKeys(event))).not.toContain(['abc123', 'xyz456789'].join(''))
@@ -946,3 +1015,34 @@ describe('persisted-log path matches the analytics path', () => {
expect(result.downloadUrl).toBe(`https://files.example.com/report.pdf?token=${REDACTED_MARKER}`)
})
})
+
+describe('sanitizeEventData handles user files like redactApiKeys', () => {
+ const userFile = {
+ id: 'file-123',
+ name: 'document.pdf',
+ url: 'http://localhost/api/files/serve/x',
+ size: 12345,
+ type: 'application/pdf',
+ key: 'workspace/abc/secret-path.pdf',
+ context: 'execution',
+ base64: 'A'.repeat(200),
+ }
+
+ it.concurrent('drops the internal storage key and truncates base64', () => {
+ const result = sanitizeEventData(userFile)
+
+ expect(result).not.toHaveProperty('key')
+ expect(result).not.toHaveProperty('context')
+ expect(result.base64).toBe(TRUNCATED_MARKER)
+ expect(result.id).toBe('file-123')
+ expect(result.url).toBe('http://localhost/api/files/serve/x')
+ })
+
+ it.concurrent('matches the redactApiKeys shape for the same file', () => {
+ expect(sanitizeEventData(userFile)).toEqual(redactApiKeys(userFile))
+ })
+
+ it.concurrent('truncates a bare base64 field outside a user file', () => {
+ expect(sanitizeEventData({ label: 'x', base64: 'A'.repeat(200) }).base64).toBe(TRUNCATED_MARKER)
+ })
+})
diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts
index b709535f458..c64c91a1dd8 100644
--- a/apps/sim/lib/core/security/redaction.ts
+++ b/apps/sim/lib/core/security/redaction.ts
@@ -23,27 +23,38 @@ const NON_SENSITIVE_KEYS = new Set([
'fetch_xml_paging_cookie',
])
-/** `hasApiKey` / `isPrivate` carry a boolean presence flag, never the secret. */
-const BOOLEAN_FLAG_KEY = /^(?:has|is)_/
-
/**
- * Words that make a key a credential when they end it. Anchored on a separator
- * so a secret word is caught in any position (`openai_api_key`, `x-api-key`,
- * `set-cookie`, `secretAccessKey`) while record identifiers that merely contain
- * `key` (`issueKey`, `partitionKey`, `keyPoints`) stay readable.
+ * Words that carry a secret when they end a key, and whose `Url` /
+ * `Link` locator is as sensitive as the secret itself: `resetPasswordUrl`
+ * is a one-shot account-takeover link, `accessTokenUrl` hands out a bearer token.
+ *
+ * Anchored on a separator so a secret word is caught in any position
+ * (`openai_api_key`, `x-api-key`, `secretAccessKey`) while record identifiers
+ * that merely contain `key` (`issueKey`, `partitionKey`, `keyPoints`) stay
+ * readable. `…Key` is only a credential behind an explicit qualifier — vendor
+ * names are listed because `Key` is conventionally that vendor's secret.
*
- * `…Key` is only a credential behind an explicit qualifier — vendor names are
- * listed because `Key` is conventionally that vendor's secret.
+ * `token` stays singular: `promptTokens`/`totalTokens` are usage counters, so
+ * plural forms are enumerated only where they are unambiguously credentials.
*/
-const SECRET_WORDS = [
+const SECRET_LOCATOR_WORDS = [
'api_?keys?',
'(?:access|anthropic|app|auth|client|decryption|deploy|encryption|license|master|openai|private|resend|root|secret|sendgrid|sign|signing|stripe|twilio)_keys?',
- 'secret',
- 'password',
- 'passwd',
- 'passphrase',
+ 'secrets?',
+ 'passwords?',
'token',
+ '(?:access|api|auth|bearer|id|refresh|session)_tokens',
'credentials?',
+].join('|')
+
+/**
+ * Secret words with no meaningful locator form. Kept out of the locator group so
+ * `authorizationUrl`, `authorizationEndpoint`, and `BETTER_AUTH_URL` stay
+ * readable — an OIDC authorization endpoint is published discovery metadata.
+ */
+const SECRET_WORDS_ONLY = [
+ 'passwd',
+ 'passphrase',
'authorization',
'auth',
'bearer',
@@ -51,21 +62,32 @@ const SECRET_WORDS = [
'jwt',
'pem',
'ssn',
- 'session_id',
'connection_string',
'service_account_json',
'code_verifier',
'kubeconfig',
].join('|')
+const SECRET_WORDS = `${SECRET_LOCATOR_WORDS}|${SECRET_WORDS_ONLY}`
+
/**
- * A locator for a secret is as sensitive as the secret: `resetPasswordUrl` is a
- * one-shot account-takeover link, `accessTokenUrl` hands out a bearer token.
+ * One-shot credential locators. The token is embedded in the URL path rather
+ * than named by the key, so `activationUrl` leaks exactly what the redacted
+ * `activationToken` beside it protects. Scoped to single-use flows so ordinary
+ * `*Url` fields stay readable.
*/
+const ONE_SHOT_LOCATOR_WORDS =
+ 'activation|invitation|invite|magic|reset|verification|verify|confirmation|confirm'
+
const SECRET_LOCATOR_SUFFIXES = 'url|uri|endpoint|link'
+/**
+ * `…_value` behind a secret word is the plaintext secret, not its name — a
+ * secrets-manager record is `{ secretKey, secretValue }` and the value half is
+ * the one that must never reach a log.
+ */
const SENSITIVE_KEY_PATTERN = new RegExp(
- `(?:^|_)(?:${SECRET_WORDS})(?:_(?:${SECRET_LOCATOR_SUFFIXES}))?$`
+ `(?:^|_)(?:(?:${SECRET_WORDS})(?:_value)?|(?:${SECRET_LOCATOR_WORDS}|${ONE_SHOT_LOCATOR_WORDS})_(?:${SECRET_LOCATOR_SUFFIXES}))$`
)
/**
@@ -80,7 +102,13 @@ const SENSITIVE_SUBSTRING =
/(?:password|passwd|passphrase|privatekey|accesstoken|refreshtoken|clientsecret|connectionstring|authorization|kubeconfig)$/
const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g
-const ACRONYM_BOUNDARY = /([A-Z]+)([A-Z][a-z])/g
+/**
+ * Only the final uppercase char before the boundary matters — it is copied
+ * straight back as `$1`. A `[A-Z]+` prefix here would backtrack the whole
+ * uppercase run at every start offset, making an attacker-supplied key name a
+ * quadratic event-loop stall.
+ */
+const ACRONYM_BOUNDARY = /([A-Z])([A-Z][a-z])/g
const NON_ALPHANUMERIC = /[^a-z0-9]+/g
/**
@@ -169,14 +197,10 @@ const SENSITIVE_VALUE_PATTERNS: Array<{ pattern: RegExp; replacement: string }>
},
]
-/**
- * Reports whether an object key is likely to hold a secret and must be redacted
- * before the value reaches a log sink.
- */
+/** Whether a key name alone marks it a credential; the value is gated separately. */
export function isSensitiveKey(key: string): boolean {
const normalized = normalizeKey(key)
if (NON_SENSITIVE_KEYS.has(normalized)) return false
- if (BOOLEAN_FLAG_KEY.test(normalized)) return false
if (SENSITIVE_KEY_PATTERN.test(normalized)) return true
return SENSITIVE_SUBSTRING.test(normalized.replaceAll('_', ''))
}
@@ -296,6 +320,21 @@ export function sanitizeEventData(event: any): any {
return event.map((item) => sanitizeEventData(item))
}
+ if (isUserFile(event)) {
+ const filtered = filterUserFileForDisplay(event)
+ const file: Record = {}
+ for (const [key, value] of Object.entries(filtered)) {
+ if (isLargeDataKey(key) && typeof value === 'string') {
+ file[key] = TRUNCATED_MARKER
+ } else if (typeof value === 'string') {
+ file[key] = redactSensitiveValues(value)
+ } else {
+ file[key] = value
+ }
+ }
+ return file
+ }
+
const sanitized: Record = {}
for (const [key, value] of Object.entries(event)) {
@@ -308,6 +347,11 @@ export function sanitizeEventData(event: any): any {
continue
}
+ if (isLargeDataKey(key) && typeof value === 'string') {
+ sanitized[key] = TRUNCATED_MARKER
+ continue
+ }
+
sanitized[key] = sanitizeEventData(value)
}
diff --git a/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
index b12130b1c65..70fe9cf0f8d 100644
--- a/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
+++ b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts
@@ -199,6 +199,7 @@ describe('secureFetchWithPinnedIP redirect credential handling', () => {
{ headers: { Authorization: 'Basic dHdpbGlv' } }
)
+ expect(capturedRequests).toHaveLength(2)
expect(headersOfHop(1).Authorization).toBeUndefined()
})
@@ -276,6 +277,7 @@ describe('secureFetchWithPinnedIP redirect credential handling', () => {
allowHttp: true,
})
+ expect(capturedRequests).toHaveLength(2)
expect(headersOfHop(1).Authorization).toBeUndefined()
})
diff --git a/apps/sim/lib/file-parsers/zip-guard.test.ts b/apps/sim/lib/file-parsers/zip-guard.test.ts
index 6125150f228..bb22fda93ec 100644
--- a/apps/sim/lib/file-parsers/zip-guard.test.ts
+++ b/apps/sim/lib/file-parsers/zip-guard.test.ts
@@ -89,6 +89,32 @@ function readEocd(buffer: Buffer): EocdFields {
throw new Error('no EOCD record found')
}
+/**
+ * Splice a 22-byte empty-EOCD decoy between the local file data and the central
+ * directory, then rewrite the real EOCD to point past it and to declare one more
+ * record than the directory holds. The decoy resolves trivially ("0 records at
+ * offset 0"), and JSZip does not error on a count mismatch — it keeps whatever
+ * records it found — so a guard that discards the mismatched candidate measures
+ * nothing at all and reads the archive as empty.
+ */
+function spliceCountMismatchDecoy(archive: Buffer): Buffer {
+ const eocd = readEocd(archive)
+ const decoy = Buffer.alloc(22)
+ decoy.writeUInt32LE(0x06054b50, 0)
+
+ const realEocd = Buffer.from(archive.subarray(eocd.offset))
+ realEocd.writeUInt16LE(eocd.entryCount + 1, 8)
+ realEocd.writeUInt16LE(eocd.entryCount + 1, 10)
+ realEocd.writeUInt32LE(eocd.cdOffset + decoy.length, 16)
+
+ return Buffer.concat([
+ archive.subarray(0, eocd.cdOffset),
+ decoy,
+ archive.subarray(eocd.cdOffset, eocd.offset),
+ realEocd,
+ ])
+}
+
/** Saturate the first central-directory record's 32-bit size and declare the real size in a ZIP64 extra field. */
function forgeZip64DeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: bigint): Buffer {
const cdStart = zipBuffer.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]))
@@ -297,7 +323,106 @@ describe('assertOoxmlArchiveWithinLimits', () => {
const inflated = await loaded.file('word/document.xml')!.async('nodebuffer')
expect(inflated.length).toBe(64 * 1024 * 1024)
expect(inflated.length / attack.length).toBeGreaterThan(100)
- }, 60_000)
+
+ // Same bomb, with the real EOCD pushed past the 64 KiB comment window by
+ // trailing junk. A spec-correct windowed scan sees no EOCD signature at all
+ // and, with byte 0 no longer a ZIP signature, no-ops entirely — while JSZip,
+ // which scans to offset 0, still finds the record and inflates 64 MiB. The
+ // guard must model the parser, so the scan covers the whole buffer.
+ const windowed = Buffer.concat([attack, Buffer.alloc(70_000, 0xab)])
+ expect(isZipShaped(windowed)).toBe(false)
+ expect(() => assertOoxmlArchiveWithinLimits(windowed)).toThrow(ZipBombError)
+
+ const loadedWindowed = await JSZip.loadAsync(windowed)
+ const inflatedWindowed = await loadedWindowed.file('word/document.xml')!.async('nodebuffer')
+ expect(inflatedWindowed.length).toBe(64 * 1024 * 1024)
+ }, 120_000)
+
+ it('rejects a within-limits archive whose EOCD sits past the 64 KiB comment window', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello world' })
+ const attack = Buffer.concat([Buffer.from([0x00]), archive, Buffer.alloc(70_000, 0xab)])
+ expect(() => assertOoxmlArchiveWithinLimits(attack, HIGH_LIMITS)).toThrow(ZipBombError)
+ })
+
+ it('rejects a bomb whose EOCD lies about the entry count behind an empty-EOCD decoy', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 200_000)
+ // Discarding the count-mismatched candidate measured NOTHING for the only
+ // interpretation JSZip actually parses, leaving the trivially-resolvable
+ // decoy as the whole verdict: "empty archive". The declared size stays under
+ // the absolute cap so the ratio check — not the walk's early size abort — is
+ // what has to see the entry.
+ expect(() =>
+ assertOoxmlArchiveWithinLimits(spliceCountMismatchDecoy(bomb), {
+ maxTotalUncompressedBytes: 1024 * 1024 * 1024,
+ maxCompressionRatio: 5,
+ ratioCheckFloorBytes: 1000,
+ })
+ ).toThrow(ZipBombError)
+ })
+
+ it('measures the real central directory when the EOCD lies about the entry count', async () => {
+ const archive = await buildZip({ 'a.xml': 'a', 'b.xml': 'b', 'c.xml': 'c' })
+ expect(readZipCentralDirectoryStats(spliceCountMismatchDecoy(archive))?.entryCount).toBe(3)
+ })
+
+ it('rejects a prepended bomb that also carries an empty-EOCD decoy', async () => {
+ const archive = await buildZip({ 'word/document.xml': 'hello' })
+ const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0)
+ // Prepending shifts the real EOCD's absolute cdOffset out from under the
+ // guard while JSZip rebases past the leading byte; the decoy then supplies a
+ // clean "empty archive" reading. A resolvable candidate must not excuse an
+ // unresolvable one.
+ const attack = Buffer.concat([Buffer.from([0x00]), spliceCountMismatchDecoy(bomb)])
+ expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError)
+ })
+
+ it('refuses an EOCD signature truncated by the buffer end', () => {
+ // Too close to the tail to hold a full record, so it yields no candidate —
+ // but it is still a ZIP signature the guard could not follow, and reading it
+ // as "not a ZIP" is the same fail-open the prepend evasion exploits.
+ const buffer = Buffer.alloc(64)
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 50)
+ expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
+ })
+
+ it('refuses, quickly, a buffer whose candidates each anchor a distinct maximal walk', () => {
+ // The run cache is keyed by central-directory offset, so distinct offsets
+ // defeat it and cost MAX_EOCD_CANDIDATES full walks. The shared record budget
+ // bounds that, and exhausting it is unverifiable rather than "measured 0".
+ const buffer = Buffer.alloc(4 * 1024 * 1024)
+ for (let offset = 0; offset + 46 <= buffer.length; offset += 46) {
+ buffer.writeUInt32LE(CENTRAL_DIRECTORY_HEADER_SIGNATURE, offset)
+ }
+ for (let i = 0; i < 128; i++) {
+ const offset = buffer.length - 128 * 22 + i * 22
+ buffer.writeUInt32LE(0x06054b50, offset)
+ buffer.writeUInt32LE(i * 46, offset + 16)
+ }
+
+ const start = performance.now()
+ expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
+ expect(performance.now() - start).toBeLessThan(250)
+ })
+
+ it('survives a central-directory record whose extra field runs past the buffer end', () => {
+ // A truncated directory can declare an extra field longer than what remains.
+ // An unclamped read raises a raw RangeError, which escapes callers that
+ // expect a typed archive error (the upload path calls this outside any try).
+ const buffer = Buffer.alloc(68)
+ buffer.writeUInt32LE(0x06054b50, 0)
+ buffer.writeUInt16LE(1, 8)
+ buffer.writeUInt16LE(1, 10)
+ buffer.writeUInt32LE(22, 16)
+ buffer.writeUInt32LE(CENTRAL_DIRECTORY_HEADER_SIGNATURE, 22)
+ buffer.writeUInt32LE(0xffffffff, 22 + 24)
+ buffer.writeUInt16LE(0xffff, 22 + 30)
+
+ expect(readZipCentralDirectoryStats(buffer)).toEqual({
+ entryCount: 1,
+ totalExtraFieldBytes: 0xffff,
+ })
+ })
it('rejects a within-limits archive behind a prepended byte', async () => {
// Defined, deliberate behaviour: an unresolvable EOCD is refused whether or
@@ -359,6 +484,33 @@ describe('assertOoxmlArchiveWithinLimits', () => {
expect(() => assertOoxmlArchiveWithinLimits(bomb)).toThrow(ZipBombError)
})
+ it('no-ops for a legacy OLE2 document containing the EOCD byte sequence', () => {
+ // Non-ZIP documents carry these four bytes by chance, and the OOXML parsers
+ // depend on a no-op here so their OLE2/plaintext fallback can run. The
+ // record's cdOffset lands outside the buffer, so no parser can read a
+ // directory from it and it carries no signal either way.
+ const body = Buffer.alloc(4096, 0x41)
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(body, 0)
+ const stray = Buffer.alloc(22)
+ stray.writeUInt32LE(0x06054b50, 0)
+ stray.writeUInt16LE(3, 8)
+ stray.writeUInt16LE(3, 10)
+ stray.writeUInt32LE(0xdeadbe, 12)
+ stray.writeUInt32LE(0xdeadbe, 16)
+ stray.copy(body, 2000)
+
+ expect(() => assertOoxmlArchiveWithinLimits(body)).not.toThrow()
+ })
+
+ it('no-ops for plaintext containing the EOCD byte sequence', () => {
+ const text = Buffer.concat([
+ Buffer.from('Quarterly report. '.repeat(50)),
+ Buffer.from([0x50, 0x4b, 0x05, 0x06]),
+ Buffer.from('...more prose follows here.'.repeat(50)),
+ ])
+ expect(() => assertOoxmlArchiveWithinLimits(text)).not.toThrow()
+ })
+
it('no-ops for a legacy OLE2/CFB document', () => {
const ole2 = Buffer.alloc(1024)
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(ole2, 0)
diff --git a/apps/sim/lib/file-parsers/zip-guard.ts b/apps/sim/lib/file-parsers/zip-guard.ts
index 23c5a33159a..1591de47dd9 100644
--- a/apps/sim/lib/file-parsers/zip-guard.ts
+++ b/apps/sim/lib/file-parsers/zip-guard.ts
@@ -27,17 +27,34 @@ const EOCD_SIGNATURE_BYTES = Buffer.from([0x50, 0x4b, 0x05, 0x06])
const EOCD_MIN_SIZE = 22
const ZIP64_EOCD_LOCATOR_SIZE = 20
const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46
-const MAX_EOCD_COMMENT_SIZE = 0xffff
const UINT32_SENTINEL = 0xffffffff
const UINT16_SENTINEL = 0xffff
/**
- * Ceiling on EOCD signatures considered in the scan window. A real archive has
- * one (plus, rarely, a stray match inside trailing data); a buffer stuffed with
- * more is an attempt to flood the candidate set, and is refused outright.
+ * Ceiling on EOCD signatures considered. A real archive has one (plus, rarely, a
+ * stray match inside entry data); a buffer stuffed with more is an attempt to
+ * flood the candidate set, and is refused outright.
*/
const MAX_EOCD_CANDIDATES = 128
+/**
+ * Ceiling on central-directory records read across ALL candidate walks in one
+ * inspection.
+ *
+ * The per-candidate run cache is keyed by central-directory offset, so an
+ * attacker who varies `cdOffset` per candidate defeats it and pays
+ * `MAX_EOCD_CANDIDATES × buffer.length / 46` record reads: 551 ms of synchronous
+ * event-loop block on a 64 MiB buffer and 1006 ms at the pipeline's 100 MiB cap,
+ * measured. The budget bounds that at 5.9 ms and 8.6 ms respectively while
+ * sitting far above any real archive — the upload path separately refuses more
+ * than 10k records. Exhausting it means the buffer could not be fully evaluated,
+ * which fails closed like any other unverifiable ZIP-shaped input.
+ *
+ * Widening the signature scan from the trailing 64 KiB to the whole buffer costs
+ * one `Buffer.indexOf` pass: 0.02 ms to 0.33 ms on a normal 5 MB OOXML.
+ */
+const MAX_CENTRAL_DIRECTORY_RECORDS_SCANNED = 250_000
+
export interface OoxmlSizeLimits {
/** Hard ceiling on the summed declared uncompressed size of all entries. */
maxTotalUncompressedBytes: number
@@ -84,29 +101,43 @@ export function isZipShaped(buffer: Buffer): boolean {
}
interface EocdScan {
- /** Offsets to evaluate; empty when the window is flooded past {@link MAX_EOCD_CANDIDATES}. */
+ /** Offsets to evaluate; empty when the buffer is flooded past {@link MAX_EOCD_CANDIDATES}. */
candidates: number[]
- /** Whether the window holds at least one EOCD signature, flooded or not. */
+ /** Whether the buffer holds at least one EOCD signature, flooded or not. */
sawSignature: boolean
}
/**
- * Every EOCD signature offset in the trailing 22 + 65535 comment-length window.
+ * Every EOCD signature offset in the WHOLE buffer.
+ *
+ * The spec puts the record in the trailing 22 + 65535 comment-length window, and
+ * yauzl honours that — but the libraries this guard actually protects do not.
+ * JSZip (`ArrayReader.lastIndexOfSignature`) and SheetJS both scan from the last
+ * byte to offset 0, so an EOCD pushed past 64 KiB of trailing junk is invisible
+ * to a windowed scan yet is exactly the record they parse. Combined with a single
+ * prepended byte — which makes the buffer read as "not ZIP-shaped" — a windowed
+ * scan let a 495x bomb through untouched. The guard models the parsers, not the
+ * spec, so the window is the buffer.
+ *
* Trailing bytes after the EOCD are common in the wild (self-extracting stubs,
* appended signatures, generator padding), so the record is not required to end
* at the buffer tail. Every candidate is returned rather than the first that
- * looks plausible: the decompression libraries this guard protects each pick
- * their own record, so the archive is evaluated under all of them. A flooded
- * window yields no candidates but still reports the signature, so the caller
- * refuses the buffer rather than reading it as "not a ZIP".
+ * looks plausible: the decompression libraries each pick their own record, so the
+ * archive is evaluated under all of them. A flooded buffer yields no candidates
+ * but still reports the signature, so the caller refuses the buffer rather than
+ * reading it as "not a ZIP".
*/
function findEocdCandidates(buffer: Buffer): EocdScan {
- const windowStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE)
const lastOffset = buffer.length - EOCD_MIN_SIZE
const candidates: number[] = []
+ let sawSignature = false
- let offset = buffer.indexOf(EOCD_SIGNATURE_BYTES, windowStart)
- while (offset !== -1 && offset <= lastOffset) {
+ let offset = buffer.indexOf(EOCD_SIGNATURE_BYTES)
+ while (offset !== -1) {
+ sawSignature = true
+ if (offset > lastOffset) {
+ break
+ }
if (candidates.length >= MAX_EOCD_CANDIDATES) {
return { candidates: [], sawSignature: true }
}
@@ -114,7 +145,7 @@ function findEocdCandidates(buffer: Buffer): EocdScan {
offset = buffer.indexOf(EOCD_SIGNATURE_BYTES, offset + 1)
}
- return { candidates, sawSignature: candidates.length > 0 }
+ return { candidates, sawSignature }
}
interface CentralDirectoryLocation {
@@ -165,6 +196,10 @@ function locateCentralDirectory(
* when the 32-bit central-directory field is saturated. The saturated 64-bit
* values appear in the extra field in a fixed order with the uncompressed size
* first, so it is always the leading 8 bytes of the ZIP64 field.
+ *
+ * The extra-field extent is clamped to the buffer: a truncated central directory
+ * can declare a length that runs off the end, and an unclamped read raises a raw
+ * `RangeError` that escapes callers which expect a typed archive error.
*/
function readUncompressedSize(
buffer: Buffer,
@@ -178,7 +213,7 @@ function readUncompressedSize(
}
const extraStart = headerOffset + CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength
- const extraEnd = extraStart + extraFieldLength
+ const extraEnd = Math.min(extraStart + extraFieldLength, buffer.length)
let cursor = extraStart
while (cursor + 4 <= extraEnd) {
const fieldId = buffer.readUInt16LE(cursor)
@@ -202,32 +237,62 @@ interface CentralDirectoryWalk {
totalExtraFieldBytes: number
}
+/**
+ * A candidate is either ignorable, unverifiable, or measurable.
+ *
+ * - `ignored` — the EOCD's `cdOffset` does not resolve at all (out of range, or a
+ * broken ZIP64 chain). No parser can read a central directory from it, and a
+ * stray `PK\x05\x06` inside compressed entry data lands here, so it carries no
+ * signal either way.
+ * - `unverifiable` — the offset resolves and the record declares entries, but not
+ * one record sits there. This is the prepended-bytes shape: JSZip rebases past
+ * arbitrary leading data and inflates the archive anyway, while every absolute
+ * offset in the buffer is shifted out from under the guard. It must fail closed.
+ * - `walk` — records were read; the run is the measurement.
+ */
+type CandidateOutcome =
+ | { kind: 'ignored' }
+ | { kind: 'unverifiable' }
+ | { kind: 'walk'; walk: CentralDirectoryWalk }
+
+/** Remaining central-directory records an inspection may read, shared across candidates. */
+interface ScanBudget {
+ remaining: number
+}
+
/**
* Walk the contiguous run of central-directory records anchored by one EOCD
* candidate. The run — not the candidate's declared entry count — is what a
* per-signature parser allocates, so a lied count can neither hide records nor
- * inflate the tally. Returns `null` when the candidate is unresolvable or when
- * the run length disagrees with the declared count: an inconsistent record is
- * suspicious (an empty EOCD appended after a real central directory has exactly
- * this shape) and must never be read as "empty archive". Stops early, and skips
- * the consistency check, once the running total exceeds the limit — the archive
- * is already over budget under this interpretation. Runs are memoized by central
- * directory offset, so many candidates aimed at one directory cost one walk.
+ * inflate the tally.
+ *
+ * A run that disagrees with the declared count is still measured. JSZip
+ * deliberately does not error on a count mismatch ("we found some records but not
+ * all… no error here", `zipEntries.js`), so it allocates and inflates exactly the
+ * run found here; discarding the candidate instead measured NOTHING for that
+ * interpretation and let a 1021x bomb through behind a single decoy record.
+ *
+ * Stops early once the running total exceeds the limit — the archive is already
+ * over budget under this interpretation — or once the shared scan budget is
+ * exhausted, which is reported as unverifiable rather than as a short run. Runs
+ * are memoized by central directory offset, so many candidates aimed at one
+ * directory cost one walk.
*/
function walkCentralDirectory(
buffer: Buffer,
eocdOffset: number,
abortAboveBytes: number,
- runCache: Map
-): CentralDirectoryWalk | null {
+ runCache: Map,
+ budget: ScanBudget
+): CandidateOutcome {
const location = locateCentralDirectory(buffer, eocdOffset)
if (!location) {
- return null
+ return { kind: 'ignored' }
}
const cached = runCache.get(location.offset)
if (cached) {
- return cached.entryCount === location.entryCount ? cached : null
+ return classifyRun(cached, location)
}
let entryCount = 0
@@ -238,6 +303,11 @@ function walkCentralDirectory(
cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length &&
buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE
) {
+ if (budget.remaining <= 0) {
+ return { kind: 'unverifiable' }
+ }
+ budget.remaining -= 1
+
const fileNameLength = buffer.readUInt16LE(cursor + 28)
const extraFieldLength = buffer.readUInt16LE(cursor + 30)
const commentLength = buffer.readUInt16LE(cursor + 32)
@@ -251,7 +321,7 @@ function walkCentralDirectory(
extraFieldLength
)
if (declaredUncompressedBytes > abortAboveBytes) {
- return { entryCount, declaredUncompressedBytes, totalExtraFieldBytes }
+ return { kind: 'walk', walk: { entryCount, declaredUncompressedBytes, totalExtraFieldBytes } }
}
cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength
@@ -259,14 +329,37 @@ function walkCentralDirectory(
const walk = { entryCount, declaredUncompressedBytes, totalExtraFieldBytes }
runCache.set(location.offset, walk)
- return entryCount === location.entryCount ? walk : null
+ return classifyRun(walk, location)
+}
+
+/**
+ * An empty run under a record that declares entries means the directory is not
+ * where the buffer says it is — unverifiable. An empty run under a record that
+ * declares nothing is simply an empty archive, and is measured as such.
+ */
+function classifyRun(
+ walk: CentralDirectoryWalk,
+ location: CentralDirectoryLocation
+): CandidateOutcome {
+ if (walk.entryCount === 0 && location.entryCount > 0) {
+ return { kind: 'unverifiable' }
+ }
+ return { kind: 'walk', walk }
}
interface ArchiveInspection {
- /** Worst case across resolvable EOCD interpretations, or `null` when none resolved. */
+ /** Worst case across measurable EOCD interpretations, or `null` when none were. */
worst: CentralDirectoryWalk | null
- /** Whether the scan window held at least one EOCD signature. */
+ /** Whether the buffer held at least one EOCD signature. */
sawEocdSignature: boolean
+ /**
+ * Whether the buffer holds an EOCD signal the guard could not evaluate: a
+ * record naming a directory that is not there, a directory too large to scan
+ * within the budget, or a candidate set too flooded to enumerate. Distinct
+ * from "no measurement" — an EOCD whose offset lands outside the buffer is
+ * unreadable by every parser too, so it is no signal rather than a bad one.
+ */
+ sawUnverifiableEocd: boolean
}
/**
@@ -275,26 +368,37 @@ interface ArchiveInspection {
* the guard must not depend on guessing which one the downstream parser reads:
* if ANY interpretation is over budget, the archive is rejected.
*
- * `worst` is `null` when no candidate resolves. `sawEocdSignature` separates the
- * two ways that happens: a buffer with no EOCD signature at all is simply not a
- * ZIP (legacy binary `.xls`/`.doc`, misidentified plaintext), while a buffer
- * that carries an EOCD signature the guard cannot follow is unverifiable and
- * must be refused — see {@link assertOoxmlArchiveWithinLimits}.
+ * `worst` is `null` when nothing was measurable — which alone is not grounds for
+ * refusal, since a non-ZIP document carrying the four EOCD bytes by chance lands
+ * here. `sawUnverifiableEocd` is the separate, convicting signal: a candidate the
+ * guard cannot follow is an evasion shape that must be refused even when another
+ * candidate measured cleanly — see {@link assertOoxmlArchiveWithinLimits}.
*/
function inspectArchive(buffer: Buffer, abortAboveBytes: number): ArchiveInspection {
if (buffer.length < EOCD_MIN_SIZE) {
- return { worst: null, sawEocdSignature: false }
+ return { worst: null, sawEocdSignature: false, sawUnverifiableEocd: false }
}
const { candidates, sawSignature } = findEocdCandidates(buffer)
let worst: CentralDirectoryWalk | null = null
+ // A signature the scan declined to enumerate — a flooded buffer, or a record
+ // truncated by the buffer end — is unevaluated, not absent. JSZip reads the
+ // LAST signature in the buffer, so flooding past the candidate ceiling would
+ // otherwise hide the one record it actually parses.
+ let sawUnverifiableEocd = sawSignature && candidates.length === 0
const runCache = new Map()
+ const budget: ScanBudget = { remaining: MAX_CENTRAL_DIRECTORY_RECORDS_SCANNED }
for (const candidate of candidates) {
- const walk = walkCentralDirectory(buffer, candidate, abortAboveBytes, runCache)
- if (!walk) {
+ const outcome = walkCentralDirectory(buffer, candidate, abortAboveBytes, runCache, budget)
+ if (outcome.kind === 'unverifiable') {
+ sawUnverifiableEocd = true
+ break
+ }
+ if (outcome.kind === 'ignored') {
continue
}
+ const { walk } = outcome
worst = worst
? {
entryCount: Math.max(worst.entryCount, walk.entryCount),
@@ -310,7 +414,7 @@ function inspectArchive(buffer: Buffer, abortAboveBytes: number): ArchiveInspect
}
}
- return { worst, sawEocdSignature: sawSignature }
+ return { worst, sawEocdSignature: sawSignature, sawUnverifiableEocd }
}
/** Parse-time shape of a ZIP central directory, read without decompressing anything. */
@@ -331,11 +435,12 @@ export interface ZipCentralDirectoryStats {
* run is what JSZip actually allocates one entry per. Unlike a raw whole-buffer
* signature scan, STORED entry payloads (e.g. a nested `.zip` archived without
* recompression) are never miscounted as records. Returns `null` when no EOCD
- * candidate resolves, so callers can fail closed.
+ * candidate is measurable or when any candidate is unverifiable, so callers can
+ * fail closed.
*/
export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirectoryStats | null {
- const { worst } = inspectArchive(buffer, Number.POSITIVE_INFINITY)
- if (!worst) {
+ const { worst, sawUnverifiableEocd } = inspectArchive(buffer, Number.POSITIVE_INFINITY)
+ if (!worst || sawUnverifiableEocd) {
return null
}
return { entryCount: worst.entryCount, totalExtraFieldBytes: worst.totalExtraFieldBytes }
@@ -350,37 +455,53 @@ export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirector
* which EOCD they read, so an archive that is a bomb under any of them is
* rejected.
*
- * Fails closed on two shapes, so a buffer a downstream library still inflates
- * cannot bypass the guard:
+ * Fails closed on two families of shape, so a buffer a downstream library still
+ * inflates cannot bypass the guard:
+ *
+ * - The buffer is ZIP-shaped (byte 0 begins a local file header or an EOCD) but
+ * nothing measurable resolves.
+ * - The buffer holds an EOCD signal the guard could not evaluate, whatever byte
+ * 0 says. Three cases: a record that names a directory offset inside the
+ * buffer where no record is found; a directory too large to scan within the
+ * shared record budget; and a signature the scan declined to enumerate at all
+ * — a candidate set flooded past {@link MAX_EOCD_CANDIDATES}, or a record
+ * truncated by the buffer end. JSZip parses the LAST signature in the buffer,
+ * so flooding would otherwise hide exactly the one record it reads.
*
- * - The buffer begins with a ZIP signature but no central directory resolves.
- * - The buffer carries an EOCD signature that resolves to nothing. This is the
- * prepended-bytes evasion: JSZip tolerates arbitrary leading data, but the
- * EOCD's `cdOffset` is absolute, so shifting the archive by even one byte
- * makes every candidate unwalkable while the archive still inflates. Keying
- * the fail-closed branch on the leading signature alone let a 1.2 GiB bomb
- * through. A buffer holding an EOCD record the guard cannot follow is the
- * shape of an evasion attempt, not of a non-ZIP file, so it is refused
- * regardless of what byte 0 says. A non-ZIP document that happens to contain
- * the four EOCD bytes in its trailing 64 KiB is also refused; that costs one
- * parse with an explicit error, against an OOM that takes down every tenant
- * on the worker.
+ * The first case is the prepended-bytes evasion: JSZip tolerates arbitrary
+ * leading data, but the EOCD's `cdOffset` is absolute, so shifting the archive
+ * by even one byte leaves the record naming an offset that no longer holds a
+ * directory, while the archive still inflates. Keying the fail-closed branch
+ * on the leading signature alone let a 1.2 GiB bomb through. The refusal holds
+ * even when another candidate measures cleanly: an empty 22-byte EOCD appended
+ * as a decoy is trivially measurable, and letting it stand in for the shifted
+ * record reopens the same hole.
*
- * Genuinely non-ZIP inputs (legacy OLE `.xls`/`.doc`, misidentified plaintext)
- * carry no EOCD signature, so they no-op and defer to the downstream parser's
- * own validation and fallbacks.
+ * An EOCD signature whose `cdOffset` lands OUTSIDE the buffer is not a signal in
+ * either direction — no parser can read a directory from it — so it neither
+ * measures nor convicts. This is the common case for the four bytes appearing by
+ * chance in a legacy OLE `.doc`/`.xls` or in plaintext, and those inputs must
+ * no-op so the downstream parsers' own fallback paths can run. Treating any
+ * stray signature as grounds for refusal turned a legacy `.doc` into a hard
+ * error. What remains is the ~1e-6 case of a stray whose offset happens to land
+ * in range and read empty; that costs one parse with an explicit error, against
+ * an OOM that takes down every tenant on the worker.
*/
export function assertOoxmlArchiveWithinLimits(
buffer: Buffer,
limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
): void {
- const { worst, sawEocdSignature } = inspectArchive(buffer, limits.maxTotalUncompressedBytes)
+ const { worst, sawEocdSignature, sawUnverifiableEocd } = inspectArchive(
+ buffer,
+ limits.maxTotalUncompressedBytes
+ )
const totalUncompressed = worst ? worst.declaredUncompressedBytes : null
- if (totalUncompressed === null) {
- if (sawEocdSignature || isZipShaped(buffer)) {
+ if (totalUncompressed === null || sawUnverifiableEocd) {
+ if (sawUnverifiableEocd || isZipShaped(buffer)) {
logger.warn('Rejected archive: central directory could not be parsed', {
compressedBytes: buffer.length,
sawEocdSignature,
+ sawUnverifiableEocd,
zipShaped: isZipShaped(buffer),
})
throw new ZipBombError(
diff --git a/apps/sim/lib/media/falai.test.ts b/apps/sim/lib/media/falai.test.ts
index df417972e9a..a47aee14d88 100644
--- a/apps/sim/lib/media/falai.test.ts
+++ b/apps/sim/lib/media/falai.test.ts
@@ -142,12 +142,13 @@ describe('runFalQueue', () => {
)
})
- it('rejects a same-origin candidate whose path is not a routable queue path', async () => {
+ it('rejects a same-origin queue path of the wrong kind (result offered for status, and vice versa)', async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
request_id: 'req-7',
- // Same origin, but not `/{app}/requests/{id}[/status]` — never routable.
+ // Both are routable queue paths, but each is the *other* kind: the status
+ // slot is handed a result path and the result slot a status path.
status_url: 'https://queue.fal.run/fal-ai/test/requests/req-7',
response_url: 'https://queue.fal.run/fal-ai/test/requests/req-7/status',
}),
@@ -166,6 +167,30 @@ describe('runFalQueue', () => {
])
})
+ it('rejects a same-origin candidate whose path is not a queue path at all', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ request_id: 'req-9',
+ // Same origin, but neither shape matches `/{app…}/requests/{id}[/status]`.
+ status_url: 'https://queue.fal.run/admin',
+ response_url: 'https://queue.fal.run/a/b/c',
+ }),
+ { status: 200 }
+ )
+ )
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
+ .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
+
+ await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key')
+
+ expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([
+ 'https://queue.fal.run/fal-ai/test/requests/req-9/status',
+ 'https://queue.fal.run/fal-ai/test/requests/req-9',
+ ])
+ })
+
it('bounds every queue read with the shared Fal queue JSON cap', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ request_id: 'req-8' }), { status: 200 })
diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts
index 52f1cafeb24..01178a290d1 100644
--- a/apps/sim/lib/media/ffmpeg.test.ts
+++ b/apps/sim/lib/media/ffmpeg.test.ts
@@ -54,25 +54,34 @@ describe('buildDrawtextFilter', () => {
})
it('routes the text through textfile and never inlines it', async () => {
- const filter = await buildDrawtextFilter(dir, 'Hello world', 'bottom')
+ const { filter, textPath } = await buildDrawtextFilter(dir, 'Hello world', 'bottom')
expect(filter.startsWith('drawtext=')).toBe(true)
- expect(filter).toContain(`textfile=${path.join(dir, 'drawtext.txt')}`)
+ expect(filter).toContain(`textfile=${textPath}`)
expect(filter).not.toContain('Hello world')
expect(filter).not.toContain(':text=')
- expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe('Hello world')
+ expect(await fs.readFile(textPath, 'utf-8')).toBe('Hello world')
+ })
+
+ it('writes a distinct text file per call so two overlays cannot clobber each other', async () => {
+ const first = await buildDrawtextFilter(dir, 'first overlay', 'bottom')
+ const second = await buildDrawtextFilter(dir, 'second overlay', 'bottom')
+
+ expect(second.textPath).not.toBe(first.textPath)
+ expect(await fs.readFile(first.textPath, 'utf-8')).toBe('first overlay')
+ expect(await fs.readFile(second.textPath, 'utf-8')).toBe('second overlay')
})
it('disables %{} expansion so text renders literally', async () => {
- const filter = await buildDrawtextFilter(dir, '100% of %{pts}', 'bottom')
+ const { filter, textPath } = await buildDrawtextFilter(dir, '100% of %{pts}', 'bottom')
expect(filter).toContain('expansion=none')
- expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe('100% of %{pts}')
+ expect(await fs.readFile(textPath, 'utf-8')).toBe('100% of %{pts}')
})
it('cannot introduce a new filter option from the text value', async () => {
const injection = "a':x=90:fontcolor=red,drawbox=0:0:100:100:red\\:,[in]scale=2[out];"
- const filter = await buildDrawtextFilter(dir, injection, 'bottom')
+ const { filter, textPath } = await buildDrawtextFilter(dir, injection, 'bottom')
const options = filter.replace(/^drawtext=/, '').split(':')
const optionNames = options.map((option) => option.split('=')[0])
@@ -92,7 +101,7 @@ describe('buildDrawtextFilter', () => {
expect(filter).not.toContain('fontcolor=red')
expect(filter).toContain('fontcolor=white')
expect(filter).not.toContain('x=90')
- expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe(injection)
+ expect(await fs.readFile(textPath, 'utf-8')).toBe(injection)
})
it.each([
@@ -105,9 +114,9 @@ describe('buildDrawtextFilter', () => {
['textfile option injection', 'x:textfile=/etc/passwd'],
['fontfile option injection', 'x:fontfile=/etc/shadow'],
])('writes %s verbatim to the text file', async (_label, text) => {
- const filter = await buildDrawtextFilter(dir, text, 'center')
+ const { filter, textPath } = await buildDrawtextFilter(dir, text, 'center')
- expect(await fs.readFile(path.join(dir, 'drawtext.txt'), 'utf-8')).toBe(text)
+ expect(await fs.readFile(textPath, 'utf-8')).toBe(text)
expect(filter.replace(/^drawtext=/, '').split(':').length).toBe(9)
expect(filter).toContain('x=(w-text_w)/2')
expect(filter).toContain('y=(h-text_h)/2')
@@ -115,15 +124,20 @@ describe('buildDrawtextFilter', () => {
})
it('escapes a temp dir path containing filtergraph metacharacters', async () => {
- const trickyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-test-'))
- const filter = await buildDrawtextFilter(trickyDir, 'text', 'top')
+ const trickyDir = path.join(dir, "a:b'c[d]")
+ await fs.mkdir(trickyDir, { recursive: true })
+
+ const { filter, textPath } = await buildDrawtextFilter(trickyDir, 'text', 'top')
- expect(filter).toContain(`textfile=${escapeFilterValue(path.join(trickyDir, 'drawtext.txt'))}`)
- await fs.rm(trickyDir, { recursive: true, force: true })
+ // The literal expected escaping, not a restatement of the implementation:
+ // each metacharacter survives as three backslashes plus itself.
+ const escapedSegment = String.raw`a\\\:b\\\'c\\\[d\\\]`
+ expect(filter).toContain(`textfile=${dir}/${escapedSegment}/${path.basename(textPath)}`)
+ expect(filter).not.toContain(`textfile=${trickyDir}/`)
})
it('falls back to the bottom position for an unknown position', async () => {
- const filter = await buildDrawtextFilter(dir, 'text', 'nowhere')
+ const { filter } = await buildDrawtextFilter(dir, 'text', 'nowhere')
expect(filter).toContain('y=h*0.86')
})
@@ -167,7 +181,7 @@ describe.skipIf(!hasFfmpeg())('runs through real ffmpeg', () => {
])('reads the text file from a dir containing %s', async (_label, name) => {
const dir = path.join(base, name)
await fs.mkdir(dir, { recursive: true })
- const filter = await buildDrawtextFilter(dir, 'hello', 'bottom')
+ const { filter } = await buildDrawtextFilter(dir, 'hello', 'bottom')
expect(() =>
execFileSync(
diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts
index 0b03d6ead6d..a07b32b2b19 100644
--- a/apps/sim/lib/media/ffmpeg.ts
+++ b/apps/sim/lib/media/ffmpeg.ts
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createLogger } from '@sim/logger'
+import { generateShortId } from '@sim/utils/id'
import ffmpeg from 'fluent-ffmpeg'
const logger = createLogger('MediaFfmpeg')
@@ -191,11 +192,9 @@ function escapeFilterValue(value: string): string {
return escapeFilterLevel(escapeFilterLevel(value))
}
-const DRAWTEXT_FILENAME = 'drawtext.txt'
-
/**
* Writes caller-supplied overlay text to a file inside the operation's temp dir
- * and returns the `drawtext` filter string that reads it.
+ * and returns the `drawtext` filter string that reads it, plus the path written.
*
* Routing the text through `textfile=` keeps it out of the filter-option string
* entirely, so no value in `text` can introduce or alter a filter option. Only
@@ -203,14 +202,17 @@ const DRAWTEXT_FILENAME = 'drawtext.txt'
* reaches the filtergraph, and it is escaped for filesystems whose paths can
* contain filtergraph metacharacters. `expansion=none` disables `%{…}` text
* expansion so the content renders literally.
+ *
+ * The filename is derived per call so two overlays built in the same temp dir
+ * cannot clobber each other's text.
*/
async function buildDrawtextFilter(
dir: string,
text: string,
position: string | undefined
-): Promise {
+): Promise<{ filter: string; textPath: string }> {
const pos = TEXT_POSITION[position || 'bottom'] || TEXT_POSITION.bottom
- const textPath = path.join(dir, DRAWTEXT_FILENAME)
+ const textPath = path.join(dir, `drawtext-${generateShortId(8)}.txt`)
await fs.writeFile(textPath, text, 'utf-8')
const options = [
@@ -225,7 +227,7 @@ async function buildDrawtextFilter(
`y=${pos.y}`,
].join(':')
- return `drawtext=${options}`
+ return { filter: `drawtext=${options}`, textPath }
}
async function withTempDir(fn: (dir: string) => Promise): Promise {
@@ -534,9 +536,9 @@ async function addText(
options: FfmpegOptions
): Promise {
if (!options.text) throw new Error('add_text requires text')
- const drawtext = await buildDrawtextFilter(dir, options.text, options.position)
+ const { filter } = await buildDrawtextFilter(dir, options.text, options.position)
const outputPath = path.join(dir, 'out.mp4')
- const command = ffmpeg(inputPath).videoFilters(drawtext).outputOptions(['-c:a', 'copy'])
+ const command = ffmpeg(inputPath).videoFilters(filter).outputOptions(['-c:a', 'copy'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}