diff --git a/agent-chat/src/browser-storage.ts b/agent-chat/src/browser-storage.ts new file mode 100644 index 000000000000..f49122a773e0 --- /dev/null +++ b/agent-chat/src/browser-storage.ts @@ -0,0 +1,56 @@ +type BrowserStorage = Pick; + +/** Storage failures must not interrupt a live session or discard its draft. */ +export function createRecoverableStorage(resolve: () => BrowserStorage | undefined): BrowserStorage { + const cached = new Map(); + const pending = new Set(); + return { + getItem(key) { + // A rejected write/remove is newer than the value still on disk. A null + // tombstone also prevents a consumed draft from reappearing on remount. + if (pending.has(key)) return cached.get(key) ?? null; + try { + const storage = resolve(); + if (storage) { + const value = storage.getItem(key); + cached.set(key, value); + return value; + } + } catch { + // Accessing the storage property itself can throw SecurityError. + } + return cached.get(key) ?? null; + }, + setItem(key, value) { + cached.set(key, value); + pending.add(key); + try { + const storage = resolve(); + if (storage) { + storage.setItem(key, value); + pending.delete(key); + } + } catch { + // Keep the selected value available to other views in this page. + } + }, + removeItem(key) { + cached.set(key, null); + pending.add(key); + try { + const storage = resolve(); + if (storage) { + storage.removeItem(key); + pending.delete(key); + } + } catch { + // Keep the tombstone until a later successful mutation of this key. + } + }, + }; +} + +// Resolve lazily inside the guarded operations; evaluating a default argument +// such as `storage = localStorage` can throw before a function's try/catch. +export const preferenceStorage = createRecoverableStorage(() => globalThis.localStorage); +export const draftStorage = createRecoverableStorage(() => globalThis.sessionStorage); diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index bd609edba2a9..a2f4fa4dede6 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -1,3 +1,4 @@ +import { preferenceStorage, draftStorage } from "../browser-storage"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useCtx } from "../context"; import { agentChatText } from "../i18n"; @@ -122,9 +123,9 @@ export function Chat() { return; } updateStoredProviderOption(provider, "model", model, allProviderOptions[provider] ?? []); - localStorage.setItem("agentui.provider", provider); - localStorage.setItem("agentui.cwd", session.cwd); - sessionStorage.setItem("agentui.draft", text); + preferenceStorage.setItem("agentui.provider", provider); + preferenceStorage.setItem("agentui.cwd", session.cwd); + draftStorage.setItem("agentui.draft", text); compose(); }; diff --git a/agent-chat/src/components/Composer.tsx b/agent-chat/src/components/Composer.tsx index ae4804d7a4a3..9c6dee71ebcd 100644 --- a/agent-chat/src/components/Composer.tsx +++ b/agent-chat/src/components/Composer.tsx @@ -1,3 +1,4 @@ +import { preferenceStorage, draftStorage } from "../browser-storage"; import { useCallback, useMemo, useState } from "react"; import { composerDraftKey, visibleWorkflowHarnesses, type OptionValue } from "../session"; import { useCtx } from "../context"; @@ -46,12 +47,12 @@ export function Composer() { clearError, start, } = useCtx(); - const [provider, setProvider] = useState(() => localStorage.getItem("agentui.provider") || "claude"); - const [cwd, setCwd] = useState(() => localStorage.getItem("agentui.cwd") || ""); - const [committedCwd, setCommittedCwd] = useState(() => localStorage.getItem("agentui.cwd") || ""); + const [provider, setProvider] = useState(() => preferenceStorage.getItem("agentui.provider") || "claude"); + const [cwd, setCwd] = useState(() => preferenceStorage.getItem("agentui.cwd") || ""); + const [committedCwd, setCommittedCwd] = useState(() => preferenceStorage.getItem("agentui.cwd") || ""); const [prompt, setPrompt] = useState(() => { - const draft = sessionStorage.getItem(composerDraftKey) || ""; - sessionStorage.removeItem(composerDraftKey); + const draft = draftStorage.getItem(composerDraftKey) || ""; + draftStorage.removeItem(composerDraftKey); return draft; }); const [startOptionsByProvider, setStartOptionsByProvider] = useState>>(() => ({ @@ -110,20 +111,20 @@ export function Composer() { const runCwd = cwd.trim(); const sent = start({ provider, cwd: runCwd, prompt: text, options: sanitizeStartOptions(startOptions, options) }); if (!sent) return; - localStorage.setItem("agentui.provider", provider); - localStorage.setItem("agentui.cwd", runCwd); + preferenceStorage.setItem("agentui.provider", provider); + preferenceStorage.setItem("agentui.cwd", runCwd); }; const changeCwd = (v: string) => { setCwd(v); }; const commitCwd = (v: string) => { const next = v.trim(); if (!next) return; setCommittedCwd(next); - localStorage.setItem("agentui.cwd", next); + preferenceStorage.setItem("agentui.cwd", next); }; const changeProvider = (v: string) => { setProvider(v); setStartOptionsByProvider((all) => all[v] ? all : { ...all, [v]: readProviderOptions(v) }); - localStorage.setItem("agentui.provider", v); + preferenceStorage.setItem("agentui.provider", v); }; const changeProviderModel = (nextProvider: string, model: string) => { changeProvider(nextProvider); diff --git a/agent-chat/src/hooks/useCatalogs.ts b/agent-chat/src/hooks/useCatalogs.ts index cb95107d1dce..213363678857 100644 --- a/agent-chat/src/hooks/useCatalogs.ts +++ b/agent-chat/src/hooks/useCatalogs.ts @@ -1,3 +1,4 @@ +import { preferenceStorage } from "../browser-storage"; import { useEffect } from "react"; import type { CommandGroup, Provider, SessionOption } from "../session"; @@ -98,7 +99,7 @@ export function useCwdValidation( if (!checked || checked.ok || !defaultCwd) return; setCwd(defaultCwd); setCommittedCwd(defaultCwd); - localStorage.setItem("agentui.cwd", defaultCwd); + preferenceStorage.setItem("agentui.cwd", defaultCwd); }, [cwd, cwdChecks, defaultCwd, setCommittedCwd, setCwd]); } @@ -112,6 +113,6 @@ export function useCwdErrorFallback( if (!message.includes("working directory does not exist") || !defaultCwd) return; setCwd(defaultCwd); setCommittedCwd(defaultCwd); - localStorage.setItem("agentui.cwd", defaultCwd); + preferenceStorage.setItem("agentui.cwd", defaultCwd); }, [defaultCwd, message, setCommittedCwd, setCwd]); } diff --git a/agent-chat/src/options-store.ts b/agent-chat/src/options-store.ts index 5c9b06d38595..b79962f907f4 100644 --- a/agent-chat/src/options-store.ts +++ b/agent-chat/src/options-store.ts @@ -1,3 +1,4 @@ +import { preferenceStorage } from "./browser-storage"; import type { OptionValue, SessionOption } from "./session"; export interface ProviderOptionStore { @@ -74,7 +75,7 @@ export function flatOptionsFromStore(store: ProviderOptionStore): Record { +export function readStoredProviderOptions(provider: string, storage: StorageLike = preferenceStorage): Record { try { return flatOptionsFromStore(normalizeProviderOptionStore(JSON.parse(storage.getItem(key(provider)) || "null"))); } catch { @@ -82,7 +83,7 @@ export function readStoredProviderOptions(provider: string, storage: StorageLike } } -export function readProviderOptionStore(provider: string, storage: StorageLike = localStorage): ProviderOptionStore { +export function readProviderOptionStore(provider: string, storage: StorageLike = preferenceStorage): ProviderOptionStore { try { return normalizeProviderOptionStore(JSON.parse(storage.getItem(key(provider)) || "null")); } catch { @@ -90,7 +91,7 @@ export function readProviderOptionStore(provider: string, storage: StorageLike = } } -export function writeProviderOptionStore(provider: string, store: ProviderOptionStore, storage: StorageLike = localStorage) { +export function writeProviderOptionStore(provider: string, store: ProviderOptionStore, storage: StorageLike = preferenceStorage) { storage.setItem(key(provider), JSON.stringify(store)); } @@ -104,7 +105,7 @@ export function updateStoredProviderOption( id: string, value: OptionValue, options: SessionOption[], - storage: StorageLike = localStorage, + storage: StorageLike = preferenceStorage, ): Record { const store = readProviderOptionStore(provider, storage); const activeModel = id === "model" && typeof value === "string" ? value : optionModel(options, store.model); @@ -124,7 +125,7 @@ export function writeStoredProviderOptions( provider: string, values: Record, options: SessionOption[] = [], - storage: StorageLike = localStorage, + storage: StorageLike = preferenceStorage, ): Record { const store = readProviderOptionStore(provider, storage); const model = typeof values.model === "string" && values.model ? values.model : optionModel(options, store.model); @@ -141,7 +142,7 @@ export function writeStoredProviderOptions( return flatOptionsFromStore(store); } -export function persistOptionsSnapshot(provider: string, options: SessionOption[], storage: StorageLike = localStorage) { +export function persistOptionsSnapshot(provider: string, options: SessionOption[], storage: StorageLike = preferenceStorage) { const values: Record = {}; for (const option of options) values[option.id] = option.value; writeStoredProviderOptions(provider, values, options, storage); diff --git a/agent-chat/src/session.ts b/agent-chat/src/session.ts index e9972679d13d..77f36353538d 100644 --- a/agent-chat/src/session.ts +++ b/agent-chat/src/session.ts @@ -1,3 +1,4 @@ +import { draftStorage } from "./browser-storage"; // Client-side session state: one WebSocket, one session per page. import { useCallback, useEffect, useRef, useState } from "react"; import { applyThemeVars } from "./theme"; @@ -403,7 +404,7 @@ export function useSession(): SessionState { if (!pending) return; clearPendingStartTimeout(); pendingStartRef.current = null; - restoreComposerDraft(sessionStorage, pending.prompt); + restoreComposerDraft(draftStorage, pending.prompt); history.replaceState(null, "", appPath("/")); document.title = "cmux agent"; sessionIdRef.current = null; diff --git a/agent-chat/test/browser-storage.test.ts b/agent-chat/test/browser-storage.test.ts new file mode 100644 index 000000000000..6d3c0f153bc7 --- /dev/null +++ b/agent-chat/test/browser-storage.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import { createRecoverableStorage, draftStorage, preferenceStorage } from "../src/browser-storage"; + +class MemoryStorage { + values = new Map(); + getItem(key: string) { return this.values.get(key) ?? null; } + setItem(key: string, value: string) { this.values.set(key, value); } + removeItem(key: string) { this.values.delete(key); } +} + +test("unavailable storage preserves a draft and consumes it once", () => { + const storage = createRecoverableStorage(() => { throw new DOMException("Denied", "SecurityError"); }); + expect(storage.getItem("draft")).toBeNull(); + storage.setItem("draft", "retry this exact prompt"); + expect(storage.getItem("draft")).toBe("retry this exact prompt"); + storage.removeItem("draft"); + expect(storage.getItem("draft")).toBeNull(); +}); + +test("healthy storage observes updates from another view", () => { + const backend = new MemoryStorage(); + const storage = createRecoverableStorage(() => backend); + storage.setItem("provider", "claude"); + expect(backend.getItem("provider")).toBe("claude"); + backend.setItem("provider", "codex"); + expect(storage.getItem("provider")).toBe("codex"); + storage.removeItem("provider"); + expect(backend.getItem("provider")).toBeNull(); +}); + +test("a later denied read retains the last known preference", () => { + const backend = new MemoryStorage(); + backend.setItem("cwd", "/work/project"); + let available = true; + const storage = createRecoverableStorage(() => available ? backend : undefined); + expect(storage.getItem("cwd")).toBe("/work/project"); + available = false; + expect(storage.getItem("cwd")).toBe("/work/project"); +}); + +test("failed writes win over stale disk data until a successful mutation", () => { + const backend = new MemoryStorage(); + backend.setItem("draft", "old prompt"); + let writable = false; + const storage = createRecoverableStorage(() => ({ + getItem: (key) => backend.getItem(key), + setItem: (key, value) => { + if (!writable) throw new DOMException("Full", "QuotaExceededError"); + backend.setItem(key, value); + }, + removeItem: (key) => backend.removeItem(key), + })); + storage.setItem("draft", "recovered prompt"); + expect(storage.getItem("draft")).toBe("recovered prompt"); + expect(backend.getItem("draft")).toBe("old prompt"); + writable = true; + storage.setItem("draft", "next prompt"); + expect(backend.getItem("draft")).toBe("next prompt"); + backend.setItem("draft", "another view's prompt"); + expect(storage.getItem("draft")).toBe("another view's prompt"); +}); + +test("failed removal does not resurrect a consumed draft", () => { + const backend = new MemoryStorage(); + backend.setItem("draft", "already consumed"); + const storage = createRecoverableStorage(() => ({ + getItem: (key) => backend.getItem(key), + setItem: (key, value) => backend.setItem(key, value), + removeItem() { throw new DOMException("Denied", "SecurityError"); }, + })); + expect(storage.getItem("draft")).toBe("already consumed"); + storage.removeItem("draft"); + expect(storage.getItem("draft")).toBeNull(); + expect(storage.getItem("draft")).toBeNull(); + expect(backend.getItem("draft")).toBe("already consumed"); +}); + +test("local preferences and session drafts keep separate fallbacks", () => { + const previous = Object.getOwnPropertyDescriptors(globalThis); + try { + for (const name of ["localStorage", "sessionStorage"]) { + Object.defineProperty(globalThis, name, { + configurable: true, + get() { throw new DOMException("Denied", "SecurityError"); }, + }); + } + preferenceStorage.setItem("storage-test", "preference"); + draftStorage.setItem("storage-test", "draft"); + expect(preferenceStorage.getItem("storage-test")).toBe("preference"); + expect(draftStorage.getItem("storage-test")).toBe("draft"); + } finally { + preferenceStorage.removeItem("storage-test"); + draftStorage.removeItem("storage-test"); + for (const name of ["localStorage", "sessionStorage"]) { + if (previous[name]) Object.defineProperty(globalThis, name, previous[name]); + else Reflect.deleteProperty(globalThis, name); + } + } +}); diff --git a/agent-chat/test/options-store.test.ts b/agent-chat/test/options-store.test.ts index 101696fb2b02..97b1e9ec2e0b 100644 --- a/agent-chat/test/options-store.test.ts +++ b/agent-chat/test/options-store.test.ts @@ -54,3 +54,40 @@ if (read.model !== "m1" || read.effort !== "high" || read.permissionMode !== "pl } console.log("options store assertions passed"); + +// Embedded browser storage can reject either property access or a write. +// These are defaults used by the live composer, not injected healthy storage. +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +try { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { throw new DOMException("Storage access denied", "SecurityError"); }, + }); + if (Object.keys(readStoredProviderOptions("denied-storage")).length) { + throw new Error("denied storage should start with empty options"); + } + updateStoredProviderOption("denied-storage", "permissionMode", "plan", options("m1")); + if (readStoredProviderOptions("denied-storage").permissionMode !== "plan") { + throw new Error("permission selection should survive denied storage within the page"); + } + + const fullStorage = { + getItem() { return JSON.stringify({ version: 2, model: "m1", harness: {}, models: { m1: { effort: "high" } } }); }, + setItem() { throw new DOMException("Storage is full", "QuotaExceededError"); }, + removeItem() { throw new DOMException("Storage is full", "QuotaExceededError"); }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: fullStorage }); + const selected = updateStoredProviderOption("full-storage", "permissionMode", "plan", options("m1")); + if (selected.effort !== "high" || selected.permissionMode !== "plan") { + throw new Error("a rejected preference write should preserve both existing and selected options"); + } + const restored = readStoredProviderOptions("full-storage"); + if (restored.effort !== "high" || restored.permissionMode !== "plan") { + throw new Error("a remounted composer should read the selection from memory when disk writes fail"); + } +} finally { + if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); +} + +console.log("options store storage-failure assertions passed"); diff --git a/agent-chat/test/session.test.ts b/agent-chat/test/session.test.ts index b9e60641c3a4..5ac92099dafa 100644 --- a/agent-chat/test/session.test.ts +++ b/agent-chat/test/session.test.ts @@ -5,12 +5,34 @@ Object.defineProperty(globalThis, "location", { const { composerDraftKey, consumeOptimisticUserEcho, foldEvent, latestRouting, restoreComposerDraft, shouldAcceptHandoffResponse } = await import("../src/session"); const { latestRouteStatus, normalizeRouteStatus, routeHealthForPhase } = await import("../route-status"); +const { draftStorage } = await import("../src/browser-storage"); const writes: Record = {}; restoreComposerDraft({ setItem: (key: string, value: string) => { writes[key] = value; } }, "retry this exact prompt"); if (writes[composerDraftKey] !== "retry this exact prompt") { - throw new Error(`pre-session start failure did not preserve composer draft: ${JSON.stringify(writes)}`); + throw new Error(`pre-session start failure did not preserve composer draft: ${JSON.stringify(writes)}`); +} + +const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +try { + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + get() { throw new DOMException("Storage access denied", "SecurityError"); }, + }); + // The failed-start path writes here before resetting the view, then the + // remounted composer reads and consumes the draft through the same store. + restoreComposerDraft(draftStorage, "recover this exact prompt after a failed start"); + if (draftStorage.getItem(composerDraftKey) !== "recover this exact prompt after a failed start") { + throw new Error("a rejected session storage write lost the failed-start prompt"); + } + draftStorage.removeItem(composerDraftKey); + if (draftStorage.getItem(composerDraftKey) !== null) { + throw new Error("a consumed failed-start prompt should not reappear"); + } +} finally { + if (originalSessionStorage) Object.defineProperty(globalThis, "sessionStorage", originalSessionStorage); + else Reflect.deleteProperty(globalThis, "sessionStorage"); } const repeated = [