feat: normalize and filter chat attachments

Attachment pickers now share an allowlist for supported file types
Local attachments are normalized to consistent MIME types before upload
VS Code file picker now respects extension filters and larger files are allowed
This commit is contained in:
Bohdan Triapitsyn
2026-07-22 11:33:22 +03:00
parent 6525c11042
commit d3a2564cf6
8 changed files with 245 additions and 21 deletions
+11 -7
View File
@@ -10,7 +10,7 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store'; import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
@@ -3716,14 +3716,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
if (files.length > 0) { if (files.length > 0) {
let attached = false;
for (const file of files) { for (const file of files) {
try { try {
await addAttachedFile(file); attached = (await addAttachedFile(file)) || attached;
} catch (error) { } catch (error) {
console.error('File attach failed', error); console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
} }
} }
if (!attached) toast.error(t('chat.chatInput.toast.attachFileFailed'));
} }
clearDropTextSuppression(); clearDropTextSuppression();
}; };
@@ -3744,20 +3745,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const attachFiles = React.useCallback(async (files: FileList | File[]) => { const attachFiles = React.useCallback(async (files: FileList | File[]) => {
const list = Array.isArray(files) ? files : Array.from(files); const list = Array.isArray(files) ? files : Array.from(files);
let attached = false;
for (const file of list) { for (const file of list) {
try { try {
await addAttachedFile(file); attached = (await addAttachedFile(file)) || attached;
} catch (error) { } catch (error) {
console.error('File attach failed', error); console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
} }
} }
if (list.length > 0 && !attached) {
toast.error(t('chat.chatInput.toast.attachFileFailed'));
}
}, [addAttachedFile, t]); }, [addAttachedFile, t]);
const handleVSCodePickFiles = React.useCallback(async () => { const handleVSCodePickFiles = React.useCallback(async () => {
try { try {
const data = (await vscodeApi?.pickFiles?.()) as { const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as {
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
skipped?: Array<{ name?: string; reason?: string }>; skipped?: Array<{ name?: string; reason?: string }>;
} | undefined; } | undefined;
@@ -5592,7 +5596,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
multiple multiple
className="hidden" className="hidden"
onChange={handleLocalFileSelect} onChange={handleLocalFileSelect}
accept="*/*" accept={ATTACHMENT_ACCEPT}
/> />
{/* Mobile attachment sheet: replaces the dropdown (which stole focus and {/* Mobile attachment sheet: replaces the dropdown (which stole focus and
+1 -1
View File
@@ -753,7 +753,7 @@ export interface VSCodeAPI {
executeCommand(command: string, ...args: unknown[]): Promise<unknown>; executeCommand(command: string, ...args: unknown[]): Promise<unknown>;
openAgentManager(): Promise<void>; openAgentManager(): Promise<void>;
openExternalUrl(url: string): Promise<void>; openExternalUrl(url: string): Promise<void>;
pickFiles?(): Promise<unknown>; pickFiles?(options?: { extensions?: string[] }): Promise<unknown>;
saveImage?(payload: unknown): Promise<unknown>; saveImage?(payload: unknown): Promise<unknown>;
saveMarkdown?(payload: unknown): Promise<unknown>; saveMarkdown?(payload: unknown): Promise<unknown>;
} }
+2
View File
@@ -51,6 +51,8 @@ So:
| `selection-store.ts` | Model/agent/variant selections | App UI state | | `selection-store.ts` | Model/agent/variant selections | App UI state |
| `voice-store.ts` | Voice state | App UI state | | `voice-store.ts` | Voice state | App UI state |
Local chat attachments are normalized before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Browser and VS Code pickers expose the same OpenCode-compatible extension allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
## Session list rules ## Session list rules
### Directory bootstrap scheduling ### Directory bootstrap scheduling
+77
View File
@@ -135,4 +135,81 @@ describe("input-store attachments", () => {
expect(useInputStore.getState().attachedFiles.map((attached) => attached.filename)).toEqual(["hello.txt"]) expect(useInputStore.getState().attachedFiles.map((attached) => attached.filename)).toEqual(["hello.txt"])
}) })
testWithMockFileReader("normalizes code files to text/plain", async () => {
const addPromise = useInputStore.getState().addAttachedFile(
new File(["const value = 1"], "example.ts", { type: "text/typescript" })
)
expect(pendingReaders).toHaveLength(1)
resolveReader(pendingReaders[0], "data:text/typescript;base64,Y29uc3QgdmFsdWUgPSAx")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles[0]?.filename).toBe("example.ts")
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe(
"data:text/plain;base64,Y29uc3QgdmFsdWUgPSAx"
)
})
testWithMockFileReader("normalizes structured text MIME types to text/plain", async () => {
const addPromise = useInputStore.getState().addAttachedFile(
new File(["{}"], "example.json", { type: "application/json" })
)
expect(pendingReaders).toHaveLength(1)
resolveReader(pendingReaders[0], "data:application/json;base64,e30=")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:text/plain;base64,e30=")
})
test("rejects an unknown binary file after inspecting its contents", async () => {
const attached = await useInputStore.getState().addAttachedFile(
new File([new Uint8Array([0, 1, 2, 3])], "archive.bin", { type: "application/octet-stream" })
)
expect(attached).toBe(false)
expect(pendingReaders).toHaveLength(0)
expect(useInputStore.getState().attachedFiles).toEqual([])
})
test("rejects unknown content with too many control bytes", async () => {
const attached = await useInputStore.getState().addAttachedFile(
new File([new Uint8Array([1, 2, 3, 65])], "encoded.custom", { type: "application/octet-stream" })
)
expect(attached).toBe(false)
expect(pendingReaders).toHaveLength(0)
})
testWithMockFileReader("accepts an unknown MIME type when its contents are text", async () => {
const addPromise = useInputStore.getState().addAttachedFile(
new File(["custom text"], "example.custom", { type: "application/octet-stream" })
)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(pendingReaders).toHaveLength(1)
resolveReader(pendingReaders[0], "data:application/octet-stream;base64,Y3VzdG9tIHRleHQ=")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("text/plain")
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe(
"data:text/plain;base64,Y3VzdG9tIHRleHQ="
)
})
testWithMockFileReader("preserves supported image MIME types", async () => {
const addPromise = useInputStore.getState().addAttachedFile(
new File([new Uint8Array([1, 2, 3])], "image.webp", { type: "image/webp" })
)
expect(pendingReaders).toHaveLength(1)
resolveReader(pendingReaders[0], "data:image/webp;base64,AQID")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp")
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID")
})
}) })
+144 -8
View File
@@ -10,6 +10,110 @@ const FILE_URI_PREFIX = "file://"
const pendingVSCodeSelectionKeys = new Set<string>() const pendingVSCodeSelectionKeys = new Set<string>()
let attachmentReadGeneration = 0 let attachmentReadGeneration = 0
const ACCEPTED_ATTACHMENT_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
] as const
export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",")
const ATTACHMENT_MIME_EXTENSIONS = new Map<string, string>([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_ATTACHMENT_EXTENSIONS = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_ATTACHMENT_EXTENSIONS = Array.from(new Set(
ACCEPTED_ATTACHMENT_TYPES.flatMap((type) => {
if (type.startsWith(".")) return [type.slice(1)]
if (type === "text/*") return TEXT_ATTACHMENT_EXTENSIONS
const extension = ATTACHMENT_MIME_EXTENSIONS.get(type)
return extension ? [extension] : []
})
)).sort()
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const IMAGE_EXTENSIONS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
const ATTACHMENT_SAMPLE_BYTES = 4096
const encodeFilePath = (filepath: string): string => { const encodeFilePath = (filepath: string): string => {
let normalized = filepath.replace(/\\/g, "/") let normalized = filepath.replace(/\\/g, "/")
if (/^[A-Za-z]:/.test(normalized)) { if (/^[A-Za-z]:/.test(normalized)) {
@@ -34,14 +138,42 @@ const toFileUrl = (filepath: string): string => {
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}` const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
const readFileAsDataUrl = (file: File): Promise<string> => new Promise((resolve, reject) => { const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => resolve(reader.result as string) reader.onload = () => {
const value = typeof reader.result === "string" ? reader.result : ""
const commaIndex = value.indexOf(",")
resolve(commaIndex === -1 ? value : `data:${mime};base64,${value.slice(commaIndex + 1)}`)
}
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")) reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"))
reader.onabort = () => reject(new Error("File read aborted")) reader.onabort = () => reject(new Error("File read aborted"))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
const bytes = new Uint8Array(await file.slice(0, ATTACHMENT_SAMPLE_BYTES).arrayBuffer())
if (bytes.some((byte) => byte === 0)) return
const controlBytes = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && controlBytes / bytes.length > 0.3) return
return "text/plain"
}
const getAttachmentMime = (file: File): string | Promise<"text/plain" | undefined> | undefined => {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (IMAGE_MIMES.has(type) || type === "application/pdf") return type
const extensionIndex = file.name.lastIndexOf(".")
const extension = extensionIndex === -1 ? "" : file.name.slice(extensionIndex + 1).toLowerCase()
const fallback = IMAGE_EXTENSIONS.get(extension) ?? (extension === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (type.startsWith("text/") || TEXT_MIMES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
return "text/plain"
}
return inspectTextContent(file)
}
const getDataUrlByteSize = (url: string): number => { const getDataUrlByteSize = (url: string): number => {
if (!url.startsWith("data:")) return 0 if (!url.startsWith("data:")) return 0
const commaIndex = url.indexOf(",") const commaIndex = url.indexOf(",")
@@ -103,7 +235,7 @@ export type InputState = {
consumePendingPresetSubmit: () => string | null consumePendingPresetSubmit: () => string | null
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
consumePendingSyntheticParts: () => SyntheticContextPart[] | null consumePendingSyntheticParts: () => SyntheticContextPart[] | null
addAttachedFile: (file: File) => Promise<void> addAttachedFile: (file: File) => Promise<boolean>
removeAttachedFile: (id: string) => void removeAttachedFile: (id: string) => void
setAttachedFiles: (files: AttachedFile[]) => void setAttachedFiles: (files: AttachedFile[]) => void
clearAttachedFiles: () => void clearAttachedFiles: () => void
@@ -154,23 +286,27 @@ export const useInputStore = create<InputState>()((set, get) => ({
addAttachedFile: async (file: File) => { addAttachedFile: async (file: File) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const generation = attachmentReadGeneration const generation = attachmentReadGeneration
const resolvedMime = getAttachmentMime(file)
const mimeType = typeof resolvedMime === "string" ? resolvedMime : await resolvedMime
if (!mimeType) return false
let dataUrl: string let dataUrl: string
try { try {
dataUrl = await readFileAsDataUrl(file) dataUrl = await readFileAsDataUrl(file, mimeType)
} catch { } catch {
return return false
} }
if (generation !== attachmentReadGeneration) return if (!dataUrl || generation !== attachmentReadGeneration) return false
const attached: AttachedFile = { const attached: AttachedFile = {
id, id,
file, file,
dataUrl, dataUrl,
mimeType: file.type, mimeType,
filename: file.name, filename: file.name,
size: file.size, size: file.size,
source: "local", source: "local",
} }
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] })) set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
return true
}, },
removeAttachedFile: (id) => removeAttachedFile: (id) =>
@@ -221,7 +357,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
pendingVSCodeSelectionKeys.add(selectionKey) pendingVSCodeSelectionKeys.add(selectionKey)
let dataUrl: string let dataUrl: string
try { try {
dataUrl = await readFileAsDataUrl(file) dataUrl = await readFileAsDataUrl(file, file.type)
} catch { } catch {
return return
} finally { } finally {
@@ -4,7 +4,7 @@ import * as path from 'path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { execGit } from './bridge-git-process-runtime'; import { execGit } from './bridge-git-process-runtime';
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; const MAX_FILE_ATTACH_SIZE_BYTES = 20 * 1024 * 1024;
const createGitCheckIgnoreTimeoutMs = () => { const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS); const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
@@ -99,7 +99,7 @@ export const readUriAsAttachment = async (
const size = stat.size ?? 0; const size = stat.size ?? 0;
if (size > MAX_FILE_ATTACH_SIZE_BYTES) { if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
return { skipped: { name, reason: 'File exceeds 10MB limit' } }; return { skipped: { name, reason: 'File exceeds 20MB limit' } };
} }
const bytes = await vscode.workspace.fs.readFile(uri); const bytes = await vscode.workspace.fs.readFile(uri);
+6 -1
View File
@@ -481,7 +481,11 @@ export async function handleFsBridgeMessage(
} }
case 'api:files/pick': { case 'api:files/pick': {
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; const options = payload as { allowMany?: boolean; extensions?: unknown };
const allowMany = options?.allowMany !== false;
const extensions = Array.isArray(options?.extensions)
? options.extensions.filter((extension): extension is string => typeof extension === 'string' && extension.length > 0)
: [];
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
const picks = await vscode.window.showOpenDialog({ const picks = await vscode.window.showOpenDialog({
@@ -490,6 +494,7 @@ export async function handleFsBridgeMessage(
canSelectMany: allowMany, canSelectMany: allowMany,
defaultUri, defaultUri,
openLabel: 'Attach', openLabel: 'Attach',
filters: extensions.length > 0 ? { Files: extensions } : undefined,
}); });
if (!picks || picks.length === 0) { if (!picks || picks.length === 0) {
+2 -2
View File
@@ -15,8 +15,8 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
await openVSCodeExternalUrl(url); await openVSCodeExternalUrl(url);
}, },
async pickFiles(): Promise<unknown> { async pickFiles(options): Promise<unknown> {
return sendBridgeMessage('api:files/pick'); return sendBridgeMessage('api:files/pick', options);
}, },
async saveImage(payload: unknown): Promise<unknown> { async saveImage(payload: unknown): Promise<unknown> {