Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const EXAMPLES = [
] as const

const NON_EMPTY_TEXT = /[^\s\u200B]/
const PROMPT_HISTORY_PERSIST_MAX_BYTES = 8 * 1024 * 1024

export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
Expand Down Expand Up @@ -273,15 +274,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})

const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
{
...Persist.global("prompt-history", ["prompt-history.v1"]),
maxBytes: PROMPT_HISTORY_PERSIST_MAX_BYTES,
},
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
{
...Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
maxBytes: PROMPT_HISTORY_PERSIST_MAX_BYTES,
},
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
Expand Down
77 changes: 77 additions & 0 deletions packages/app/src/components/prompt-input/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"

type PromptStore = {
current: () => []
cursor: () => number
set: (...args: unknown[]) => void
}

let prompt = {} as PromptStore
const toasts: Array<{ title?: string; description?: string }> = []

let createPromptAttachments: typeof import("./attachments").createPromptAttachments
let MAX_ATTACHMENT_BYTES = 0

beforeAll(async () => {
mock.module("@/context/prompt", () => ({
usePrompt: () => prompt,
}))
mock.module("@/context/language", () => ({
useLanguage: () => ({
t: (key: string, params?: { limit?: string }) => {
if (key === "prompt.toast.attachmentTooLarge.title") return "Attachment too large"
if (key === "prompt.toast.attachmentTooLarge.description") return `limit:${params?.limit ?? ""}`
return key
},
}),
}))
mock.module("@opencode-ai/ui/toast", () => ({
showToast: (value: { title?: string; description?: string }) => {
toasts.push(value)
},
}))
const mod = await import("./attachments")
createPromptAttachments = mod.createPromptAttachments
MAX_ATTACHMENT_BYTES = mod.MAX_ATTACHMENT_BYTES
})

beforeEach(() => {
toasts.length = 0
prompt = {
current: () => [],
cursor: () => 0,
set: () => undefined,
}
})

describe("prompt attachments", () => {
test("rejects oversized attachments before FileReader", async () => {
const setCalls: unknown[][] = []
prompt.set = (...args) => setCalls.push(args)

const api = createRoot((dispose) => {
const value = createPromptAttachments({
editor: () => undefined,
isFocused: () => true,
isDialogActive: () => false,
setDraggingType: () => undefined,
focusEditor: () => undefined,
addPart: () => false,
})
dispose()
return value
})

await api.addImageAttachment({
type: "application/pdf",
size: MAX_ATTACHMENT_BYTES + 1,
name: "huge.pdf",
} as File)

expect(setCalls).toHaveLength(0)
expect(toasts).toHaveLength(1)
expect(toasts[0]?.title).toBe("Attachment too large")
expect(toasts[0]?.description).toBe("limit:32 MiB")
})
})
14 changes: 14 additions & 0 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import { getCursorPosition } from "./editor-dom"

export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
export const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120

function formatMiB(bytes: number) {
return `${Math.max(1, Math.round(bytes / (1024 * 1024)))} MiB`
}

function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
Expand Down Expand Up @@ -37,6 +42,15 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {

const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
if (file.size > MAX_ATTACHMENT_BYTES) {
showToast({
title: language.t("prompt.toast.attachmentTooLarge.title"),
description: language.t("prompt.toast.attachmentTooLarge.description", {
limit: formatMiB(MAX_ATTACHMENT_BYTES),
}),
})
return
}

const reader = new FileReader()
reader.onload = () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function createPromptActions(

const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
const PROMPT_PERSIST_MAX_BYTES = 8 * 1024 * 1024

type PromptSession = ReturnType<typeof createPromptSession>

Expand All @@ -160,7 +161,10 @@ function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`

const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
{
...Persist.scoped(dir, id, "prompt", [legacy]),
maxBytes: PROMPT_PERSIST_MAX_BYTES,
},
createStore<{
prompt: Prompt
cursor?: number
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ export const dict = {

"prompt.toast.pasteUnsupported.title": "Unsupported paste",
"prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.",
"prompt.toast.attachmentTooLarge.title": "Attachment too large",
"prompt.toast.attachmentTooLarge.description": "Attachments must be {{limit}} or smaller.",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/utils/persist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,41 @@ describe("persist localStorage resilience", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})

test("normalizer rejects payloads above byte limit", () => {
const raw = JSON.stringify({ value: "x".repeat(2048) })
const result = persistTesting.normalize({ value: "ok" }, raw, undefined, 1024)
expect(result).toBeUndefined()
})

test("bounded sync writes keep last persisted value when payload is too large", () => {
const api = persistTesting.localStorageDirect()
api.setItem("oversized", '{"value":"small"}')
const before = storage.calls.remove

persistTesting.writeBoundedSync(api, "oversized", JSON.stringify({ value: "x".repeat(4096) }), 1024)

expect(storage.getItem("oversized")).toBe('{"value":"small"}')
expect(storage.calls.remove).toBe(before)
})

test("bounded async writes skip storage calls when payload is too large", async () => {
const calls: string[] = []
await persistTesting.writeBoundedAsync(
{
getItem: async () => null,
setItem: async () => {
calls.push("set")
},
removeItem: async () => {
calls.push("remove")
},
},
"oversized",
JSON.stringify({ value: "x".repeat(4096) }),
1024,
)

expect(calls).toEqual([])
})
})
37 changes: 29 additions & 8 deletions packages/app/src/utils/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type PersistTarget = {
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
maxBytes?: number
}

const LEGACY_STORAGE = "default.dat"
Expand Down Expand Up @@ -195,12 +196,30 @@ function parse(value: string) {
}
}

function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
function tooLarge(value: string, maxBytes?: number) {
if (!maxBytes) return false
return value.length * 2 > maxBytes
Comment thread
neriousy marked this conversation as resolved.
Outdated
}

function writeBoundedSync(storage: SyncStorage, key: string, value: string, maxBytes?: number) {
if (tooLarge(value, maxBytes)) return
storage.setItem(key, value)
}

async function writeBoundedAsync(storage: AsyncStorage, key: string, value: string, maxBytes?: number) {
if (tooLarge(value, maxBytes)) return
await storage.setItem(key, value)
}

function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown, maxBytes?: number) {
if (tooLarge(raw, maxBytes)) return
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
return JSON.stringify(merged)
const next = JSON.stringify(merged)
if (tooLarge(next, maxBytes)) return
return next
}

function workspaceStorage(dir: string) {
Expand Down Expand Up @@ -300,6 +319,8 @@ export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
writeBoundedSync,
writeBoundedAsync,
}

export const Persist = {
Expand Down Expand Up @@ -366,7 +387,7 @@ export function persisted<T>(
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
const next = normalize(defaults, raw, config.migrate, config.maxBytes)
if (next === undefined) {
current.removeItem(key)
return null
Expand All @@ -379,7 +400,7 @@ export function persisted<T>(
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue

const next = normalize(defaults, legacyRaw, config.migrate)
const next = normalize(defaults, legacyRaw, config.migrate, config.maxBytes)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
Expand All @@ -392,7 +413,7 @@ export function persisted<T>(
return null
},
setItem: (key, value) => {
current.setItem(key, value)
writeBoundedSync(current, key, value, config.maxBytes)
},
removeItem: (key) => {
current.removeItem(key)
Expand All @@ -409,7 +430,7 @@ export function persisted<T>(
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
const next = normalize(defaults, raw, config.migrate, config.maxBytes)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
Expand All @@ -424,7 +445,7 @@ export function persisted<T>(
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue

const next = normalize(defaults, legacyRaw, config.migrate)
const next = normalize(defaults, legacyRaw, config.migrate, config.maxBytes)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
Expand All @@ -437,7 +458,7 @@ export function persisted<T>(
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
await writeBoundedAsync(current, key, value, config.maxBytes)
},
removeItem: async (key) => {
await current.removeItem(key)
Expand Down
Loading