fix(app): validate persisted client state (#3289)

* fix(app): validate persisted client state

Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.

* fix(app): migrate legacy agent preferences

Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
Mohamed Boudra
2026-08-13 12:02:39 +02:00
committed by GitHub
parent c190fed27c
commit 36312352db
69 changed files with 1801 additions and 800 deletions
+5 -4
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
buildExplorerCheckoutKey,
@@ -24,6 +24,7 @@ import {
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
migratePanelState,
PanelPersistedStateSchema,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
@@ -37,6 +38,7 @@ import {
type SortOption,
} from "./state";
import { isWeb } from "@/constants/platform";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export type { ExplorerTab } from "../explorer-tab-memory";
export type { ExplorerCheckoutContext } from "../explorer-checkout-context";
export type {
@@ -311,9 +313,8 @@ export const usePanelStore = create<PanelState>()(
{
name: "panel-state",
version: 12,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
partialize: (state) => ({
desktop: state.desktop,
explorerTab: state.explorerTab,
+36 -4
View File
@@ -5,6 +5,7 @@ import {
type ExplorerTab,
} from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import { z } from "zod";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
@@ -161,7 +162,37 @@ export function buildToggleFileExplorerPatch(
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
type MigratablePanelState = Record<string, unknown>;
const ExplorerTabSchema = z.enum(["changes", "files", "pr"]);
const DesktopSidebarStorageSchema = z.strictObject({
agentListOpen: z.boolean().optional(),
fileExplorerOpen: z.boolean().optional(),
focusModeEnabled: z.boolean().optional(),
zoomed: z.boolean().optional(),
focused: z.boolean().optional(),
});
export const PanelPersistedStateSchema = z.strictObject({
mobileView: z.enum(["agent", "agent-list", "file-explorer"]).optional(),
mobilePanel: z
.strictObject({
target: z.enum(["agent", "agent-list", "file-explorer"]),
revision: z.number().int().nonnegative(),
})
.optional(),
desktop: DesktopSidebarStorageSchema.optional(),
explorerTab: ExplorerTabSchema.optional(),
explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(),
expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
sidebarWidth: z.number().optional(),
explorerWidth: z.number().optional(),
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
explorerShowHiddenFiles: z.boolean().optional(),
explorerFilesSplitRatio: z.number().optional(),
});
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
@@ -193,7 +224,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
state.explorerTabByCheckout = {};
return;
}
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const entries = Object.entries(state.explorerTabByCheckout);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
@@ -205,7 +236,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
}
function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
const desktop = state.desktop as Record<string, unknown> | undefined;
const desktop = state.desktop;
if (!desktop) {
return;
}
@@ -227,7 +258,8 @@ export function migratePanelState(
version: number,
options: { isWeb: boolean },
): MigratablePanelState {
const state = (persistedState ?? {}) as MigratablePanelState;
const result = PanelPersistedStateSchema.safeParse(persistedState);
const state: MigratablePanelState = result.success ? result.data : {};
const { isWeb } = options;
if (version < 2) {