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:
@@ -16,5 +16,8 @@ export function createInMemoryKeyValueStorage(
|
||||
async setItem(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async removeItem(key) {
|
||||
entries.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
|
||||
|
||||
export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
|
||||
export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
|
||||
export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
|
||||
|
||||
const changesPreferencesSchema = z.object({
|
||||
const changesPreferencesSchema = z.strictObject({
|
||||
layout: z.enum(["unified", "split"]).optional(),
|
||||
viewMode: z.enum(["flat", "tree"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
@@ -32,28 +33,28 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> {
|
||||
const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
return true;
|
||||
}
|
||||
if (legacyValue === "false") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
const legacyValue = await readValidatedString(
|
||||
storage,
|
||||
LEGACY_WRAP_LINES_STORAGE_KEY,
|
||||
z.enum(["true", "false"]),
|
||||
);
|
||||
return legacyValue === null ? null : legacyValue === "true";
|
||||
}
|
||||
|
||||
export async function loadChangesPreferencesFromStorage(
|
||||
storage: KeyValueStorage,
|
||||
): Promise<ChangesPreferences> {
|
||||
const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
|
||||
const stored = await readValidatedJson(
|
||||
storage,
|
||||
CHANGES_PREFERENCES_STORAGE_KEY,
|
||||
changesPreferencesSchema,
|
||||
);
|
||||
if (stored) {
|
||||
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) {
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
|
||||
}
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...stored };
|
||||
}
|
||||
|
||||
const legacyWrapLines = await loadLegacyWrapLinesPreference(storage);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
createShortcutOverrideStore,
|
||||
type ShortcutOverrideStore,
|
||||
} from "@/keyboard/shortcut-override-store";
|
||||
import { readValidatedJson } from "@/storage/validated-storage";
|
||||
|
||||
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const QUERY_KEY = ["keyboard-shortcut-overrides"];
|
||||
|
||||
const EMPTY_OVERRIDES: ShortcutOverrides = {};
|
||||
const ShortcutOverridesSchema = z.record(z.string(), z.string().nullable());
|
||||
|
||||
export interface UseKeyboardShortcutOverridesReturn {
|
||||
overrides: ShortcutOverrides;
|
||||
@@ -76,10 +79,10 @@ function getStore(queryClient: QueryClient): ShortcutOverrideStore {
|
||||
|
||||
async function loadOverridesFromStorage(): Promise<ShortcutOverrides> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as ShortcutOverrides;
|
||||
}
|
||||
return (
|
||||
(await readValidatedJson(AsyncStorage, STORAGE_KEY, ShortcutOverridesSchema)) ??
|
||||
EMPTY_OVERRIDES
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { readValidatedString } from "@/storage/validated-storage";
|
||||
|
||||
type EditorTargetId = string;
|
||||
|
||||
@@ -8,11 +10,7 @@ const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return stored.trim() || null;
|
||||
return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
|
||||
@@ -17,6 +17,9 @@ export function createInMemoryKeyValueStorage(
|
||||
async setItem(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async removeItem(key) {
|
||||
entries.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes";
|
||||
import { THEME_OPTIONS } from "@/styles/theme";
|
||||
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
|
||||
@@ -42,6 +43,18 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.theme).toBe("auto");
|
||||
});
|
||||
|
||||
it.each(THEME_OPTIONS)("loads the persisted $name theme", async ({ name }) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ theme: name }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.theme).toBe(name);
|
||||
});
|
||||
|
||||
it("seeds storage with the client defaults when nothing is persisted", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
@@ -382,14 +395,14 @@ describe("appearance settings", () => {
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
it("maps an unrecognized tool call detail level to overview", async () => {
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type SidebarRowItems,
|
||||
} from "@/components/sidebar/display-preferences/row-items";
|
||||
import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson } from "@/storage/validated-storage";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
@@ -28,6 +30,7 @@ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
|
||||
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
||||
@@ -74,14 +77,45 @@ export interface Settings extends AppSettings {
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* `sidebarRowItems` is widened back to `unknown` because it is still read for a value the
|
||||
* current shape no longer has — see `isChecksHiddenByLegacyRowItem`.
|
||||
*/
|
||||
type StoredAppSettings = Partial<Omit<AppSettings, "sidebarRowItems">> & {
|
||||
compactToolCalls?: unknown;
|
||||
sidebarRowItems?: unknown;
|
||||
};
|
||||
const SidebarRowItemsSchema = z.strictObject({
|
||||
host: z.boolean().optional(),
|
||||
changeRequest: z.boolean().optional(),
|
||||
services: z.boolean().optional(),
|
||||
checks: z.boolean().optional(),
|
||||
scripts: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const StoredAppSettingsSchema = z.strictObject({
|
||||
theme: ThemePreferenceSchema.optional(),
|
||||
language: z
|
||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||
.optional(),
|
||||
sendBehavior: z.enum(["interrupt", "queue"]).optional(),
|
||||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
|
||||
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
|
||||
useLegacyTerminalRenderer: z.boolean().optional(),
|
||||
uiFontFamily: z.string().optional(),
|
||||
monoFontFamily: z.string().optional(),
|
||||
uiFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
codeFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
|
||||
workspaceTitleSource: z.enum(["title", "branch"]).optional(),
|
||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(),
|
||||
sidebarRowItems: SidebarRowItemsSchema.optional(),
|
||||
sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(),
|
||||
autoExpandReasoning: z.boolean().optional(),
|
||||
toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(),
|
||||
compactToolCalls: z.boolean().optional(),
|
||||
chatOutlineEnabled: z.boolean().optional(),
|
||||
vimKeybindings: z.boolean().optional(),
|
||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||
manageBuiltInDaemon: z.boolean().optional(),
|
||||
releaseChannel: z.enum(["stable", "beta"]).optional(),
|
||||
});
|
||||
|
||||
const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
|
||||
|
||||
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
|
||||
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
@@ -114,6 +148,7 @@ export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsBridge {
|
||||
@@ -146,17 +181,20 @@ export async function saveAppSettings(input: {
|
||||
|
||||
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
|
||||
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
|
||||
if (stored) {
|
||||
return normalizeAppSettings(JSON.parse(stored));
|
||||
return normalizeAppSettings(stored);
|
||||
}
|
||||
|
||||
const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY);
|
||||
const legacyStored = await readValidatedJson(
|
||||
deps.storage,
|
||||
LEGACY_SETTINGS_KEY,
|
||||
LegacyRendererSettingsSchema,
|
||||
);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
...pickAppSettingsFromLegacy(legacyStored),
|
||||
} satisfies AppSettings;
|
||||
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
@@ -197,11 +235,11 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
|
||||
}
|
||||
|
||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
const stored =
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as StoredAppSettings)
|
||||
: {};
|
||||
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) };
|
||||
const result = StoredAppSettingsSchema.safeParse(value);
|
||||
return {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettings(result.success ? result.data : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
|
||||
@@ -334,7 +372,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
function pickAppSettingsFromLegacy(
|
||||
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
|
||||
): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
@@ -420,15 +460,11 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
|
||||
|
||||
async function loadRendererSettingsPayload(
|
||||
storage: KeyValueStorage,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const current = await storage.getItem(APP_SETTINGS_KEY);
|
||||
): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
|
||||
const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
||||
if (current) {
|
||||
return JSON.parse(current) as Record<string, unknown>;
|
||||
return current;
|
||||
}
|
||||
|
||||
const legacy = await storage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!legacy) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(legacy) as Record<string, unknown>;
|
||||
return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user