diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 046fadd003..df50b82d0e 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -5,7 +5,11 @@ import type { IAppMeta } from "@posthog/platform/app-meta"; import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; import type { IImageProcessor } from "@posthog/platform/image-processor"; import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { IMAGE_MIME_TYPES } from "@shared/constants/image"; +import { + ALLOWED_IMAGE_MIME_TYPES, + IMAGE_MIME_TYPES, + isRasterImageFile, +} from "@posthog/shared"; import { z } from "zod"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -293,7 +297,8 @@ export const osRouter = router({ if (stat.size > input.maxSizeBytes) return null; const ext = path.extname(input.filePath).toLowerCase().slice(1); - const mime = IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; + const mime = IMAGE_MIME_TYPES[ext]; + if (!mime || !ALLOWED_IMAGE_MIME_TYPES.has(mime)) return null; const buffer = await fsPromises.readFile(input.filePath); return `data:${mime};base64,${buffer.toString("base64")}`; @@ -354,7 +359,7 @@ export const osRouter = router({ .input(z.object({ filePath: z.string().min(1) })) .mutation(async ({ input }) => { const ext = path.extname(input.filePath).toLowerCase().slice(1); - if (!IMAGE_MIME_TYPES[ext]) { + if (!isRasterImageFile(input.filePath)) { throw new Error(`Unsupported image type: .${ext}`); } diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx index cbe6fbd637..2e5aed70c7 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx @@ -1,9 +1,9 @@ -import { Flex, Text } from "@radix-ui/themes"; import { buildImageDataUrl, isAllowedImageMimeType, MAX_IMAGE_BASE64_LENGTH, -} from "@shared/utils/imageDataUrl"; +} from "@posthog/shared"; +import { Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; interface SafeImagePreviewProps { @@ -12,6 +12,7 @@ interface SafeImagePreviewProps { mimeType: string; alt?: string; className?: string; + style?: React.CSSProperties; /** Rendered when the image fails to decode or has a disallowed mime type. */ fallback?: React.ReactNode; } @@ -33,6 +34,7 @@ export function SafeImagePreview({ mimeType, alt, className, + style, fallback, }: SafeImagePreviewProps) { const [hasError, setHasError] = useState(false); @@ -57,6 +59,7 @@ export function SafeImagePreview({ src={buildImageDataUrl(mimeType, base64)} alt={alt ?? "image preview"} className={className ?? "max-h-full max-w-full object-contain"} + style={style} onError={() => setHasError(true)} /> ); diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 0cdb2e6df0..aa7b1f7bca 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -12,11 +12,14 @@ import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { Check, Copy } from "@phosphor-icons/react"; +import { + getImageMimeType, + isRasterImageFile, + parseImageDataUrl, +} from "@posthog/shared"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import type { Task } from "@shared/types"; -import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; @@ -73,7 +76,7 @@ export function CodeEditorPanel({ const repoPath = useCwd(taskId); const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); const filePath = getRelativePath(absolutePath, repoPath); - const isImage = isImageFile(absolutePath); + const isImage = isRasterImageFile(absolutePath); const isMarkdown = isMarkdownFile(absolutePath); const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); const expandToFile = useFileTreeStore((s) => s.expandToFile); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts index 7194a769a0..5d7131c9d7 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -7,26 +7,6 @@ const mockFs = vi.hoisted(() => ({ readFileAsBase64: { query: vi.fn() }, })); -vi.mock("@shared/constants/image", async () => { - const actual = await vi.importActual< - typeof import("@shared/constants/image") - >("@shared/constants/image"); - return { - ...actual, - getImageMimeType: (name: string) => { - const ext = name.split(".").pop()?.toLowerCase(); - const map: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - }; - return map[ext ?? ""] ?? "image/png"; - }, - }; -}); - vi.mock("@renderer/trpc/client", () => ({ trpcClient: { fs: mockFs, @@ -172,6 +152,26 @@ describe("cloud-prompt", () => { ).rejects.toThrow(/Unsupported image/); }); + it("treats SVG attachments as text resource links", async () => { + const blocks = await buildCloudPromptBlocks( + 'see ', + ); + expect(blocks[1]).toMatchObject({ + type: "resource_link", + name: "icon.svg", + }); + expect(mockFs.readFileAsBase64.query).not.toHaveBeenCalled(); + }); + + it("rejects HEIC and HEIF as unsupported attachments (not images)", async () => { + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unsupported attachment/); + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unsupported attachment/); + }); + it("does not rely on readAbsoluteFile for txt attachments", async () => { const blocks = await buildCloudPromptBlocks( 'read ', diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index ccbf2d6395..079d30a1c2 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -1,7 +1,12 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; +import { + CLOUD_PROMPT_PREFIX, + getImageMimeType, + isClaudeImageFile, + isRasterImageFile, + serializeCloudPrompt, +} from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFileExtension, getFileName, @@ -61,8 +66,6 @@ const TEXT_FILENAMES = new Set([ "README", "README.md", ]); -const CLOUD_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); - const MAX_EMBEDDED_IMAGE_BYTES = 5 * 1024 * 1024; function isTextAttachment(filePath: string): boolean { @@ -71,10 +74,6 @@ function isTextAttachment(filePath: string): boolean { return TEXT_FILENAMES.has(fileName) || TEXT_EXTENSIONS.has(ext); } -export function isSupportedCloudImageAttachment(filePath: string): boolean { - return CLOUD_IMAGE_EXTENSIONS.has(getFileExtension(filePath)); -} - export function isSupportedCloudTextAttachment(filePath: string): boolean { return isTextAttachment(filePath); } @@ -163,7 +162,7 @@ async function buildAttachmentBlock(filePath: string): Promise { const fileName = getFileName(filePath); const uri = pathToFileUri(filePath); - if (isSupportedCloudImageAttachment(fileName)) { + if (isClaudeImageFile(fileName)) { const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); if (!base64) { throw new Error(`Unable to read attached image ${fileName}`); @@ -183,7 +182,7 @@ async function buildAttachmentBlock(filePath: string): Promise { }; } - if (isImageFile(fileName)) { + if (isRasterImageFile(fileName)) { throw new Error( `Cloud image attachments currently support PNG, JPG, GIF, and WebP. Unsupported image: ${fileName}`, ); diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index 5cb3c971eb..32170ea698 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -12,9 +12,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; +import { isRasterImageFile } from "@posthog/shared"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; -import { isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; import { @@ -123,7 +123,7 @@ export function AttachmentMenu({ try { const results = await trpcClient.os.selectAttachments.query({ mode }); for (const { path: filePath, kind } of results) { - if (kind === "file" && isImageFile(filePath)) { + if (kind === "file" && isRasterImageFile(filePath)) { try { const attachment = await persistImageFilePath(filePath); onAddAttachment(attachment); diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx index 5c4408100b..dfba7350c5 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx @@ -1,7 +1,7 @@ import { File, X } from "@phosphor-icons/react"; +import { isGifFile, isRasterImageFile } from "@posthog/shared"; import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; -import { isGifFile, isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import type { FileAttachment } from "../utils/content"; @@ -151,7 +151,7 @@ export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) { return ( {attachments.map((att) => - isImageFile(att.label) ? ( + isRasterImageFile(att.label) ? ( ({ }, })); -vi.mock("@shared/constants/image", async () => { - const actual = await vi.importActual< - typeof import("@shared/constants/image") - >("@shared/constants/image"); +vi.mock("@posthog/shared", async () => { + const actual = + await vi.importActual("@posthog/shared"); return { ...actual, getImageMimeType: () => "image/png" }; }); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts index 8ae3c42404..1e366b57b2 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -1,6 +1,6 @@ +import { getImageMimeType, isRasterImageFile } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFilePath } from "@utils/getFilePath"; import type { FileAttachment } from "./content"; @@ -74,7 +74,7 @@ export async function resolveDroppedFile( const filePath = getFilePath(file); if (!filePath) return null; - if (isImageFile(file.name)) { + if (isRasterImageFile(file.name)) { try { return await persistImageFilePath(filePath); } catch { diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx index 7f7841d282..eebf2b948c 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx @@ -1,8 +1,8 @@ import { EditorView } from "@codemirror/view"; import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { MultiFileDiff } from "@pierre/diffs/react"; +import { parseImageDataUrl } from "@posthog/shared"; import { Code } from "@radix-ui/themes"; -import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useThemeStore } from "@stores/themeStore"; import { compactHomePath } from "@utils/path"; import { useEffect, useMemo, useRef } from "react"; diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index c2f3b0fa95..46cc1fffcf 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -1,56 +1,15 @@ import { fetchAuthState } from "@features/auth/hooks/authQueries"; import { xmlToContent } from "@features/message-editor/utils/content"; +import { isBinaryFile } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc"; import { logger } from "@utils/logger"; +import { getFileName } from "@utils/path"; const log = logger.scope("title-generator"); const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; const PASTED_TEXT_SNIPPET_LIMIT = 500; -const BINARY_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "bmp", - "ico", - "svg", - "mp3", - "mp4", - "wav", - "avi", - "mov", - "mkv", - "pdf", - "zip", - "tar", - "gz", - "rar", - "7z", - "exe", - "dll", - "so", - "dylib", - "wasm", - "ttf", - "otf", - "woff", - "woff2", - "eot", -]); - -function getExtension(filePath: string): string { - const dot = filePath.lastIndexOf("."); - return dot >= 0 ? filePath.slice(dot + 1).toLowerCase() : ""; -} - -function getFileName(filePath: string): string { - const slash = filePath.lastIndexOf("/"); - return slash >= 0 ? filePath.slice(slash + 1) : filePath; -} - export async function enrichDescriptionWithFileContent( description: string, filePaths: string[] = [], @@ -74,7 +33,7 @@ export async function enrichDescriptionWithFileContent( const parts = await Promise.all( paths.map(async (filePath) => { - if (BINARY_EXTENSIONS.has(getExtension(filePath))) { + if (isBinaryFile(filePath)) { return `[Attached: ${getFileName(filePath)}]`; } try { diff --git a/apps/code/src/shared/constants/image.ts b/apps/code/src/shared/constants/image.ts deleted file mode 100644 index b7b2e11fc3..0000000000 --- a/apps/code/src/shared/constants/image.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const IMAGE_MIME_TYPES: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - ico: "image/x-icon", - tiff: "image/tiff", - tif: "image/tiff", -}; - -const IMAGE_EXTENSIONS = new Set(Object.keys(IMAGE_MIME_TYPES)); - -export function isImageFile(filename: string): boolean { - const ext = filename.split(".").pop()?.toLowerCase(); - return !!ext && IMAGE_EXTENSIONS.has(ext); -} - -export function isGifFile(filename: string): boolean { - return filename.split(".").pop()?.toLowerCase() === "gif"; -} - -export function getImageMimeType(filePath: string): string { - const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; - return IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; -} diff --git a/apps/code/src/shared/utils/imageDataUrl.test.ts b/apps/code/src/shared/utils/imageDataUrl.test.ts deleted file mode 100644 index f197142da6..0000000000 --- a/apps/code/src/shared/utils/imageDataUrl.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildImageDataUrl, - isAllowedImageMimeType, - parseImageDataUrl, -} from "./imageDataUrl"; - -const TINY_PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - -describe("parseImageDataUrl", () => { - it("parses a valid PNG data URL", () => { - const result = parseImageDataUrl( - `data:image/png;base64,${TINY_PNG_BASE64}`, - ); - expect(result).toEqual({ - mimeType: "image/png", - base64: TINY_PNG_BASE64, - }); - }); - - it.each([ - ["image/jpeg"], - ["image/webp"], - ["image/gif"], - ["image/bmp"], - ["image/avif"], - ["image/tiff"], - ["image/x-icon"], - ])("accepts allowed mime type %s", (mimeType) => { - const result = parseImageDataUrl( - `data:${mimeType};base64,${TINY_PNG_BASE64}`, - ); - expect(result).not.toBeNull(); - expect(result?.mimeType).toBe(mimeType); - }); - - it("rejects SVG data URLs to prevent script execution", () => { - expect( - parseImageDataUrl(`data:image/svg+xml;base64,${TINY_PNG_BASE64}`), - ).toBeNull(); - }); - - it.each([ - ["text/html"], - ["application/javascript"], - ["application/octet-stream"], - ["text/plain"], - ])("rejects non-image mime type %s", (mimeType) => { - expect( - parseImageDataUrl(`data:${mimeType};base64,${TINY_PNG_BASE64}`), - ).toBeNull(); - }); - - it("rejects non-base64 data URLs", () => { - expect(parseImageDataUrl("data:image/png,not-base64")).toBeNull(); - }); - - it.each([ - ["empty string", ""], - ["plain text", "hello world"], - ["http URL", "https://example.com/image.png"], - ["truncated data prefix", "data"], - ["missing payload separator", "data:image/png;base64"], - ["empty payload", "data:image/png;base64,"], - ["bare prefix", "data:"], - ])("rejects non-data-URL or malformed input: %s", (_label, value) => { - expect(parseImageDataUrl(value)).toBeNull(); - }); - - it("rejects extremely large payloads", () => { - const huge = "A".repeat(30 * 1024 * 1024); - expect(parseImageDataUrl(`data:image/png;base64,${huge}`)).toBeNull(); - }); - - it("trims surrounding whitespace before parsing", () => { - const result = parseImageDataUrl( - `\n data:image/png;base64,${TINY_PNG_BASE64} \n`, - ); - expect(result?.mimeType).toBe("image/png"); - }); - - it("tolerates long leading-whitespace prefixes", () => { - const padding = " ".repeat(256); - const result = parseImageDataUrl( - `${padding}data:image/png;base64,${TINY_PNG_BASE64}`, - ); - expect(result?.mimeType).toBe("image/png"); - }); - - it("strips whitespace inside base64 payload", () => { - const withNewlines = TINY_PNG_BASE64.match(/.{1,40}/g)?.join("\n") ?? ""; - const result = parseImageDataUrl(`data:image/png;base64,${withNewlines}`); - expect(result?.base64).toBe(TINY_PNG_BASE64); - }); - - it("ignores additional parameters before the base64 marker", () => { - const result = parseImageDataUrl( - `data:image/png;charset=utf-8;base64,${TINY_PNG_BASE64}`, - ); - expect(result?.mimeType).toBe("image/png"); - }); - - it("normalises mime type casing", () => { - const result = parseImageDataUrl( - `data:IMAGE/PNG;base64,${TINY_PNG_BASE64}`, - ); - expect(result?.mimeType).toBe("image/png"); - }); - - it.each([[null], [undefined], [123], [{}]])( - "handles non-string input safely: %p", - (value) => { - expect(parseImageDataUrl(value as unknown as string)).toBeNull(); - }, - ); -}); - -describe("isAllowedImageMimeType", () => { - it.each([["image/png"], ["IMAGE/JPEG"], ["image/webp"], ["image/gif"]])( - "accepts %s", - (mimeType) => { - expect(isAllowedImageMimeType(mimeType)).toBe(true); - }, - ); - - it.each([ - ["image/svg+xml"], - ["text/html"], - ["application/javascript"], - ["text/plain"], - ])("rejects %s", (mimeType) => { - expect(isAllowedImageMimeType(mimeType)).toBe(false); - }); -}); - -describe("buildImageDataUrl", () => { - it("builds a data URL from parts", () => { - expect(buildImageDataUrl("image/png", "abc")).toBe( - "data:image/png;base64,abc", - ); - }); -}); diff --git a/apps/code/src/shared/utils/imageDataUrl.ts b/apps/code/src/shared/utils/imageDataUrl.ts deleted file mode 100644 index 7ce8522656..0000000000 --- a/apps/code/src/shared/utils/imageDataUrl.ts +++ /dev/null @@ -1,52 +0,0 @@ -const ALLOWED_IMAGE_MIME_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/bmp", - "image/x-icon", - "image/vnd.microsoft.icon", - "image/tiff", - "image/avif", -]); - -const DATA_URL_PATTERN = - /^data:([a-zA-Z]+\/[a-zA-Z0-9.+-]+)(?:;[a-zA-Z0-9-]+=[^;,]+)*;base64,([A-Za-z0-9+/=\s]+)$/; - -const MAX_DATA_URL_LENGTH = 20 * 1024 * 1024; -export const MAX_IMAGE_BASE64_LENGTH = 15 * 1024 * 1024; - -export interface ParsedImageDataUrl { - mimeType: string; - base64: string; -} - -export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { - if (typeof value !== "string" || value.length === 0) return null; - if (value.length > MAX_DATA_URL_LENGTH) return null; - if (!/^\s{0,1024}data:/.test(value)) return null; - - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - const match = DATA_URL_PATTERN.exec(trimmed); - if (!match) return null; - - const mimeType = match[1].toLowerCase(); - if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) return null; - - const base64 = match[2].replace(/\s+/g, ""); - if (base64.length === 0 || base64.length > MAX_IMAGE_BASE64_LENGTH) { - return null; - } - - return { mimeType, base64 }; -} - -export function isAllowedImageMimeType(mimeType: string): boolean { - return ALLOWED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase()); -} - -export function buildImageDataUrl(mimeType: string, base64: string): string { - return `data:${mimeType};base64,${base64}`; -} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 8afd10d43f..384c7b4449 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -16,9 +16,12 @@ config.resolver.nodeModulesPaths = [ path.resolve(monorepoRoot, "node_modules"), ]; -// Force React to resolve from monorepo root +// Force React to resolve from monorepo root, and alias workspace packages +// directly to their TypeScript source so Babel can transpile them (Metro +// does not consume the ESM `dist/` build cleanly). config.resolver.extraNodeModules = { react: path.resolve(monorepoRoot, "node_modules/react"), + "@posthog/shared": path.resolve(monorepoRoot, "packages/shared/src/index.ts"), }; // Apply NativeWind first so its resolver/transformer changes are in place diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b5de3bef6e..daa6872662 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "@expo/ui": "0.2.0-beta.9", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", diff --git a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts index b3e220f165..375948798d 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts +++ b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts @@ -1,3 +1,4 @@ +import { getImageMimeType } from "@posthog/shared"; import { Alert } from "react-native"; import { logger } from "@/lib/logger"; import type { PendingAttachment } from "./types"; @@ -10,11 +11,8 @@ function makeId(): string { function inferImageMime(uri: string, mime?: string | null): string { if (mime) return mime; - const lower = uri.toLowerCase(); - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".webp")) return "image/webp"; - return "image/jpeg"; + const detected = getImageMimeType(uri); + return detected === "application/octet-stream" ? "image/jpeg" : detected; } function deriveFileName(uri: string, fallback: string): string { diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index 24d65db929..9843b339ab 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -95,6 +95,45 @@ describe("promptToClaude", () => { expect(JSON.stringify(attachRes)).toContain("KEEP_ATTACH"); }); + it("forwards base64 image blocks with supported MIME types", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [ + { + type: "image", + data: "ZmFrZQ==", + mimeType: "image/png", + } as PromptRequest["prompt"][number], + ], + }); + + expect(result.message.content).toEqual([ + { + type: "image", + source: { type: "base64", data: "ZmFrZQ==", media_type: "image/png" }, + }, + ]); + }); + + it("replaces unsupported base64 image MIME types with a text notice", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [ + { + type: "image", + data: "ZmFrZQ==", + mimeType: "image/heic", + } as PromptRequest["prompt"][number], + ], + }); + + expect(result.message.content).toHaveLength(1); + expect(result.message.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("image/heic"), + }); + }); + it("maps file URI-only image blocks to workspace Read prompt text", () => { const req: PromptRequest = { sessionId: "session-1", diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 323e4bc43b..7780e70a60 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -3,24 +3,12 @@ import { fileURLToPath } from "node:url"; import type { PromptRequest } from "@agentclientprotocol/sdk"; import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; - -type ImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp"; +import { isClaudeImageMimeType, isImageFile } from "@posthog/shared"; const PDF_EXTENSIONS = new Set(["pdf"]); -const COMMON_IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "bmp", - "svg", - "heic", - "tif", - "tiff", -]); - +// Video-only on purpose: audio formats get the default "large text" hint. +// Do not replace with AUDIO_VIDEO_EXTENSIONS from @posthog/shared. const VIDEO_EXTENSIONS = new Set([ "mp4", "mov", @@ -53,7 +41,7 @@ export function readToolGuidanceForPath(filePath: string): string { if (PDF_EXTENSIONS.has(ext)) { return 'Optional `pages` string (e.g. "1-5") per Read call instead of loading the entire PDF.'; } - if (COMMON_IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) { + if (isImageFile(filePath) || VIDEO_EXTENSIONS.has(ext)) { return "Binary file — use Read with `file_path`; prefer bounded reads where supported."; } return "Large text — use multiple Read calls with optional `offset` and `limit`."; @@ -131,14 +119,22 @@ function processPromptChunk( case "image": if (chunk.data) { - content.push({ - type: "image", - source: { - type: "base64", - data: chunk.data, - media_type: chunk.mimeType as ImageMimeType, - }, - }); + if (isClaudeImageMimeType(chunk.mimeType)) { + content.push({ + type: "image", + source: { + type: "base64", + data: chunk.data, + media_type: chunk.mimeType, + }, + }); + } else { + content.push( + sdkText( + `[Unsupported image MIME type: ${chunk.mimeType}. Supported: image/jpeg, image/png, image/gif, image/webp.]`, + ), + ); + } } else if (chunk.uri?.startsWith("http")) { content.push({ type: "image", diff --git a/packages/shared/src/binary.test.ts b/packages/shared/src/binary.test.ts new file mode 100644 index 0000000000..042073e843 --- /dev/null +++ b/packages/shared/src/binary.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + ARCHIVE_EXTENSIONS, + AUDIO_VIDEO_EXTENSIONS, + BINARY_EXTENSIONS, + DOCUMENT_BINARY_EXTENSIONS, + EXECUTABLE_EXTENSIONS, + FONT_EXTENSIONS, + isBinaryFile, +} from "./binary"; +import { IMAGE_MIME_TYPES } from "./image"; + +describe("isBinaryFile", () => { + it.each([ + ["foo.png"], + ["foo.JPG"], + ["path/to/foo.gif"], + ["foo.webp"], + ["foo.tiff"], + ["foo.avif"], + ["foo.heic"], + ["foo.heif"], + ["foo.mp3"], + ["foo.mp4"], + ["foo.mov"], + ["foo.flac"], + ["foo.ogg"], + ["foo.m4a"], + ["foo.aac"], + ["foo.mpg"], + ["foo.mpeg"], + ["foo.pdf"], + ["foo.zip"], + ["foo.tar.gz"], + ["foo.tgz"], + ["foo.bz2"], + ["foo.xz"], + ["foo.7z"], + ["foo.exe"], + ["foo.dll"], + ["foo.dylib"], + ["foo.wasm"], + ["foo.bin"], + ["foo.o"], + ["foo.ttf"], + ["foo.otf"], + ["foo.woff2"], + ["foo.eot"], + ])("returns true for %s", (filename) => { + expect(isBinaryFile(filename)).toBe(true); + }); + + it.each([ + ["foo.txt"], + ["foo.md"], + ["foo.ts"], + ["foo.json"], + ["foo"], + [""], + ["README"], + [".gitignore"], + ["foo.svg"], + ["path/to/icon.svg"], + ])("returns false for %s", (filename) => { + expect(isBinaryFile(filename)).toBe(false); + }); + + it("excludes SVG so title generation reads it as text", () => { + expect(BINARY_EXTENSIONS.has("svg")).toBe(false); + }); + + it("includes every binary extension from the source-of-truth sets", () => { + const expected = [ + ...Object.keys(IMAGE_MIME_TYPES).filter((ext) => ext !== "svg"), + ...AUDIO_VIDEO_EXTENSIONS, + ...ARCHIVE_EXTENSIONS, + ...EXECUTABLE_EXTENSIONS, + ...FONT_EXTENSIONS, + ...DOCUMENT_BINARY_EXTENSIONS, + ]; + for (const ext of expected) { + expect(BINARY_EXTENSIONS.has(ext)).toBe(true); + } + }); +}); diff --git a/packages/shared/src/binary.ts b/packages/shared/src/binary.ts new file mode 100644 index 0000000000..d22545293e --- /dev/null +++ b/packages/shared/src/binary.ts @@ -0,0 +1,64 @@ +import { extensionOf, IMAGE_MIME_TYPES } from "./image"; + +export const AUDIO_VIDEO_EXTENSIONS: ReadonlySet = new Set([ + "mp3", + "mp4", + "wav", + "avi", + "mov", + "mkv", + "webm", + "mpg", + "mpeg", + "flac", + "ogg", + "m4a", + "aac", +]); + +export const ARCHIVE_EXTENSIONS: ReadonlySet = new Set([ + "zip", + "tar", + "gz", + "tgz", + "bz2", + "xz", + "rar", + "7z", +]); + +export const EXECUTABLE_EXTENSIONS: ReadonlySet = new Set([ + "exe", + "dll", + "so", + "dylib", + "wasm", + "bin", + "o", +]); + +export const FONT_EXTENSIONS: ReadonlySet = new Set([ + "ttf", + "otf", + "woff", + "woff2", + "eot", +]); + +export const DOCUMENT_BINARY_EXTENSIONS: ReadonlySet = new Set(["pdf"]); + +// SVG is excluded — it is XML text and consumers like title generation read +// it as text rather than treating it as opaque bytes. +export const BINARY_EXTENSIONS: ReadonlySet = new Set([ + ...Object.keys(IMAGE_MIME_TYPES).filter((ext) => ext !== "svg"), + ...AUDIO_VIDEO_EXTENSIONS, + ...ARCHIVE_EXTENSIONS, + ...EXECUTABLE_EXTENSIONS, + ...FONT_EXTENSIONS, + ...DOCUMENT_BINARY_EXTENSIONS, +]); + +export function isBinaryFile(filename: string): boolean { + const ext = extensionOf(filename); + return ext.length > 0 && BINARY_EXTENSIONS.has(ext); +} diff --git a/packages/shared/src/image.test.ts b/packages/shared/src/image.test.ts new file mode 100644 index 0000000000..6e9a959d30 --- /dev/null +++ b/packages/shared/src/image.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { + buildImageDataUrl, + getImageMimeType, + isAllowedImageMimeType, + isClaudeImageFile, + isClaudeImageMimeType, + isGifFile, + isImageFile, + isRasterImageFile, + parseImageDataUrl, +} from "./image"; + +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +describe("parseImageDataUrl", () => { + it("parses a valid PNG data URL", () => { + const result = parseImageDataUrl( + `data:image/png;base64,${TINY_PNG_BASE64}`, + ); + expect(result).toEqual({ + mimeType: "image/png", + base64: TINY_PNG_BASE64, + }); + }); + + it.each([ + ["image/jpeg"], + ["image/webp"], + ["image/gif"], + ["image/bmp"], + ["image/avif"], + ["image/tiff"], + ["image/x-icon"], + ])("accepts allowed mime type %s", (mimeType) => { + const result = parseImageDataUrl( + `data:${mimeType};base64,${TINY_PNG_BASE64}`, + ); + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe(mimeType); + }); + + it("rejects SVG data URLs to prevent script execution", () => { + expect( + parseImageDataUrl(`data:image/svg+xml;base64,${TINY_PNG_BASE64}`), + ).toBeNull(); + }); + + it.each([ + ["text/html"], + ["application/javascript"], + ["application/octet-stream"], + ["text/plain"], + ])("rejects non-image mime type %s", (mimeType) => { + expect( + parseImageDataUrl(`data:${mimeType};base64,${TINY_PNG_BASE64}`), + ).toBeNull(); + }); + + it("rejects non-base64 data URLs", () => { + expect(parseImageDataUrl("data:image/png,not-base64")).toBeNull(); + }); + + it.each([ + ["empty string", ""], + ["plain text", "hello world"], + ["http URL", "https://example.com/image.png"], + ["truncated data prefix", "data"], + ["missing payload separator", "data:image/png;base64"], + ["empty payload", "data:image/png;base64,"], + ["bare prefix", "data:"], + ])("rejects non-data-URL or malformed input: %s", (_label, value) => { + expect(parseImageDataUrl(value)).toBeNull(); + }); + + it("rejects extremely large payloads", () => { + const huge = "A".repeat(30 * 1024 * 1024); + expect(parseImageDataUrl(`data:image/png;base64,${huge}`)).toBeNull(); + }); + + it("trims surrounding whitespace before parsing", () => { + const result = parseImageDataUrl( + `\n data:image/png;base64,${TINY_PNG_BASE64} \n`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("tolerates long leading-whitespace prefixes", () => { + const padding = " ".repeat(256); + const result = parseImageDataUrl( + `${padding}data:image/png;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("strips whitespace inside base64 payload", () => { + const withNewlines = TINY_PNG_BASE64.match(/.{1,40}/g)?.join("\n") ?? ""; + const result = parseImageDataUrl(`data:image/png;base64,${withNewlines}`); + expect(result?.base64).toBe(TINY_PNG_BASE64); + }); + + it("ignores additional parameters before the base64 marker", () => { + const result = parseImageDataUrl( + `data:image/png;charset=utf-8;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("normalises mime type casing", () => { + const result = parseImageDataUrl( + `data:IMAGE/PNG;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it.each([[null], [undefined], [123], [{}]])( + "handles non-string input safely: %p", + (value) => { + expect(parseImageDataUrl(value as unknown as string)).toBeNull(); + }, + ); +}); + +describe("isAllowedImageMimeType", () => { + it.each([["image/png"], ["IMAGE/JPEG"], ["image/webp"], ["image/gif"]])( + "accepts %s", + (mimeType) => { + expect(isAllowedImageMimeType(mimeType)).toBe(true); + }, + ); + + it.each([ + ["image/svg+xml"], + ["image/heic"], + ["image/heif"], + ["text/html"], + ["application/javascript"], + ["text/plain"], + ])("rejects %s", (mimeType) => { + expect(isAllowedImageMimeType(mimeType)).toBe(false); + }); +}); + +describe("buildImageDataUrl", () => { + it("builds a data URL from parts", () => { + expect(buildImageDataUrl("image/png", "abc")).toBe( + "data:image/png;base64,abc", + ); + }); +}); + +describe("isImageFile", () => { + it.each([ + ["foo.png"], + ["foo.PNG"], + ["path/to/foo.jpg"], + ["foo.jpeg"], + ["foo.gif"], + ["foo.webp"], + ["foo.bmp"], + ["foo.ico"], + ["foo.tiff"], + ["foo.tif"], + ["foo.svg"], + ["foo.heic"], + ["foo.heif"], + ["foo.avif"], + ])("returns true for %s", (filename) => { + expect(isImageFile(filename)).toBe(true); + }); + + it.each([["foo.txt"], ["foo.md"], ["foo"], ["foo.ts"], ["foo.pdf"], [""]])( + "returns false for %s", + (filename) => { + expect(isImageFile(filename)).toBe(false); + }, + ); +}); + +describe("isRasterImageFile", () => { + it.each([ + ["foo.png"], + ["foo.jpg"], + ["foo.jpeg"], + ["foo.JPEG"], + ["foo.gif"], + ["foo.webp"], + ["foo.bmp"], + ["foo.ico"], + ["foo.tiff"], + ["foo.tif"], + ["foo.avif"], + ])("returns true for raster %s", (filename) => { + expect(isRasterImageFile(filename)).toBe(true); + }); + + it.each([["foo.svg"], ["foo.heic"], ["foo.heif"]])( + "returns false for non-raster %s", + (filename) => { + expect(isRasterImageFile(filename)).toBe(false); + }, + ); + + it("returns false for non-images", () => { + expect(isRasterImageFile("foo.txt")).toBe(false); + expect(isRasterImageFile("foo")).toBe(false); + }); + + it("returns false for dotfiles with no real extension", () => { + expect(isRasterImageFile(".gitignore")).toBe(false); + }); + + it("returns false for hidden files in a directory", () => { + expect(isRasterImageFile("/path/.heic")).toBe(false); + expect(isRasterImageFile("C:\\path\\.png")).toBe(false); + }); + + it("strips URI query and fragment before parsing extension", () => { + expect(isRasterImageFile("ph://asset/IMG.png?width=1024")).toBe(true); + expect(isRasterImageFile("file://photo.jpg#preview")).toBe(true); + }); +}); + +describe("isClaudeImageMimeType", () => { + it.each([["image/jpeg"], ["image/png"], ["IMAGE/GIF"], ["image/webp"]])( + "accepts %s", + (mimeType) => { + expect(isClaudeImageMimeType(mimeType)).toBe(true); + }, + ); + + it.each([ + ["image/svg+xml"], + ["image/heic"], + ["image/bmp"], + ["image/avif"], + ["application/octet-stream"], + ["text/plain"], + ])("rejects %s", (mimeType) => { + expect(isClaudeImageMimeType(mimeType)).toBe(false); + }); +}); + +describe("isClaudeImageFile", () => { + it.each([["foo.png"], ["foo.JPG"], ["foo.jpeg"], ["foo.gif"], ["foo.webp"]])( + "returns true for Claude-supported %s", + (filename) => { + expect(isClaudeImageFile(filename)).toBe(true); + }, + ); + + it.each([ + ["foo.bmp"], + ["foo.ico"], + ["foo.tiff"], + ["foo.svg"], + ["foo.heic"], + ["foo.heif"], + ["foo.avif"], + ["foo.txt"], + ["foo"], + [""], + ])("returns false for unsupported %s", (filename) => { + expect(isClaudeImageFile(filename)).toBe(false); + }); +}); + +describe("isGifFile", () => { + it("returns true for .gif", () => { + expect(isGifFile("foo.gif")).toBe(true); + expect(isGifFile("foo.GIF")).toBe(true); + }); + + it("returns false for non-gif images", () => { + expect(isGifFile("foo.png")).toBe(false); + }); +}); + +describe("getImageMimeType", () => { + it.each([ + ["foo.png", "image/png"], + ["foo.jpg", "image/jpeg"], + ["foo.JPEG", "image/jpeg"], + ["foo.gif", "image/gif"], + ["foo.webp", "image/webp"], + ["foo.svg", "image/svg+xml"], + ["foo.heic", "image/heic"], + ["foo.heif", "image/heif"], + ["foo.avif", "image/avif"], + ["foo.ico", "image/x-icon"], + ["foo.tiff", "image/tiff"], + ["foo.tif", "image/tiff"], + ])("maps %s to %s", (filename, expected) => { + expect(getImageMimeType(filename)).toBe(expected); + }); + + it("falls back to application/octet-stream for unknown extensions", () => { + expect(getImageMimeType("foo.unknown")).toBe("application/octet-stream"); + expect(getImageMimeType("foo")).toBe("application/octet-stream"); + }); + + it("strips URI query and fragment from picker-style URIs", () => { + expect(getImageMimeType("ph://asset/IMG.png?width=1024")).toBe("image/png"); + expect(getImageMimeType("https://cdn/img.webp#thumb")).toBe("image/webp"); + }); + + it("ignores extensions on hidden basenames", () => { + expect(getImageMimeType("/path/.heic")).toBe("application/octet-stream"); + }); +}); diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts new file mode 100644 index 0000000000..7bc4603e5e --- /dev/null +++ b/packages/shared/src/image.ts @@ -0,0 +1,146 @@ +export const IMAGE_MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + tiff: "image/tiff", + tif: "image/tiff", + svg: "image/svg+xml", + heic: "image/heic", + heif: "image/heif", + avif: "image/avif", +}; + +const IMAGE_EXTENSIONS: ReadonlySet = new Set( + Object.keys(IMAGE_MIME_TYPES), +); + +const RASTER_IMAGE_EXTENSIONS: ReadonlySet = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + "tiff", + "tif", + "avif", +]); + +// SVG is intentionally excluded — SVG can contain