From 36312352db88b2b5c4024465c40ff97fe48f67f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 13 Aug 2026 12:02:39 +0200 Subject: [PATCH] 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. --- ...ent-timeline-pagination-old-daemon.spec.ts | 2 +- .../command-center-agent-controls.spec.ts | 9 +- ...w-workspace-codex-mode-preferences.spec.ts | 17 +- .../new-workspace-mode-cycle-safety.spec.ts | 14 +- .../app/e2e/browser/sessions-empty.spec.ts | 2 +- .../browser/workspace-model-restart.spec.ts | 9 +- .../worktree-restore-after-restart.spec.ts | 2 +- packages/app/e2e/support/fixtures.ts | 2 +- .../app/e2e/support/helpers/archive-tab.ts | 2 +- .../e2e/support/helpers/daemon-registry.ts | 26 +- .../e2e/support/helpers/relay-deployment.ts | 2 +- packages/app/e2e/support/helpers/settings.ts | 2 +- .../app/e2e/support/helpers/startup-dsl.ts | 6 +- .../agent-profiles/migration/index.test.ts | 4 + .../app/src/agent-profiles/migration/index.ts | 39 +- .../src/contexts/sidebar-callout-context.tsx | 11 +- .../contexts/sidebar-callout-state.test.ts | 7 - .../app/src/contexts/sidebar-callout-state.ts | 17 +- .../preferences.test.ts | 30 ++ .../create-agent-preferences/preferences.ts | 78 ++- .../src/create-agent-preferences/storage.ts | 29 +- .../test-utils/fake-preference-storage.ts | 2 +- .../app/src/data/provider-snapshot-cache.ts | 36 +- .../app/src/desktop/browser/store/index.ts | 6 +- .../app/src/desktop/browser/store/state.ts | 68 +-- packages/app/src/git/use-actions.tsx | 6 +- .../hooks/use-changes-preferences/fakes.ts | 3 + .../hooks/use-changes-preferences/storage.ts | 29 +- .../hooks/use-keyboard-shortcut-overrides.ts | 11 +- .../app/src/hooks/use-preferred-editor.ts | 8 +- packages/app/src/hooks/use-settings/fakes.ts | 3 + .../src/hooks/use-settings/storage.test.ts | 17 +- .../app/src/hooks/use-settings/storage.ts | 90 +++- packages/app/src/hosts/appearance.ts | 24 +- .../internal/subscriptions.ts | 19 +- packages/app/src/review/state.ts | 65 +-- packages/app/src/review/store.test.ts | 51 +- packages/app/src/review/store.ts | 6 +- packages/app/src/runtime/host-runtime.test.ts | 3 + packages/app/src/runtime/host-runtime.ts | 93 ++-- .../src/runtime/replica-cache/index.test.ts | 13 +- .../app/src/runtime/replica-cache/index.ts | 504 ++++++++++++++---- .../storage/validated-persist-storage.test.ts | 65 +++ .../src/storage/validated-persist-storage.ts | 43 ++ .../app/src/storage/validated-storage.test.ts | 51 ++ packages/app/src/storage/validated-storage.ts | 47 ++ packages/app/src/stores/draft-store/index.ts | 19 +- .../src/stores/draft-store/migration.test.ts | 88 ++- .../app/src/stores/draft-store/migration.ts | 201 ++++--- .../app/src/stores/draft-store/persistence.ts | 23 +- packages/app/src/stores/draft-store/state.ts | 166 +++--- .../stores/last-workspace-selection.test.ts | 9 +- .../src/stores/last-workspace-selection.ts | 23 +- .../index.ts | 1 + packages/app/src/stores/panel-store/index.ts | 9 +- packages/app/src/stores/panel-store/state.ts | 40 +- .../sidebar-collapsed-sections-store/index.ts | 14 +- .../state.test.ts | 4 +- .../sidebar-collapsed-sections-store/state.ts | 45 +- .../app/src/stores/sidebar-order-store.ts | 20 +- packages/app/src/stores/sidebar-view-store.ts | 47 +- .../src/stores/workspace-layout-store.test.ts | 19 + .../app/src/stores/workspace-layout-store.ts | 94 +++- packages/app/src/types/host-connection.ts | 112 ++-- packages/app/src/utils/client-id.test.ts | 3 + packages/app/src/utils/client-id.ts | 16 +- packages/app/src/workspace-pins/store.ts | 27 +- .../workspace-service-routes/store.test.ts | 5 +- .../app/src/workspace-service-routes/store.ts | 43 +- 69 files changed, 1801 insertions(+), 800 deletions(-) create mode 100644 packages/app/src/storage/validated-persist-storage.test.ts create mode 100644 packages/app/src/storage/validated-persist-storage.ts create mode 100644 packages/app/src/storage/validated-storage.test.ts create mode 100644 packages/app/src/storage/validated-storage.ts diff --git a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts index 16e2c7eab..6ec68a2b1 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts @@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); const history = await holdOlderHistoryPages(page, agent, daemon.port); diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 3021a242e..08597285f 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -1,4 +1,5 @@ import { test } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center"; import { applyCommandCenterAgentControls, @@ -12,17 +13,15 @@ import { import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId, provider: "mock", providerPreferences: { mock: { @@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page): mode: "load-test", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 05f636007..ddd415ed9 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; @@ -10,7 +11,6 @@ import { import { expectNoTruncation } from "../support/helpers/no-truncation"; import { escapeRegex } from "../support/helpers/regex"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record | return maybeEnvelope.message as Record; } -async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise { +async function seedCodexDefaultPermissionPreferences(page: Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin model: "ten-second-stream", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => { test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { await gotoAppShell(page); @@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => { }); test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { const agent = await seeded.client.createAgent({ diff --git a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts index 61a58fee8..2b6ab5d53 100644 --- a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { @@ -6,7 +7,6 @@ import { selectNewWorkspaceProject, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{ }; } -async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise { +async function seedCodexDefaultPreferences(page: Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis }, mock: { model: "ten-second-stream" }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => { // control and silently change that (possibly running) agent's mode — e.g. into a // permissive/bypass mode. See use-keyboard-action-handler.ts. test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" }); - await seedCodexDefaultPreferences(page, serverId); + await seedCodexDefaultPreferences(page); const modeRequests = await recordSetAgentModeRequests(page); try { diff --git a/packages/app/e2e/browser/sessions-empty.spec.ts b/packages/app/e2e/browser/sessions-empty.spec.ts index 05884b45a..32769996e 100644 --- a/packages/app/e2e/browser/sessions-empty.spec.ts +++ b/packages/app/e2e/browser/sessions-empty.spec.ts @@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async ( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); await gotoAppShell(page); diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index c77b0bb1e..54cbb0c0e 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -7,6 +7,7 @@ import path from "node:path"; import net from "node:net"; import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { metroTest as test } from "../support/fixtures"; import { buildSeededHost } from "../support/helpers/daemon-registry"; import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader"; @@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: { daemon: host, preferences: { - serverId: input.serverId, provider: "codex", providerPreferences: { - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, + codex: { + model: "gpt-5.4-mini", + thinkingByModel: { "gpt-5.4-mini": "low" }, + }, }, - }, + } satisfies FormPreferences, }, ); } diff --git a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts index 4cf294d69..c4d2ccfff 100644 --- a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts +++ b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts @@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => { label: "restart daemon", nowIso, }), - preferences: buildCreateAgentPreferences(serverId), + preferences: buildCreateAgentPreferences(), }, ); } diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index e8352a32e..723f04808 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -132,7 +132,7 @@ const test = metroTest.extend< endpoint: `127.0.0.1:${daemonPort}`, nowIso, }); - const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId); + const createAgentPreferences = buildCreateAgentPreferences(); await page.addInitScript( ({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => { diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index 201bb189a..c33414412 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -26,7 +26,7 @@ function buildSeededStoragePayload() { endpoint: `127.0.0.1:${getE2EDaemonPort()}`, nowIso, }), - preferences: buildCreateAgentPreferences(getServerId()), + preferences: buildCreateAgentPreferences(), }; } diff --git a/packages/app/e2e/support/helpers/daemon-registry.ts b/packages/app/e2e/support/helpers/daemon-registry.ts index 6ef72ddc2..f3b63a751 100644 --- a/packages/app/e2e/support/helpers/daemon-registry.ts +++ b/packages/app/e2e/support/helpers/daemon-registry.ts @@ -1,14 +1,23 @@ +import type { + FormPreferences, + ProviderPreferences, +} from "../../../src/create-agent-preferences/preferences"; + export const TEST_HOST_LABEL = "localhost"; export const TEST_PROVIDER_PREFERENCES = { claude: { model: "haiku" }, - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, -} as const; + codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } }, +} satisfies Record; -export function buildDirectTcpConnection(endpoint: string) { +export function buildDirectTcpConnection(endpoint: string): { + id: string; + type: "directTcp"; + endpoint: string; +} { return { id: `direct:${endpoint}`, - type: "directTcp" as const, + type: "directTcp", endpoint, }; } @@ -33,12 +42,11 @@ export function buildSeededHost(input: { export const TEST_MOCK_PROVIDER_PREFERENCES = { ...TEST_PROVIDER_PREFERENCES, mock: { model: "ten-second-stream" }, -} as const; +} satisfies Record; -export function buildCreateAgentPreferences(serverId: string) { +export function buildCreateAgentPreferences() { return { - serverId, - provider: "mock" as const, + provider: "mock", providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES, - }; + } satisfies FormPreferences; } diff --git a/packages/app/e2e/support/helpers/relay-deployment.ts b/packages/app/e2e/support/helpers/relay-deployment.ts index 19e480d48..3d727e080 100644 --- a/packages/app/e2e/support/helpers/relay-deployment.ts +++ b/packages/app/e2e/support/helpers/relay-deployment.ts @@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) }, + { storedHost: host, preferences: buildCreateAgentPreferences() }, ); const relaySocketOpened = new Promise((resolve) => { diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index d87db35ef..2af795375 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -132,7 +132,7 @@ export async function seedSavedSettingsHosts( if (!firstHost) { throw new Error("Expected at least one settings host fixture."); } - const preferences = buildCreateAgentPreferences(firstHost.serverId); + const preferences = buildCreateAgentPreferences(); await page.evaluate( ({ keys, storedRegistry, storedPreferences }) => { diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index 96ad96f3c..fe78109fe 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -106,7 +106,7 @@ class StartupScenario { if (!firstHost) { throw new Error("Expected at least one startup test host."); } - const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); + const createAgentPreferences = buildStoredCreateAgentPreferences(); await this.page.evaluate( ({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => { @@ -245,6 +245,6 @@ function buildStoredHost(input: { return buildSeededHost(input); } -function buildStoredCreateAgentPreferences(serverId: string) { - return buildCreateAgentPreferences(serverId); +function buildStoredCreateAgentPreferences() { + return buildCreateAgentPreferences(); } diff --git a/packages/app/src/agent-profiles/migration/index.test.ts b/packages/app/src/agent-profiles/migration/index.test.ts index cf40001e7..a2034b5d1 100644 --- a/packages/app/src/agent-profiles/migration/index.test.ts +++ b/packages/app/src/agent-profiles/migration/index.test.ts @@ -18,6 +18,10 @@ class MemoryStorage { async setItem(key: string, value: string): Promise { this.values.set(key, value); } + + async removeItem(key: string): Promise { + this.values.delete(key); + } } class FakeProfileHost { diff --git a/packages/app/src/agent-profiles/migration/index.ts b/packages/app/src/agent-profiles/migration/index.ts index 964ef248f..f16042075 100644 --- a/packages/app/src/agent-profiles/migration/index.ts +++ b/packages/app/src/agent-profiles/migration/index.ts @@ -2,29 +2,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { z } from "zod"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { FormPreferencesSchema } from "@/create-agent-preferences/preferences"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; const PREFERENCES_KEY = "@paseo:create-agent-preferences"; const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:"; const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const; -const legacyPreferencesSchema = z.object({ - favoriteModels: z - .array( - z.object({ - provider: z.string().trim().min(1), - modelId: z.string().trim().min(1), - }), - ) - .optional(), -}); - -type LegacyFavorite = NonNullable< - z.infer["favoriteModels"] ->[number]; +type LegacyFavorite = NonNullable["favoriteModels"]>[number]; export interface LegacyFavoriteMigrationStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface LegacyFavoriteMigrationHost { @@ -47,18 +37,6 @@ function completionKey(serverId: string): string { return `${COMPLETION_KEY_PREFIX}${serverId}`; } -function parseStoredPreferences(raw: string | null): LegacyFavorite[] { - if (!raw) { - return []; - } - try { - const result = legacyPreferencesSchema.safeParse(JSON.parse(raw)); - return result.success ? (result.data.favoriteModels ?? []) : []; - } catch { - return []; - } -} - function favoriteKey(favorite: Pick): string { return `${favorite.provider}\u0000${favorite.modelId}`; } @@ -205,11 +183,16 @@ export class LegacyFavoriteProfileMigration { return; } const markerKey = completionKey(serverId); - if ((await this.storage.getItem(markerKey)) === "1") { + if ((await readValidatedString(this.storage, markerKey, z.literal("1"))) === "1") { return; } - const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY)); + const preferences = await readValidatedJson( + this.storage, + PREFERENCES_KEY, + FormPreferencesSchema, + ); + const favorites = preferences?.favoriteModels ?? []; if (favorites.length === 0) { await this.storage.setItem(markerKey, "1"); return; diff --git a/packages/app/src/contexts/sidebar-callout-context.tsx b/packages/app/src/contexts/sidebar-callout-context.tsx index 2e337aadd..548a32edc 100644 --- a/packages/app/src/contexts/sidebar-callout-context.tsx +++ b/packages/app/src/contexts/sidebar-callout-context.tsx @@ -13,9 +13,9 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import { clearSidebarCallouts, createSidebarCalloutState, + DismissedCalloutKeysSchema, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -24,6 +24,7 @@ import { type SidebarCalloutState, unregisterSidebarCallout, } from "./sidebar-callout-state"; +import { readValidatedJson } from "@/storage/validated-storage"; export type { SidebarCalloutOptions } from "./sidebar-callout-state"; @@ -62,8 +63,12 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) { async function loadDismissedKeys(): Promise { let dismissedKeys: ReadonlySet; try { - const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); - dismissedKeys = parseDismissedCalloutKeys(value); + const value = await readValidatedJson( + AsyncStorage, + DISMISSED_CALLOUTS_STORAGE_KEY, + DismissedCalloutKeysSchema, + ); + dismissedKeys = new Set(value ?? []); } catch (error) { console.error("[SidebarCallouts] Failed to load dismissed callouts", error); dismissedKeys = stateRef.current.dismissedKeys; diff --git a/packages/app/src/contexts/sidebar-callout-state.test.ts b/packages/app/src/contexts/sidebar-callout-state.test.ts index 9b7d1a13a..f73468f0b 100644 --- a/packages/app/src/contexts/sidebar-callout-state.test.ts +++ b/packages/app/src/contexts/sidebar-callout-state.test.ts @@ -4,7 +4,6 @@ import { createSidebarCalloutState, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -118,12 +117,6 @@ describe("sidebar callout state", () => { expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); }); - it("parses stored dismissal keys defensively", () => { - expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"])); - expect(parseDismissedCalloutKeys("{")).toEqual(new Set()); - expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set()); - }); - it("clears visible callouts without dropping dismissal state", () => { let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"])); state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state; diff --git a/packages/app/src/contexts/sidebar-callout-state.ts b/packages/app/src/contexts/sidebar-callout-state.ts index fa1da011a..0cd83c53f 100644 --- a/packages/app/src/contexts/sidebar-callout-state.ts +++ b/packages/app/src/contexts/sidebar-callout-state.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { z } from "zod"; import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout"; export interface SidebarCalloutOptions { @@ -44,21 +45,7 @@ export function normalizeDismissalKey(key: string | null | undefined): string | return trimmed ? trimmed : null; } -export function parseDismissedCalloutKeys(value: string | null): Set { - if (!value) { - return new Set(); - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!Array.isArray(parsed)) { - return new Set(); - } - return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); - } catch { - return new Set(); - } -} +export const DismissedCalloutKeysSchema = z.array(z.string()); export function serializeDismissedCalloutKeys(keys: ReadonlySet): string { return JSON.stringify([...keys]); diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 7529a8072..cb48416d7 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -137,6 +137,36 @@ describe("create agent preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); + it("strips the explicitly supported legacy location fields", () => { + expect( + parseFormPreferences({ + workingDir: "/old/workspace", + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingOptionId: "high", + }, + }, + serverId: "old-host", + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingByModel: { "gpt-5.4-mini": "high" }, + }, + }, + }); + }); + + it("rejects unknown persisted fields outside the explicit legacy shape", () => { + expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); + }); + it("persists and reloads the workspace isolation choice", async () => { const storage = new FakeCreateAgentPreferenceStorage(); const preferences = new CreateAgentPreferencesService(storage); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 7153fa8b4..710df9efd 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,19 +1,38 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const providerPreferencesSchema = z.object({ +const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); + +export interface ProviderPreferences { + model?: string; + mode?: string; + thinkingByModel?: Record; + featureValues?: Record; +} + +export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; + +export interface FormPreferences { + provider?: string; + providerPreferences?: Record; + favoriteModels?: Array<{ provider: string; modelId: string }>; + isolation?: "local" | "worktree"; + launchTarget?: LaunchTarget; +} + +const providerPreferencesSchema: z.ZodType = z.strictObject({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string(), z.string()).optional(), - featureValues: z.record(z.string(), z.unknown()).optional(), + featureValues: featureValuesSchema.optional(), }); -const launchTargetSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("chat") }), - z.object({ kind: z.literal("terminal"), profileId: z.string() }), +const launchTargetSchema: z.ZodType = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("chat") }), + z.strictObject({ kind: z.literal("terminal"), profileId: z.string() }), ]); -const formPreferencesSchema = z.object({ +export const FormPreferencesSchema = z.strictObject({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. @@ -21,7 +40,7 @@ const formPreferencesSchema = z.object({ // import it; ordinary preference writes must not erase it first. favoriteModels: z .array( - z.object({ + z.strictObject({ provider: z.string(), modelId: z.string(), }), @@ -31,16 +50,50 @@ const formPreferencesSchema = z.object({ // What the New workspace composer submits to: the chat agent (default) or a // terminal profile. See `@/new-workspace-launch` for resolution/fallback. launchTarget: launchTargetSchema.optional(), +}) satisfies z.ZodType; + +const LegacyProviderPreferencesSchema = z.strictObject({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOptionId: z.string().optional(), }); -export type ProviderPreferences = z.infer; -export type FormPreferences = z.infer; -export type LaunchTarget = z.infer; +const LegacyFormPreferencesSchema = z + .strictObject({ + workingDir: z.string().optional(), + provider: z.string().optional(), + serverId: z.string().optional(), + providerPreferences: z.record(z.string(), LegacyProviderPreferencesSchema).optional(), + }) + .transform(({ provider, providerPreferences }): FormPreferences => { + const migratedProviderPreferences: Record = {}; + for (const [providerId, legacy] of Object.entries(providerPreferences ?? {})) { + const model = legacy.model; + migratedProviderPreferences[providerId] = { + ...(model !== undefined ? { model } : {}), + ...(legacy.mode !== undefined ? { mode: legacy.mode } : {}), + ...(model !== undefined && legacy.thinkingOptionId !== undefined + ? { thinkingByModel: { [model]: legacy.thinkingOptionId } } + : {}), + }; + } + return { + ...(provider !== undefined ? { provider } : {}), + ...(providerPreferences !== undefined + ? { providerPreferences: migratedProviderPreferences } + : {}), + }; + }); + +export const StoredFormPreferencesSchema: z.ZodType = z.union([ + FormPreferencesSchema, + LegacyFormPreferencesSchema, +]); export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; export function parseFormPreferences(value: unknown): FormPreferences { - const result = formPreferencesSchema.safeParse(value); + const result = StoredFormPreferencesSchema.safeParse(value); return result.success ? result.data : DEFAULT_FORM_PREFERENCES; } @@ -115,6 +168,7 @@ export function mergeCreateAgentSelectionPreferences(args: { const modelId = args.modelId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; + const featureValues = featureValuesSchema.safeParse(args.featureValues); return mergeProviderPreferences({ preferences: args.preferences, @@ -123,7 +177,7 @@ export function mergeCreateAgentSelectionPreferences(args: { model: modelId || undefined, mode: modeId || undefined, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), - ...(args.featureValues ? { featureValues: args.featureValues } : {}), + ...(featureValues.success ? { featureValues: featureValues.data } : {}), }, }); } diff --git a/packages/app/src/create-agent-preferences/storage.ts b/packages/app/src/create-agent-preferences/storage.ts index 2dd231142..951c9efcf 100644 --- a/packages/app/src/create-agent-preferences/storage.ts +++ b/packages/app/src/create-agent-preferences/storage.ts @@ -1,5 +1,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { FormPreferences } from "./preferences"; +import { readValidatedJson } from "@/storage/validated-storage"; +import { + FormPreferencesSchema, + StoredFormPreferencesSchema, + type FormPreferences, +} from "./preferences"; export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; @@ -10,19 +15,19 @@ export interface CreateAgentPreferenceStorage { export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { async read(): Promise { - const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); - if (!stored) { - return null; - } - - try { - return JSON.parse(stored); - } catch { - return null; - } + return readValidatedJson( + AsyncStorage, + CREATE_AGENT_PREFERENCES_STORAGE_KEY, + StoredFormPreferencesSchema, + ); } async write(preferences: FormPreferences): Promise { - await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); + const result = FormPreferencesSchema.safeParse(preferences); + if (!result.success) { + await AsyncStorage.removeItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); + return; + } + await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(result.data)); } } diff --git a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts index 6b0fcf245..84d7cc53d 100644 --- a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts +++ b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts @@ -73,5 +73,5 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt } function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; + return structuredClone(value); } diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index d252613d1..ed4823a39 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -6,6 +6,7 @@ import { type CompactProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; +import { z } from "zod"; const CACHE_VERSION = 1; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; @@ -44,6 +45,13 @@ interface ProviderSnapshotIndex { entries: ProviderSnapshotIndexEntry[]; } +const StoredProviderSnapshotSchema: z.ZodType = z.strictObject({ + version: z.literal(CACHE_VERSION), + hash: z.string(), + generatedAt: z.string().datetime({ offset: true }), + compactSnapshot: CompactProviderSnapshotSchema, +}); + export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } @@ -81,22 +89,8 @@ function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIn function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); - if (typeof parsed !== "object" || parsed === null) return null; - const version = Reflect.get(parsed, "version"); - const hash = Reflect.get(parsed, "hash"); - const generatedAt = Reflect.get(parsed, "generatedAt"); - const compactSnapshot = CompactProviderSnapshotSchema.safeParse( - Reflect.get(parsed, "compactSnapshot"), - ); - if ( - version !== CACHE_VERSION || - typeof hash !== "string" || - typeof generatedAt !== "string" || - !compactSnapshot.success - ) { - return null; - } - return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data }; + const result = StoredProviderSnapshotSchema.safeParse(parsed); + return result.success ? result.data : null; } export function createProviderSnapshotCache( @@ -173,12 +167,18 @@ export function createProviderSnapshotCache( }): Promise { const currentIndex = await ensureCacheIndex(); const key = cacheKey(input.serverId, input.cwd); - const value = JSON.stringify({ + const stored = StoredProviderSnapshotSchema.safeParse({ version: CACHE_VERSION, hash: input.hash, generatedAt: input.generatedAt, compactSnapshot: input.compactSnapshot, - } satisfies StoredProviderSnapshot); + }); + if (!stored.success) { + await storage.removeItem(key); + await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); + return; + } + const value = JSON.stringify(stored.data); const incomingBytes = storedBytes(key, value); const canStoreIncoming = incomingBytes <= maxBytes; const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); diff --git a/packages/app/src/desktop/browser/store/index.ts b/packages/app/src/desktop/browser/store/index.ts index 7ff21c733..16558eaa0 100644 --- a/packages/app/src/desktop/browser/store/index.ts +++ b/packages/app/src/desktop/browser/store/index.ts @@ -1,9 +1,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { applyBrowserPatch, + BrowserIndexStateSchema, type BrowserIndexState, type BrowserRecord, type BrowserRecordPatch, @@ -74,7 +76,7 @@ export const useBrowserStore = create()( }), { name: "workspace-browser-store", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, BrowserIndexStateSchema), partialize: (state) => sanitizeBrowsersForPersist(state), merge: (persistedState, currentState) => ({ ...currentState, diff --git a/packages/app/src/desktop/browser/store/state.ts b/packages/app/src/desktop/browser/store/state.ts index 3909a7aa3..47d0de245 100644 --- a/packages/app/src/desktop/browser/store/state.ts +++ b/packages/app/src/desktop/browser/store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type BrowserViewport = | { mode: "responsive" } | { mode: "fixed"; width: number; height: number }; @@ -23,6 +25,32 @@ export interface BrowserIndexState { browsersById: Record; } +const BrowserViewportSchema = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("responsive") }), + z.strictObject({ + mode: z.literal("fixed"), + width: z.number().positive(), + height: z.number().positive(), + }), +]); + +const BrowserRecordSchema = z.strictObject({ + browserId: z.string(), + url: z.string(), + title: z.string(), + isLoading: z.boolean(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + faviconUrl: z.string().nullable(), + lastError: z.string().nullable(), + viewport: BrowserViewportSchema.optional().default(RESPONSIVE_BROWSER_VIEWPORT), + createdAt: z.number(), +}); + +export const BrowserIndexStateSchema: z.ZodType = z.strictObject({ + browsersById: z.record(z.string(), BrowserRecordSchema), +}); + export function createFixedBrowserViewport(width: number, height: number): BrowserViewport { return { mode: "fixed", @@ -32,19 +60,10 @@ export function createFixedBrowserViewport(width: number, height: number): Brows } export function normalizeBrowserViewport(value: unknown): BrowserViewport { - if ( - value && - typeof value === "object" && - (value as { mode?: unknown }).mode === "fixed" && - typeof (value as { width?: unknown }).width === "number" && - typeof (value as { height?: unknown }).height === "number" - ) { - return createFixedBrowserViewport( - (value as { width: number }).width, - (value as { height: number }).height, - ); - } - return RESPONSIVE_BROWSER_VIEWPORT; + const result = BrowserViewportSchema.safeParse(value); + return result.success && result.data.mode === "fixed" + ? createFixedBrowserViewport(result.data.width, result.data.height) + : RESPONSIVE_BROWSER_VIEWPORT; } function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean { @@ -56,27 +75,8 @@ function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): b } export function normalizeBrowserIndexState(value: unknown): BrowserIndexState { - if (!value || typeof value !== "object") { - return { browsersById: {} }; - } - const browsersById = (value as { browsersById?: unknown }).browsersById; - if (!browsersById || typeof browsersById !== "object") { - return { browsersById: {} }; - } - return { - browsersById: Object.fromEntries( - Object.entries(browsersById).map(([browserId, record]) => { - const browser = record as BrowserRecord & { viewport?: unknown }; - return [ - browserId, - { - ...browser, - viewport: normalizeBrowserViewport(browser.viewport), - } satisfies BrowserRecord, - ]; - }), - ), - }; + const result = BrowserIndexStateSchema.safeParse(value); + return result.success ? result.data : { browsersById: {} }; } export function trimNonEmpty(value: string | null | undefined): string | null { diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 63f6c7425..05e65a21a 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "re import { Info } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { getForgePresentation, type Forge } from "@/git/forge"; @@ -29,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { readValidatedString } from "@/storage/validated-storage"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -361,10 +363,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use } let isActive = true; setShipDefault("pr"); - AsyncStorage.getItem(shipDefaultStorageKey) + readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"])) .then((value) => { if (!isActive) return; - if (value === "pr" || value === "merge") { + if (value) { setShipDefault(value); return; } diff --git a/packages/app/src/hooks/use-changes-preferences/fakes.ts b/packages/app/src/hooks/use-changes-preferences/fakes.ts index 96e7a8490..62f194a5d 100644 --- a/packages/app/src/hooks/use-changes-preferences/fakes.ts +++ b/packages/app/src/hooks/use-changes-preferences/fakes.ts @@ -16,5 +16,8 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts index 3ceaadead..05a0038dc 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -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; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise { - 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 { - 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); diff --git a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts index 874b28122..620e76efd 100644 --- a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts +++ b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts @@ -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 { 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); } diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index 480fd3d56..b21618518 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -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 { - 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( diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts index 0e7ce025d..691c3e9c1 100644 --- a/packages/app/src/hooks/use-settings/fakes.ts +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -17,6 +17,9 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index b421c27cb..87fdd300f 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -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 () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3b78310fa..0b7a733de 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -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(THEME_OPTIONS.map((option) => option.name)); +const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name)); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ @@ -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> & { - 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; 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; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface DesktopSettingsBridge { @@ -146,17 +181,20 @@ export async function saveAppSettings(input: { export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { 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; 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 { return result; } -function pickAppSettingsFromLegacy(legacy: Record): Partial { +function pickAppSettingsFromLegacy( + legacy: z.infer, +): Partial { const result: Partial = {}; 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 | null> { - const current = await storage.getItem(APP_SETTINGS_KEY); +): Promise | null> { + const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema); if (current) { - return JSON.parse(current) as Record; + return current; } - const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); - if (!legacy) { - return null; - } - return JSON.parse(legacy) as Record; + return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema); } diff --git a/packages/app/src/hosts/appearance.ts b/packages/app/src/hosts/appearance.ts index 8f4fc80a2..e51f4410d 100644 --- a/packages/app/src/hosts/appearance.ts +++ b/packages/app/src/hosts/appearance.ts @@ -1,5 +1,6 @@ import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors"; import type { HostProfile } from "@/types/host-connection"; +import { z } from "zod"; export type HostColor = "none" | IdentityColorName; @@ -19,27 +20,18 @@ export interface HostAppearance { badgeDisplay: HostBadgeDisplay | null; } +export const HostAppearanceSchema: z.ZodType = z.strictObject({ + color: z.enum(["none", ...IDENTITY_COLOR_NAMES]), + badgeDisplay: z.enum(["name", "icon", "hidden"]).nullable(), +}); + export function defaultHostAppearance(): HostAppearance { return { color: "none", badgeDisplay: null }; } -function isHostColor(value: unknown): value is HostColor { - return HOST_COLORS.some((color) => color === value); -} - -function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay { - return HOST_BADGE_DISPLAYS.some((display) => display === value); -} - export function normalizeStoredHostAppearance(value: unknown): HostAppearance { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return defaultHostAppearance(); - } - const record = value as Record; - return { - color: isHostColor(record.color) ? record.color : "none", - badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null, - }; + const result = HostAppearanceSchema.safeParse(value); + return result.success ? result.data : defaultHostAppearance(); } export function resolveHostBadgeDisplay(input: { diff --git a/packages/app/src/push-notifications/internal/subscriptions.ts b/packages/app/src/push-notifications/internal/subscriptions.ts index 7a2934767..26df575eb 100644 --- a/packages/app/src/push-notifications/internal/subscriptions.ts +++ b/packages/app/src/push-notifications/internal/subscriptions.ts @@ -2,23 +2,22 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; +const ExpoPushTokenSchema = z.string().trim().min(1); function storageKey(serverId: string): string { return `${STORAGE_PREFIX}${serverId}`; } function getExpoProjectId(): string | null { - const constants = Constants as unknown as { - easConfig?: { projectId?: unknown }; - expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; - }; - const fromEas = constants.easConfig?.projectId; - if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); - const fromExtra = constants.expoConfig?.extra?.eas?.projectId; - return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null; + const fromEas = ExpoPushTokenSchema.safeParse(Constants.easConfig?.projectId); + if (fromEas.success) return fromEas.data; + const fromExtra = ExpoPushTokenSchema.safeParse(Constants.expoConfig?.extra?.eas?.projectId); + return fromExtra.success ? fromExtra.data : null; } async function ensurePushPermission(): Promise { @@ -31,7 +30,7 @@ async function ensurePushPermission(): Promise { async function resolveToken(serverId: string): Promise { const key = storageKey(serverId); - const cached = await AsyncStorage.getItem(key); + const cached = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if (!(await ensurePushPermission())) { await AsyncStorage.removeItem(key); return null; @@ -87,7 +86,7 @@ export function startSubscription(input: StartPushNotificationsInput): () => voi export async function revokeSubscription(input: RevokePushNotificationsInput): Promise { const key = storageKey(input.serverId); - const token = await AsyncStorage.getItem(key); + const token = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if ( token && input.client?.isConnected && diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts index dff34ff03..1b5057a64 100644 --- a/packages/app/src/review/state.ts +++ b/packages/app/src/review/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type ReviewDraftMode = "uncommitted" | "base"; export type ReviewDraftSide = "old" | "new"; @@ -30,8 +32,27 @@ export interface ReviewDraftStoreState { // Only drafts are persisted; diffModeOverrides is intentionally excluded. export interface SerializedReviewDraftState { drafts: Record; + activeModesByScope?: Record; } +const IsoDateTimeSchema = z.string().datetime({ offset: true }); +export const ReviewDraftCommentSchema: z.ZodType = z.strictObject({ + id: z.string(), + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, +}); + +export const SerializedReviewDraftStateSchema: z.ZodType = + z.strictObject({ + drafts: z.record(z.string(), z.array(ReviewDraftCommentSchema)), + // COMPAT(reviewDraftModes): v1 persisted this field; v2 discards it during migration. + activeModesByScope: z.record(z.string(), z.enum(["uncommitted", "base"])).optional(), + }); + export function setDiffModeOverrideInState( state: ReviewDraftStoreState, input: { scopeKey: string; override: DiffModeOverride }, @@ -160,45 +181,11 @@ export function serializeReviewDraftState( } export function normalizePersistedState(state: unknown): ReviewDraftStoreState { - if (!state || typeof state !== "object") { - return { drafts: {}, diffModeOverrides: {} }; - } - // activeModesByScope may be present in old persisted JSON — tolerate and ignore it. - const persisted = state as { drafts?: unknown }; - const drafts = persisted.drafts; - if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, diffModeOverrides: {} }; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(drafts)) { - if (!Array.isArray(value)) { - continue; - } - normalized[key] = value.filter((comment): comment is ReviewDraftComment => - isReviewDraftComment(comment), - ); - } - - return { drafts: normalized, diffModeOverrides: {} }; -} - -export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.filePath === "string" && - (record.side === "old" || record.side === "new") && - typeof record.lineNumber === "number" && - Number.isInteger(record.lineNumber) && - record.lineNumber > 0 && - typeof record.body === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); + const result = SerializedReviewDraftStateSchema.safeParse(state); + return { + drafts: result.success ? result.data.drafts : {}, + diffModeOverrides: {}, + }; } function applyCommentUpdates( diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 62e68f015..8c8dea946 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, @@ -16,6 +18,7 @@ import { type ReviewDraftComment, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "./state"; @@ -69,6 +72,20 @@ function makeFile(): ParsedDiffFile { }; } +function createMemoryStorage(): StateStorage & { values: Map } { + const values = new Map(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + describe("buildReviewDraftKey", () => { it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => { const base = buildReviewDraftKey({ @@ -122,7 +139,27 @@ describe("buildReviewDraftKey", () => { }); describe("normalizePersistedState", () => { - it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => { + it("keeps v1 review comments for migration while dropping the legacy mode field", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { "review:key": [makeComment()] }, + activeModesByScope: { "review:scope": "base" }, + }; + backing.values.set( + "@paseo:review-draft-store", + JSON.stringify({ state: legacyState, version: 1 }), + ); + const storage = createValidatedPersistStorage(backing, SerializedReviewDraftStateSchema); + + const stored = await storage.getItem("@paseo:review-draft-store"); + const normalized = normalizePersistedState(stored?.state); + + expect(normalized.drafts["review:key"]).toEqual([makeComment()]); + expect(normalized.diffModeOverrides).toEqual({}); + expect(backing.values.has("@paseo:review-draft-store")).toBe(true); + }); + + it("rejects the complete payload when any draft comment or field is invalid", () => { const normalized = normalizePersistedState({ drafts: { "review:key": [ @@ -146,17 +183,7 @@ describe("normalizePersistedState", () => { }); expect(normalized.diffModeOverrides).toEqual({}); - expect(normalized.drafts["review:key"]).toEqual([ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Keep me.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - ]); + expect(normalized.drafts).toEqual({}); }); it("returns empty state for null, non-object, or malformed inputs", () => { diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index d1cc5e16b..3f9d7c55e 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { @@ -17,12 +17,14 @@ import { type ReviewDraftSide, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DiffModeOverride, @@ -168,7 +170,7 @@ export const useReviewDraftStore = create()( { name: "@paseo:review-draft-store", version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SerializedReviewDraftStateSchema), partialize: (state) => serializeReviewDraftState(state), migrate: async (state) => normalizePersistedState(state), }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 1fe16b72c..9fc711d68 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -445,6 +445,9 @@ function createMemoryHostRuntimeStorage(entries: Record = {}): H setItem: async (key, value) => { values.set(key, value); }, + removeItem: async (key) => { + values.delete(key); + }, }; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 5bd205074..eff24ee32 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; import { DaemonClient, + type DaemonClientConfig, type ConnectionState, type FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; @@ -11,6 +12,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + StoredHostRegistrySchema, type HostConnection, type HostProfile, } from "@/types/host-connection"; @@ -28,6 +30,8 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { getOrCreateClientId } from "@/utils/client-id"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; import { selectBestConnection, type ConnectionCandidate, @@ -163,6 +167,7 @@ export interface HostRuntimeControllerDeps { export interface HostRuntimeStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; } export interface HostRuntimeStartOptions { @@ -483,12 +488,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps { const base = { suppressSendErrors: true, clientId, - clientType: "mobile" as const, + clientType: "mobile", appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, - }; + } satisfies Omit; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, @@ -1288,9 +1293,10 @@ export interface InitialDaemonConnectionHint { useTls?: boolean; } -function isInitialConnectionHintRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const InitialDaemonConnectionHintSchema: z.ZodType = z.object({ + listen: z.string().trim().min(1), + useTls: z.boolean().optional().default(false), +}); export function readInitialDaemonConnectionHint(input?: { isWebRuntime?: boolean; @@ -1299,18 +1305,10 @@ export function readInitialDaemonConnectionHint(input?: { if (!isWebRuntime || typeof globalThis === "undefined") { return null; } - const value = (globalThis as Record)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY]; - if (!isInitialConnectionHintRecord(value)) { - return null; - } - const listen = typeof value.listen === "string" ? value.listen.trim() : ""; - if (!listen) { - return null; - } - return { - listen, - useTls: value.useTls === true, - }; + const result = InitialDaemonConnectionHintSchema.safeParse( + Reflect.get(globalThis, INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY), + ); + return result.success ? result.data : null; } function readConfiguredLocalDaemonOverride(): string | null { @@ -1419,7 +1417,7 @@ export class HostRuntimeStore { let isE2E: string | null = null; try { - isE2E = await this.storage.getItem(E2E_STORAGE_KEY); + isE2E = await readValidatedString(this.storage, E2E_STORAGE_KEY, z.string().min(1)); } catch { return; } @@ -1452,17 +1450,25 @@ export class HostRuntimeStore { let shouldPersistHosts = false; let profiles: HostProfile[] = []; try { - const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); + const stored = await readValidatedJson( + this.storage, + REGISTRY_STORAGE_KEY, + StoredHostRegistrySchema, + ); if (stored) { - const parsed = JSON.parse(stored) as unknown; - if (Array.isArray(parsed)) { - const normalizedProfiles = parsed - .map((entry) => normalizeStoredHostProfile(entry)) - .filter((entry): entry is HostProfile => entry !== null); - profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); - if (profiles.length !== normalizedProfiles.length) { - shouldPersistHosts = true; + const normalizedProfiles: HostProfile[] = []; + for (const entry of stored) { + const profile = normalizeStoredHostProfile(entry); + if (!profile) { + await this.storage.removeItem(REGISTRY_STORAGE_KEY); + normalizedProfiles.length = 0; + break; } + normalizedProfiles.push(profile); + } + profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); + if (profiles.length !== normalizedProfiles.length) { + shouldPersistHosts = true; } } this.hosts = profiles; @@ -1922,7 +1928,11 @@ export class HostRuntimeStore { void this.persistHosts().catch((error) => console.error("[HostRuntime] Failed to persist host registry", error), ); - return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile; + const profile = next.find((daemon) => daemon.serverId === input.serverId); + if (!profile) { + throw new Error(`Host ${input.serverId} was not inserted`); + } + return profile; } private setHostsAndSync( @@ -2321,25 +2331,19 @@ export class HostRuntimeStore { let singletonHostRuntimeStore: HostRuntimeStore | null = null; const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; -type HostRuntimeGlobal = typeof globalThis & { - [HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore; -}; - export function getHostRuntimeStore(): HostRuntimeStore { if (singletonHostRuntimeStore) { return singletonHostRuntimeStore; } - const runtimeGlobal = globalThis as HostRuntimeGlobal; - if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { - singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; - if (singletonHostRuntimeStore) { - return singletonHostRuntimeStore; - } + const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY); + if (existing instanceof HostRuntimeStore) { + singletonHostRuntimeStore = existing; + return existing; } singletonHostRuntimeStore = new HostRuntimeStore(); - runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; + Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore); return singletonHostRuntimeStore; } @@ -2396,12 +2400,11 @@ export function useHostRuntimeConnectionStatuses( return useMemo(() => { // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. void version; - return new Map( - serverIds.map( - (serverId) => - [serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, - ), - ); + const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ + serverId, + store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + ]); + return new Map(entries); }, [serverIds, store, version]); } diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 8149032f3..6066aed51 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,6 +25,10 @@ class MemoryStorage implements ReplicaCacheStorage { this.writes += 1; this.values.set(key, value); } + + async removeItem(key: string): Promise { + this.values.delete(key); + } } function workspace( @@ -272,7 +276,7 @@ describe("ReplicaCache", () => { version: number; hosts: Array<{ timeline: Record | null }>; }; - expect(persisted.version).toBe(4); + expect(persisted.version).toBe(5); expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); }); @@ -331,12 +335,12 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects version 3 cache data and overwrites it on flush", async () => { + it("rejects and clears version 4 cache data before overwriting it on flush", async () => { const storage = new MemoryStorage(); storage.values.set( "@paseo:replica-cache", JSON.stringify({ - version: 3, + version: 4, hosts: [ { serverId: SERVER_ID, @@ -357,11 +361,12 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.restore(); + expect(storage.values.has("@paseo:replica-cache")).toBe(false); await cache.flush(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 4, + version: 5, hosts: [], }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c773327bb..7459b526e 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,19 +1,14 @@ import { Buffer } from "buffer"; import { z } from "zod"; -import { - AgentSnapshotPayloadSchema, - ProjectPlacementPayloadSchema, - WorkspaceDescriptorPayloadSchema, - WorkspaceProjectDescriptorPayloadSchema, - type AgentSnapshotPayload, - type WorkspaceDescriptorPayload, -} from "@getpaseo/protocol/messages"; +import { AgentStatusSchema } from "@getpaseo/protocol/messages"; +import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, selectAgentTimelineState, useSessionStore, type Agent, + type AgentTimelineState, type SessionReplica, type SessionState, type ProjectDescriptor, @@ -23,39 +18,217 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream" import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 4; +const CACHE_VERSION = 5; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; -const DATE_TAG = "__paseoDate"; - -const StoredAgentSchema = z.object({ - snapshot: AgentSnapshotPayloadSchema, - projectPlacement: ProjectPlacementPayloadSchema.nullable(), - lastActivityAt: z.string(), +const IsoDateSchema = z.iso.datetime(); +const TimelinePositionSchema = z.strictObject({ + epoch: z.string(), + seq: z.number().int().nonnegative(), }); -const StoredTimelineSchema = z.object({ +const TimelineItemBaseShape = { + id: z.string(), + timelineCursor: TimelinePositionSchema.optional(), + timestamp: IsoDateSchema, +}; + +const TodoEntrySchema = z.strictObject({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), +}); + +const TaskActivitySchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("created"), count: z.number().int().nonnegative() }), + z.strictObject({ + type: z.enum(["added", "started", "completed", "reopened"]), + task: z.string(), + }), +]); + +const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("user_message"), + clientMessageId: z.string().optional(), + messageId: z.string().optional(), + text: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("assistant_message"), + messageId: z.string().optional(), + text: z.string(), + blockGroupId: z.string().optional(), + blockIndex: z.number().int().nonnegative().optional(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("thought"), + text: z.string(), + status: z.enum(["loading", "ready"]), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("todo_list"), + provider: AgentProviderSchema, + items: z.array(TodoEntrySchema), + activity: TaskActivitySchema, + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("activity_log"), + activityType: z.enum(["system", "info", "success", "error"]), + message: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().nonnegative().optional(), + }), +]); + +const AgentCapabilitiesSchema = z.strictObject({ + supportsStreaming: z.boolean(), + supportsSessionPersistence: z.boolean(), + supportsSessionListing: z.boolean().optional(), + supportsDynamicModes: z.boolean(), + supportsMcpServers: z.boolean(), + supportsReasoningStream: z.boolean(), + supportsToolInvocations: z.boolean(), + supportsRewindConversation: z.boolean().optional(), + supportsRewindFiles: z.boolean().optional(), + supportsRewindBoth: z.boolean().optional(), +}); + +const StoredAgentSnapshotSchema = z.strictObject({ + id: z.string(), + provider: AgentProviderSchema, + cwd: z.string(), + workspaceId: z.string().optional(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable().optional(), + createdAt: IsoDateSchema, + updatedAt: IsoDateSchema, + lastUserMessageAt: IsoDateSchema.nullable(), + status: AgentStatusSchema, + activeTurn: z + .strictObject({ + turnId: z.string(), + startedAt: IsoDateSchema.nullable(), + }) + .nullable() + .optional(), + capabilities: AgentCapabilitiesSchema, + currentModeId: z.string().nullable(), + availableModes: z.array(z.never()).max(0), + pendingPermissions: z.array(z.never()).max(0), + persistence: z.null(), + lastError: z.string().optional(), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: IsoDateSchema.nullable().optional(), + archivedAt: IsoDateSchema.nullable().optional(), +}); + +const StoredAgentSchema = z.strictObject({ + snapshot: StoredAgentSnapshotSchema, + projectPlacement: z.null(), + lastActivityAt: IsoDateSchema, +}); + +const WorkspaceScriptSchema = z.strictObject({ + scriptName: z.string(), + type: z.enum(["script", "service"]), + hostname: z.string(), + port: z.number().int().positive().nullable(), + localProxyUrl: z.string().nullable().optional(), + publicProxyUrl: z.string().nullable().optional(), + proxyUrl: z.string().nullable(), + lifecycle: z.enum(["running", "stopped"]), + health: z.enum(["healthy", "unhealthy"]).nullable(), + exitCode: z.number().nullable(), + terminalId: z.string().nullable(), +}); + +const WorkspaceGitRuntimeSchema = z + .strictObject({ + currentBranch: z.string().nullable().optional(), + remoteUrl: z.string().nullable().optional(), + isPaseoOwnedWorktree: z.boolean().optional(), + isDirty: z.boolean().nullable().optional(), + aheadBehind: z.strictObject({ ahead: z.number(), behind: z.number() }).nullable().optional(), + aheadOfOrigin: z.number().nullable().optional(), + behindOfOrigin: z.number().nullable().optional(), + }) + .nullable() + .optional(); + +const StoredWorkspaceSchema = z.strictObject({ + id: z.string(), + projectId: z.string(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectCustomIconRevision: z.string().nullable(), + projectRootPath: z.string(), + workspaceDirectory: z.string(), + worktreeSlug: z.string().optional(), + projectKind: z.enum(["git", "non_git", "directory"]), + workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), + name: z.string(), + title: z.string().nullable(), + pinnedAt: z.string().nullable(), + status: z.enum(["needs_input", "failed", "running", "attention", "done"]), + statusEnteredAt: IsoDateSchema.nullable(), + activityAt: z.null(), + archivingAt: z.string().nullable(), + diffStat: z.strictObject({ additions: z.number(), deletions: z.number() }).nullable(), + scripts: z.array(WorkspaceScriptSchema), + gitRuntime: WorkspaceGitRuntimeSchema, + forge: z.string().optional(), +}); + +const StoredProjectSchema = z.strictObject({ + projectId: z.string(), + projectKey: z.string().optional(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git", "directory"]), +}); + +const StoredTimelineSchema = z.strictObject({ agentId: z.string(), - items: z.unknown(), + items: z.array(StoredTimelineItemSchema), }); -const StoredHostSchema = z.object({ +const StoredHostSchema = z.strictObject({ serverId: z.string(), agents: z.array(StoredAgentSchema), - workspaces: z.array(WorkspaceDescriptorPayloadSchema), - projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), - emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), + workspaces: z.array(StoredWorkspaceSchema), + projects: z.array(StoredProjectSchema), + emptyProjects: z.array(StoredProjectSchema), timeline: StoredTimelineSchema.nullable(), }); -const StoredCacheSchema = z.object({ +const StoredCacheSchema = z.strictObject({ version: z.literal(CACHE_VERSION), hosts: z.array(StoredHostSchema), }); type StoredAgent = z.infer; type StoredHost = z.infer; +type StoredTimelineItem = z.infer; +type StoredWorkspace = z.infer; +type StoredProject = z.infer; interface ReplicaInput { agent: Agent | undefined; @@ -67,104 +240,177 @@ interface ReplicaInput { export interface ReplicaCacheStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; } interface ReplicaCacheOptions { maxBytes?: number; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasString(value: Record, key: string): boolean { - return typeof value[key] === "string"; -} - -function isStreamItem(value: unknown): value is StreamItem { - if (!isRecord(value) || !hasString(value, "id") || !(value.timestamp instanceof Date)) { - return false; - } - switch (value.kind) { - case "user_message": - case "assistant_message": - return hasString(value, "text"); - case "thought": - return hasString(value, "text") && (value.status === "loading" || value.status === "ready"); - case "tool_call": - return isRecord(value.payload) && isRecord(value.payload.data); - case "todo_list": - return hasString(value, "provider") && Array.isArray(value.items); - case "activity_log": - return hasString(value, "message") && hasString(value, "activityType"); - case "compaction": - return value.status === "loading" || value.status === "completed"; - default: - return false; - } -} - -function encodeDates(value: unknown): unknown { - if (value instanceof Date) { - return { [DATE_TAG]: value.toISOString() }; - } - if (Array.isArray(value)) { - return value.map(encodeDates); - } - if (!isRecord(value)) { - return value; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, encodeDates(entry)])); -} - -function decodeDates(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(decodeDates); - } - if (!isRecord(value)) { - return value; - } - if (Object.keys(value).length === 1 && typeof value[DATE_TAG] === "string") { - const date = new Date(value[DATE_TAG]); - return Number.isNaN(date.getTime()) ? value : date; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, decodeDates(entry)])); -} - function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { if (!stored) { return null; } - const decoded = decodeDates(stored.items); - if (!Array.isArray(decoded) || !decoded.every(isStreamItem)) { - return null; - } return { agentId: stored.agentId, - items: decoded, + items: stored.items.map(deserializeTimelineItem), }; } +function timelineBase(item: StreamItem) { + return { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: item.timestamp.toISOString(), + }; +} + +function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { + const base = timelineBase(item); + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + case "tool_call": + return null; + } +} + +function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { + const base = { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: new Date(item.timestamp), + }; + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + } +} + function serializeAgent(agent: Agent): StoredAgent { - const snapshot: AgentSnapshotPayload = { + const snapshot = { id: agent.id, provider: agent.provider, cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.model, - ...(agent.features ? { features: agent.features } : {}), thinkingOptionId: agent.thinkingOptionId ?? null, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - capabilities: agent.capabilities, + ...(agent.activeTurn?.turnId + ? { + activeTurn: { + turnId: agent.activeTurn.turnId, + startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + }, + } + : {}), + capabilities: { + supportsStreaming: agent.capabilities.supportsStreaming, + supportsSessionPersistence: agent.capabilities.supportsSessionPersistence, + ...(agent.capabilities.supportsSessionListing !== undefined + ? { supportsSessionListing: agent.capabilities.supportsSessionListing } + : {}), + supportsDynamicModes: agent.capabilities.supportsDynamicModes, + supportsMcpServers: agent.capabilities.supportsMcpServers, + supportsReasoningStream: agent.capabilities.supportsReasoningStream, + supportsToolInvocations: agent.capabilities.supportsToolInvocations, + ...(agent.capabilities.supportsRewindConversation !== undefined + ? { supportsRewindConversation: agent.capabilities.supportsRewindConversation } + : {}), + ...(agent.capabilities.supportsRewindFiles !== undefined + ? { supportsRewindFiles: agent.capabilities.supportsRewindFiles } + : {}), + ...(agent.capabilities.supportsRewindBoth !== undefined + ? { supportsRewindBoth: agent.capabilities.supportsRewindBoth } + : {}), + }, currentModeId: agent.currentModeId, - availableModes: agent.availableModes, + availableModes: [], pendingPermissions: [], - persistence: agent.persistence, - ...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}), - ...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}), + persistence: null, ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, @@ -175,7 +421,7 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, - projectPlacement: agent.projectPlacement ?? null, + projectPlacement: null, lastActivityAt: agent.lastActivityAt.toISOString(), }; } @@ -188,7 +434,7 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } -function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptorPayload { +function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { id: workspace.id, projectId: workspace.projectId, @@ -208,15 +454,25 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor activityAt: null, archivingAt: workspace.archivingAt, diffStat: workspace.diffStat, - scripts: workspace.scripts, + scripts: workspace.scripts.map((script) => ({ + scriptName: script.scriptName, + type: script.type, + hostname: script.hostname, + port: script.port, + ...(script.localProxyUrl !== undefined ? { localProxyUrl: script.localProxyUrl } : {}), + ...(script.publicProxyUrl !== undefined ? { publicProxyUrl: script.publicProxyUrl } : {}), + proxyUrl: script.proxyUrl, + lifecycle: script.lifecycle, + health: script.health, + exitCode: script.exitCode, + terminalId: script.terminalId, + })), gitRuntime: workspace.gitRuntime, - githubRuntime: workspace.githubRuntime, forge: workspace.forge, - project: workspace.project, }; } -function serializeProject(project: ProjectDescriptor) { +function serializeProject(project: ProjectDescriptor): StoredProject { return { projectId: project.projectId, ...(project.projectKey ? { projectKey: project.projectKey } : {}), @@ -244,9 +500,9 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl (candidate) => candidate.workspaceDirectory === agent.cwd, )) : undefined; - const timeline = agentId + const timeline: AgentTimelineState = agentId ? selectAgentTimelineState(session, agentId) - : { status: "cold" as const }; + : { status: "cold" }; return { agent, workspace, @@ -256,9 +512,10 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl } function serializeHost(serverId: string, input: ReplicaInput): StoredHost { - const items = input.timelineItems?.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); + const items = input.timelineItems + ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) + .map(serializeTimelineItem) + .filter((item) => item !== null); return { serverId, agents: input.agent ? [serializeAgent(input.agent)] : [], @@ -269,7 +526,7 @@ function serializeHost(serverId: string, input: ReplicaInput): StoredHost { input.agent && items ? { agentId: input.agent.id, - items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), + items: items.slice(-MAX_TIMELINE_ITEMS), } : null, }; @@ -360,10 +617,14 @@ export class ReplicaCache { try { parsed = JSON.parse(raw); } catch { + await this.clearInvalidCache(); return; } const cache = StoredCacheSchema.safeParse(parsed); - if (!cache.success) return; + if (!cache.success) { + await this.clearInvalidCache(); + return; + } for (const host of cache.data.hosts) { if (!this.activeServerIds.has(host.serverId)) { this.needsPersist = true; @@ -372,7 +633,13 @@ export class ReplicaCache { this.storedHosts.set(host.serverId, host); if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); } - if (this.buildBoundedPayload().evicted) this.needsPersist = true; + const bounded = this.buildBoundedPayload(); + if (!bounded) { + await this.clearInvalidCache(); + this.storedHosts.clear(); + return; + } + if (bounded.evicted) this.needsPersist = true; for (const host of this.storedHosts.values()) { useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); const session = useSessionStore.getState().sessions[host.serverId]; @@ -427,11 +694,15 @@ export class ReplicaCache { this.persistTimer = null; } this.captureSessions(); - const { payload } = this.buildBoundedPayload(); + const bounded = this.buildBoundedPayload(); this.needsPersist = false; + if (!bounded) { + await this.clearInvalidCache(); + return; + } const write = this.writeQueue .catch(() => undefined) - .then(() => this.storage.setItem(STORAGE_KEY, payload)); + .then(() => this.storage.setItem(STORAGE_KEY, bounded.payload)); this.writeQueue = write; await write.catch(() => undefined); } @@ -461,24 +732,35 @@ export class ReplicaCache { return true; } - private buildBoundedPayload(): { payload: string; evicted: boolean } { + private buildBoundedPayload(): { payload: string; evicted: boolean } | null { let evicted = false; let payload = this.serialize(); + if (payload === null) return null; while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { const oldestServerId = this.storedHosts.keys().next().value; if (oldestServerId === undefined) break; this.storedHosts.delete(oldestServerId); evicted = true; payload = this.serialize(); + if (payload === null) return null; } return { payload, evicted }; } - private serialize(): string { - return JSON.stringify({ + private serialize(): string | null { + const cache = StoredCacheSchema.safeParse({ version: CACHE_VERSION, hosts: Array.from(this.storedHosts.values()), }); + return cache.success ? JSON.stringify(cache.data) : null; + } + + private async clearInvalidCache(): Promise { + try { + await this.storage.removeItem(STORAGE_KEY); + } catch { + // A storage failure must not turn invalid cached data into application state. + } } private schedulePersist(): void { diff --git a/packages/app/src/storage/validated-persist-storage.test.ts b/packages/app/src/storage/validated-persist-storage.test.ts new file mode 100644 index 000000000..7255086e3 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { StateStorage } from "zustand/middleware"; +import { createValidatedPersistStorage } from "./validated-persist-storage"; + +class MemoryStorage implements StateStorage { + readonly values = new Map(); + + getItem(name: string): string | null { + return this.values.get(name) ?? null; + } + + setItem(name: string, value: string): void { + this.values.set(name, value); + } + + removeItem(name: string): void { + this.values.delete(name); + } +} + +const StateSchema = z.strictObject({ enabled: z.boolean() }); + +describe("createValidatedPersistStorage", () => { + it.each([ + ["malformed JSON", "{"], + ["invalid state", JSON.stringify({ state: { enabled: "yes" }, version: 1 })], + ["unknown state fields", JSON.stringify({ state: { enabled: true, extra: true }, version: 1 })], + [ + "unknown envelope fields", + JSON.stringify({ state: { enabled: true }, version: 1, extra: true }), + ], + ])("clears %s", async (_label, stored) => { + const backing = new MemoryStorage(); + backing.values.set("settings", stored); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toBeNull(); + expect(backing.values.has("settings")).toBe(false); + }); + + it("returns schema-validated state", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { enabled: true }, version: 1 })); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toEqual({ + state: { enabled: true }, + version: 1, + }); + }); + + it("clears a value rejected by the schema before writing", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { count: 1 } })); + const storage = createValidatedPersistStorage( + backing, + z.strictObject({ count: z.number().finite() }), + ); + + await storage.setItem("settings", { state: { count: Number.NaN } }); + + expect(backing.values.has("settings")).toBe(false); + }); +}); diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts new file mode 100644 index 000000000..07459f864 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import type { PersistStorage, StateStorage } from "zustand/middleware"; + +export function createValidatedPersistStorage( + backingStorage: StateStorage, + stateSchema: z.ZodType, +): PersistStorage { + const envelopeSchema = z.strictObject({ + state: stateSchema, + version: z.number().int().nonnegative().optional(), + }); + + return { + getItem: async (name) => { + const raw = await backingStorage.getItem(name); + if (raw === null) return null; + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await backingStorage.removeItem(name); + return null; + } + + const result = envelopeSchema.safeParse(decoded); + if (!result.success) { + await backingStorage.removeItem(name); + return null; + } + return result.data; + }, + setItem: async (name, value) => { + const result = envelopeSchema.safeParse(value); + if (!result.success) { + await backingStorage.removeItem(name); + return; + } + await backingStorage.setItem(name, JSON.stringify(result.data)); + }, + removeItem: (name) => backingStorage.removeItem(name), + }; +} diff --git a/packages/app/src/storage/validated-storage.test.ts b/packages/app/src/storage/validated-storage.test.ts new file mode 100644 index 000000000..8ebd7339d --- /dev/null +++ b/packages/app/src/storage/validated-storage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "./validated-storage"; + +class MemoryStorage { + readonly values = new Map(); + + async getItem(key: string): Promise { + return this.values.get(key) ?? null; + } + + async removeItem(key: string): Promise { + this.values.delete(key); + } +} + +describe("validated storage reads", () => { + it.each([ + ["malformed JSON", "{"], + ["wrong field type", JSON.stringify({ enabled: "yes" })], + ["unknown field", JSON.stringify({ enabled: true, surprise: true })], + ])("clears invalid JSON payloads: %s", async (_label, raw) => { + const storage = new MemoryStorage(); + storage.values.set("settings", raw); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toBeNull(); + expect(storage.values.has("settings")).toBe(false); + }); + + it("clears invalid scalar values", async () => { + const storage = new MemoryStorage(); + storage.values.set("channel", "nightly"); + + await expect( + readValidatedString(storage, "channel", z.enum(["stable", "beta"])), + ).resolves.toBeNull(); + expect(storage.values.has("channel")).toBe(false); + }); + + it("returns only schema-validated values", async () => { + const storage = new MemoryStorage(); + storage.values.set("settings", JSON.stringify({ enabled: true })); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toEqual({ enabled: true }); + expect(storage.values.has("settings")).toBe(true); + }); +}); diff --git a/packages/app/src/storage/validated-storage.ts b/packages/app/src/storage/validated-storage.ts new file mode 100644 index 000000000..b47c92e49 --- /dev/null +++ b/packages/app/src/storage/validated-storage.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export interface ValidatedStorage { + getItem(key: string): Promise; + removeItem(key: string): Promise; +} + +async function clearInvalidValue(storage: ValidatedStorage, key: string): Promise { + try { + await storage.removeItem(key); + } catch { + // Invalid persisted data must never cross the boundary, even when cleanup fails. + } +} + +export async function readValidatedString( + storage: ValidatedStorage, + key: string, + schema: z.ZodType, +): Promise { + const raw = await storage.getItem(key); + if (raw === null) return null; + const result = schema.safeParse(raw); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} + +export async function readValidatedJson( + storage: ValidatedStorage, + key: string, + schema: z.ZodType, +): Promise { + const raw = await storage.getItem(key); + if (raw === null) return null; + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await clearInvalidValue(storage, key); + return null; + } + const result = schema.safeParse(decoded); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 528517041..4b89ee98c 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types"; import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -27,8 +27,14 @@ import { type DraftRecord, type DraftStoreState, } from "./state"; -import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { + migrateDraftInput, + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { createDraftPersistStorage } from "./persistence"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -58,7 +64,7 @@ type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; let gcScheduled = false; const draftPersistStorage = createDraftPersistStorage( - createJSONStorage(() => AsyncStorage), + createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema), ); export function flushDraftPersistStorage(): Promise { @@ -414,12 +420,11 @@ export const useDraftStore = create()( version: DRAFT_STORE_VERSION, storage: draftPersistStorage, partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), - migrate: (persistedState) => { - return migratePersistedState(persistedState, { + migrate: (state) => + migratePersistedState(state, { migrateLegacyImages, nowMs: Date.now(), - }); - }, + }), onRehydrateStorage: () => { return () => { void migrateAllLegacyDrafts(); diff --git a/packages/app/src/stores/draft-store/migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts index 6ef20097f..218e74c4e 100644 --- a/packages/app/src/stores/draft-store/migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; +import { + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { isAttachmentMetadata, type DraftRecord } from "./state"; const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => images.filter(isAttachmentMetadata); +function createMemoryStorage(): StateStorage & { values: Map } { + const values = new Map(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + function activeDraft( text: string, updatedAt: number, @@ -96,6 +116,30 @@ function workspaceReviewAttachment(): Extract { + it("keeps a supported legacy draft for migration", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { + "draft:unsent": { + text: "do not lose this", + attachments: [], + }, + }, + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: legacyState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); + + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1, + }); + + expect(migrated.drafts["draft:unsent"]?.input.text).toBe("do not lose this"); + expect(backing.values.has("paseo-drafts")).toBe(true); + }); + it("promotes the newest legacy New Workspace draft into the singleton surface", async () => { const forkDraft = activeDraft("fork context", 1700000000003); const agentDraft = activeDraft("agent prompt", 1700000000004); @@ -242,25 +286,35 @@ describe("draft-store migration", () => { }); }); - it("rejects workspace review attachments from migrated draft attachments", async () => { - const migrated = await migratePersistedState( - { - drafts: { - "agent:server:agent": { - input: { - text: "hello", - attachments: [workspaceReviewAttachment()], - }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 2, + it("drops a legacy workspace review attachment without deleting its draft", async () => { + const backing = createMemoryStorage(); + const persistedState = { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + attachments: [workspaceReviewAttachment()], }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, }, - createModalDraft: null, }, - { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, - ); + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: persistedState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); - expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]); + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1700000000002, + }); + + expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ + text: "hello", + attachments: [], + }); + expect(backing.values.has("paseo-drafts")).toBe(true); }); }); diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts index fc21087ca..7d1756fc7 100644 --- a/packages/app/src/stores/draft-store/migration.ts +++ b/packages/app/src/stores/draft-store/migration.ts @@ -1,11 +1,12 @@ import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys"; +import { z } from "zod"; import { - isAttachmentMetadata, - isLegacyDraftImage, - isUserComposerAttachment, + AttachmentMetadataSchema, + LegacyDraftImageSchema, normalizeAttachmentMetadata, normalizeComposerAttachment, + UserComposerAttachmentSchema, type CanonicalDraftInput, type DraftLifecycleState, type DraftRecord, @@ -13,28 +14,108 @@ import { type PersistedDraftImage, } from "./state"; +const LegacyPullRequestItemSchema = z.strictObject({ + kind: z.literal("pr"), + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}); +const LegacyGithubPrAttachmentSchema = z.strictObject({ + kind: z.literal("github_pr"), + item: LegacyPullRequestItemSchema, + owner: z.literal("new-workspace-picker").optional(), +}); +const LegacyReviewAttachmentContextLineSchema = z.strictObject({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), +}); +const LegacyReviewAttachmentSchema = z.strictObject({ + kind: z.literal("review"), + reviewDraftKey: z.string(), + commentCount: z.number().int().nonnegative(), + attachment: z.strictObject({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z.strictObject({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z.strictObject({ + hunkHeader: z.string(), + targetLine: LegacyReviewAttachmentContextLineSchema, + lines: z.array(LegacyReviewAttachmentContextLineSchema), + }), + }), + ), + }), +}); +const PersistedComposerAttachmentSchema = z.union([ + UserComposerAttachmentSchema, + LegacyGithubPrAttachmentSchema, + LegacyReviewAttachmentSchema, +]); +type PersistedComposerAttachment = z.infer; +const LegacyAttachmentMetadataSchema = AttachmentMetadataSchema.extend({ + previewUri: z.string(), +}); +type PersistedImage = PersistedDraftImage | z.infer; + +const RawDraftInputSchema = z.strictObject({ + text: z.string().optional(), + attachments: z.array(PersistedComposerAttachmentSchema).optional(), + images: z + .array( + z.union([AttachmentMetadataSchema, LegacyAttachmentMetadataSchema, LegacyDraftImageSchema]), + ) + .optional(), + cwd: z.string().optional(), +}); +const DraftLifecycleSchema = z.enum(["active", "abandoned", "sent"]); +const NestedDraftRecordSchema = z.strictObject({ + input: RawDraftInputSchema, + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const FlatDraftRecordSchema = RawDraftInputSchema.extend({ + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const PersistedDraftRecordSchema = z.union([NestedDraftRecordSchema, FlatDraftRecordSchema]); +export const PersistedDraftStoreSchema = z.strictObject({ + drafts: z.record(z.string(), PersistedDraftRecordSchema).optional(), + createModalDraft: PersistedDraftRecordSchema.nullable().optional(), +}); +type PersistedDraftRecord = z.infer; + export type MigrateLegacyImages = ( images: readonly PersistedDraftImage[], ) => Promise; -function normalizePersistedImage(value: unknown): PersistedDraftImage | null { - if (isAttachmentMetadata(value)) { +function normalizePersistedImage(value: PersistedImage): PersistedDraftImage { + if ("id" in value) { return normalizeAttachmentMetadata(value); } - if (isLegacyDraftImage(value)) { - return { - uri: value.uri, - ...(value.mimeType ? { mimeType: value.mimeType } : {}), - }; - } - return null; -} - -function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { - if (!isUserComposerAttachment(value)) { - return null; - } - return normalizeComposerAttachment(value); + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; } function legacyImagesToAttachments( @@ -46,24 +127,36 @@ function legacyImagesToAttachments( })); } +function normalizePersistedComposerAttachment( + attachment: PersistedComposerAttachment, +): UserComposerAttachment | null { + if (attachment.kind === "review") { + return null; + } + if (attachment.kind === "github_pr" && attachment.item.kind === "pr") { + return { + kind: "github_pr", + item: { ...attachment.item, kind: "change_request" }, + ...(attachment.owner ? { owner: attachment.owner } : {}), + }; + } + const result = UserComposerAttachmentSchema.safeParse(attachment); + if (!result.success) { + throw new Error("Persisted composer attachment failed validation after migration"); + } + return normalizeComposerAttachment(result.data); +} + export async function migrateDraftInput( input: { rawInput: unknown }, ports: { migrateLegacyImages: MigrateLegacyImages }, ): Promise { - const rawInput = - input.rawInput && typeof input.rawInput === "object" - ? (input.rawInput as Record) - : {}; - const attachments = Array.isArray(rawInput.attachments) - ? rawInput.attachments - .map((entry) => normalizePersistedComposerAttachment(entry)) - .filter((entry): entry is UserComposerAttachment => entry !== null) - : []; - const legacyImages = Array.isArray(rawInput.images) - ? rawInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : []; + const result = RawDraftInputSchema.safeParse(input.rawInput); + const rawInput = result.success ? result.data : {}; + const attachments = (rawInput.attachments ?? []) + .map(normalizePersistedComposerAttachment) + .filter((attachment): attachment is UserComposerAttachment => attachment !== null); + const legacyImages = (rawInput.images ?? []).map(normalizePersistedImage); const migratedImages = await ports.migrateLegacyImages(legacyImages); return { @@ -72,30 +165,29 @@ export async function migrateDraftInput( }; } -function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { - if (lifecycle === "sent" || lifecycle === "abandoned") { - return lifecycle as DraftLifecycleState; - } - return "active"; +function resolvePersistedLifecycle( + lifecycle: DraftLifecycleState | undefined, +): DraftLifecycleState { + return lifecycle ?? "active"; } -function extractRawInput(record: Record): unknown { - if ("input" in record && record.input && typeof record.input === "object") { +function extractRawInput(record: PersistedDraftRecord): z.infer { + if ("input" in record) { return record.input; } return record; } async function buildMigratedDraftRecord( - parsed: Record, + parsed: PersistedDraftRecord, ports: { migrateLegacyImages: MigrateLegacyImages }, nowMs: number, ): Promise { return { input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), lifecycle: resolvePersistedLifecycle(parsed.lifecycle), - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, - version: typeof parsed.version === "number" ? parsed.version : 1, + updatedAt: parsed.updatedAt ?? nowMs, + version: parsed.version ?? 1, }; } @@ -143,30 +235,17 @@ export async function migratePersistedState( state: unknown, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, ): Promise { - const input = (state ?? {}) as { - drafts?: Record; - createModalDraft?: unknown; - }; + const result = PersistedDraftStoreSchema.safeParse(state); + const input = result.success ? result.data : {}; const nextDrafts: Record = {}; for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { - if (!rawRecord || typeof rawRecord !== "object") { - continue; - } - nextDrafts[draftKey] = await buildMigratedDraftRecord( - rawRecord as Record, - ports, - ports.nowMs, - ); + nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord, ports, ports.nowMs); } let createModalDraft: DraftRecord | null = null; - if (input.createModalDraft && typeof input.createModalDraft === "object") { - createModalDraft = await buildMigratedDraftRecord( - input.createModalDraft as Record, - ports, - ports.nowMs, - ); + if (input.createModalDraft) { + createModalDraft = await buildMigratedDraftRecord(input.createModalDraft, ports, ports.nowMs); } return { diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts index ab7c374c5..f56b67130 100644 --- a/packages/app/src/stores/draft-store/persistence.ts +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -1,7 +1,6 @@ import type { PersistStorage } from "zustand/middleware"; export const DRAFT_PERSIST_INTERVAL_MS = 200; - export interface PersistenceScheduler { now: () => number; schedule: (callback: () => void, delayMs: number) => unknown; @@ -12,10 +11,28 @@ export interface DraftPersistStorage extends PersistStorage { flush: () => Promise; } +let nextSystemTimerId = 0; +const systemTimers = new Map>(); const systemScheduler: PersistenceScheduler = { now: Date.now, - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - cancel: (handle) => clearTimeout(handle as ReturnType), + schedule: (callback, delayMs) => { + const id = nextSystemTimerId++; + systemTimers.set( + id, + setTimeout(() => { + systemTimers.delete(id); + callback(); + }, delayMs), + ); + return id; + }, + cancel: (handle) => { + if (typeof handle !== "number") return; + const timer = systemTimers.get(handle); + if (timer === undefined) return; + clearTimeout(timer); + systemTimers.delete(handle); + }, }; export function createDraftPersistStorage( diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index a2314ec3a..ee53825ed 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,12 +3,7 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; -import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file"; -import { - ForgeSearchItemSchema, - GitHubSearchItemSchema, - UploadedFileAttachmentSchema, -} from "@getpaseo/protocol/messages"; +import { z } from "zod"; export const DRAFT_STORE_VERSION = 5; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; @@ -41,26 +36,95 @@ export interface DraftStoreState { createModalDraft: DraftRecord | null; } +export const AttachmentMetadataSchema = z.strictObject({ + id: z.string(), + mimeType: z.string(), + storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), + storageKey: z.string(), + fileName: z.string().nullable().optional(), + byteSize: z.number().nullable().optional(), + createdAt: z.number(), +}) satisfies z.ZodType; +export const LegacyDraftImageSchema: z.ZodType = z.strictObject({ + uri: z.string(), + mimeType: z.string().optional(), +}); +const UploadedFileSchema = z.strictObject({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), +}); +const ForgeItemFields = { + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}; +const IssueItemSchema = z.strictObject({ kind: z.literal("issue"), ...ForgeItemFields }); +const ChangeRequestItemSchema = z.strictObject({ + kind: z.literal("change_request"), + ...ForgeItemFields, +}); +export const UserComposerAttachmentSchema: z.ZodType = z.discriminatedUnion( + "kind", + [ + z.strictObject({ kind: z.literal("image"), metadata: AttachmentMetadataSchema }), + z.strictObject({ kind: z.literal("file"), attachment: UploadedFileSchema }), + z.strictObject({ + kind: z.literal("workspace_file"), + path: z.string(), + selection: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("whole_file") }), + z.strictObject({ + kind: z.literal("line_range"), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + }), + ]), + }), + z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), + z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), + z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), + z.strictObject({ + kind: z.literal("github_pr"), + item: ChangeRequestItemSchema, + owner: z.literal(NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER).optional(), + }), + ], +); +export const CanonicalDraftInputSchema = z.strictObject({ + text: z.string(), + attachments: z.array(UserComposerAttachmentSchema), + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. + cwd: z.string().optional(), +}); +const DraftRecordSchema: z.ZodType = z.strictObject({ + input: CanonicalDraftInputSchema, + lifecycle: z.enum(["active", "abandoned", "sent"]), + updatedAt: z.number(), + version: z.number().int().positive(), +}); +export const DraftStoreStateSchema: z.ZodType = z.strictObject({ + drafts: z.record(z.string(), DraftRecordSchema), + createModalDraft: DraftRecordSchema.nullable(), +}); + export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.mimeType === "string" && - typeof record.storageType === "string" && - typeof record.storageKey === "string" && - typeof record.createdAt === "number" - ); + return AttachmentMetadataSchema.safeParse(value).success; } export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return typeof record.uri === "string"; + return LegacyDraftImageSchema.safeParse(value).success; } export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { @@ -80,39 +144,7 @@ export function normalizeAttachmentMetadata(image: AttachmentMetadata): Attachme } export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - if (record.kind === "image") { - const metadata = record.metadata; - return isAttachmentMetadata(metadata); - } - if (record.kind === "workspace_file") { - return isWorkspaceFileComposerAttachment(value); - } - if (record.kind === "file") { - return UploadedFileAttachmentSchema.safeParse(record.attachment).success; - } - if ( - record.kind !== "forge_issue" && - record.kind !== "forge_change_request" && - record.kind !== "github_issue" && - record.kind !== "github_pr" - ) { - return false; - } - if ( - record.kind === "github_pr" && - record.owner !== undefined && - record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ) { - return false; - } - return ( - ForgeSearchItemSchema.safeParse(record.item).success || - GitHubSearchItemSchema.safeParse(record.item).success - ); + return UserComposerAttachmentSchema.safeParse(value).success; } export function normalizeComposerAttachment( @@ -131,33 +163,11 @@ export function normalizeComposerAttachment( selection: attachment.selection, }; } - if (attachment.kind === "github_pr") { - const item = - (attachment.item as { kind: string }).kind === "pr" - ? { ...attachment.item, kind: "change_request" as const } - : attachment.item; - return { - kind: "github_pr", - item, - ...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ? { owner: attachment.owner } - : {}), - }; - } return attachment; } export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { - if (!value || typeof value !== "object") { - return false; - } - const input = value as Record; - // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. - return ( - typeof input.text === "string" && - Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) - ); + return CanonicalDraftInputSchema.safeParse(value).success; } export function toDraftInputIfReady( diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts index 06f4dceec..e5fc979be 100644 --- a/packages/app/src/stores/last-workspace-selection.test.ts +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -20,12 +20,19 @@ class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage this.saved = value; } + async clear(): Promise { + this.saved = null; + } + finishHydrationWith(selection: ActiveWorkspaceSelection | null) { this.finishRead(selection ? JSON.stringify(selection) : null); } getSavedSelection(): ActiveWorkspaceSelection | null { - return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null; + if (!this.saved) return null; + const parsed: unknown = JSON.parse(this.saved); + if (!parsed || typeof parsed !== "object") return null; + return this.saved ? JSON.parse(this.saved) : null; } } diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts index f7bd3bc2e..6e689e0a4 100644 --- a/packages/app/src/stores/last-workspace-selection.ts +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface ActiveWorkspaceSelection { serverId: string; workspaceId: string; @@ -8,19 +10,17 @@ export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route- export interface LastWorkspaceSelectionStorage { read(): Promise; write(value: string): Promise; + clear(): Promise; } +const ActiveWorkspaceSelectionSchema: z.ZodType = z.strictObject({ + serverId: z.string().trim().min(1), + workspaceId: z.string().trim().min(1), +}); + function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return null; - } - const record = input as Record; - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; - if (!serverId || !workspaceId) { - return null; - } - return { serverId, workspaceId }; + const result = ActiveWorkspaceSelectionSchema.safeParse(input); + return result.success ? result.data : null; } function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { @@ -75,6 +75,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio .then((stored) => { if (revision === hydrationRevision) { selection = parseStoredWorkspaceSelection(stored); + if (stored !== null && selection === null) { + void storage.clear().catch(() => {}); + } } return undefined; }) diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 6cc819193..9adcd6c6b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -25,6 +25,7 @@ export type { NavigateToWorkspaceInput } from "./navigation"; const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), + clear: () => AsyncStorage.removeItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), }; const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index baac870f4..6695ee5f4 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -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()( { 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, diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index a7e13288b..611c133b5 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -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; +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; 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); + const entries = Object.entries(state.explorerTabByCheckout); const next: Record = {}; 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 | 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) { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index abda2aac7..c4fb6239a 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -1,9 +1,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { type CollapsedProjectsState, + type PersistedCollapsedProjects, mergePersistedCollapsedProjects, + PersistedCollapsedProjectsSchema, serializeCollapsedProjects, setProjectCollapsed, togglePinnedCollapsed, @@ -19,7 +22,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState { } export const useSidebarCollapsedSectionsStore = create()( - persist( + persist( (set) => ({ collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set(), @@ -34,13 +37,10 @@ export const useSidebarCollapsedSectionsStore = create AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PersistedCollapsedProjectsSchema), partialize: (state) => serializeCollapsedProjects(state), merge: (persistedState, currentState) => - mergePersistedCollapsedProjects( - persistedState as { collapsedProjectKeys?: unknown } | undefined, - currentState, - ), + mergePersistedCollapsedProjects(persistedState, currentState), }, ), ); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index 051d7cc7c..f2d9258b6 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -52,13 +52,13 @@ describe("sidebar collapsed projects transitions", () => { expect(restored.collapsedPinned).toBe(true); }); - it("restores collapsed project keys from persisted preferences", () => { + it("rejects the complete value when a persisted project key is invalid", () => { const restored = mergePersistedCollapsedProjects( { collapsedProjectKeys: ["project-a", "project-b", 42] }, emptyState(), ); - expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + expect(Array.from(restored.collapsedProjectKeys)).toEqual([]); expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); }); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 3a03ab0ac..4d84205e1 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface CollapsedProjectsState { collapsedProjectKeys: Set; collapsedStatusGroupKeys: Set; @@ -5,11 +7,18 @@ export interface CollapsedProjectsState { } export interface PersistedCollapsedProjects { - collapsedProjectKeys?: unknown; - collapsedStatusGroupKeys?: unknown; - collapsedPinned?: unknown; + collapsedProjectKeys?: string[]; + collapsedStatusGroupKeys?: string[]; + collapsedPinned?: boolean; } +export const PersistedCollapsedProjectsSchema: z.ZodType = + z.strictObject({ + collapsedProjectKeys: z.array(z.string()).optional(), + collapsedStatusGroupKeys: z.array(z.string()).optional(), + collapsedPinned: z.boolean().optional(), + }); + export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState { return { ...state, collapsedPinned: !state.collapsedPinned }; } @@ -67,22 +76,21 @@ export function serializeCollapsedProjects(state: CollapsedProjectsState): { } export function mergePersistedCollapsedProjects( - persisted: PersistedCollapsedProjects | undefined, + persistedValue: unknown, current: S, ): S { - if ( - !persisted?.collapsedProjectKeys && - !persisted?.collapsedStatusGroupKeys && - persisted?.collapsedPinned === undefined - ) { + const result = PersistedCollapsedProjectsSchema.safeParse(persistedValue); + if (!result.success) { return current; } - const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys); - const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys); - const restoredPinned = - typeof persisted.collapsedPinned === "boolean" - ? persisted.collapsedPinned - : current.collapsedPinned; + const persisted = result.data; + const restoredProjects = deserializeCollapsedKeys( + persisted.collapsedProjectKeys ?? Array.from(current.collapsedProjectKeys), + ); + const restoredStatusGroups = deserializeCollapsedKeys( + persisted.collapsedStatusGroupKeys ?? Array.from(current.collapsedStatusGroupKeys), + ); + const restoredPinned = persisted.collapsedPinned ?? current.collapsedPinned; if ( areSetsEqual(current.collapsedProjectKeys, restoredProjects) && areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) && @@ -98,11 +106,8 @@ export function mergePersistedCollapsedProjects { - if (!Array.isArray(value)) { - return new Set(); - } - return new Set(value.filter((key): key is string => typeof key === "string")); +function deserializeCollapsedKeys(value: string[]): Set { + return new Set(value); } function areSetsEqual(left: Set, right: Set): boolean { diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 307e432c0..bd6bdbbf3 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface SidebarOrderStoreState { projectOrder: string[]; @@ -18,6 +20,14 @@ interface SidebarOrderPersistedState { workspaceOrderByServerAndProject?: Record; } +const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); +const SidebarOrderPersistedStateSchema = z.strictObject({ + projectOrder: z.array(z.string()).optional(), + workspaceOrderByProject: StringArrayRecordSchema.optional(), + projectOrderByServerId: StringArrayRecordSchema.optional(), + workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), +}); + interface SidebarWorkspaceOrderScope { serverId: string; projectViewKey: string; @@ -71,11 +81,11 @@ export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; workspaceOrderByProject: Record; } { - const state = persistedState as SidebarOrderPersistedState | undefined; - - if (!state) { + const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { projectOrder: [], workspaceOrderByProject: {} }; } + const state: SidebarOrderPersistedState = result.data; const projectOrder = normalizeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); @@ -135,7 +145,7 @@ export const useSidebarOrderStore = create()( }), { name: "sidebar-project-workspace-order", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), partialize: (state) => ({ projectOrder: state.projectOrder, workspaceOrderByProject: state.workspaceOrderByProject, diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 52d2198d9..e8d77b0a0 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type SidebarGroupMode = "project" | "status"; @@ -23,51 +25,55 @@ interface SidebarViewPersistedState { hostFilters: string[]; } -function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { - return value === "project" || value === "status"; -} +const SidebarGroupModeSchema = z.enum(["project", "status"]); +const SidebarViewPersistedStateSchema = z.strictObject({ + groupMode: SidebarGroupModeSchema.optional(), + hostFilters: z.array(z.string()).optional(), + hostFilter: z.string().nullable().optional(), + groupModeByServerId: z.record(z.string(), SidebarGroupModeSchema).optional(), +}); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +type SidebarViewStorageState = z.infer; -function readLegacyGroupMode(persistedState: Record): SidebarGroupMode | null { +function readLegacyGroupMode(persistedState: SidebarViewStorageState): SidebarGroupMode | null { const groupModeByServerId = persistedState.groupModeByServerId; - if (!isRecord(groupModeByServerId)) { + if (!groupModeByServerId) { return null; } - const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode); + const modes = Object.values(groupModeByServerId); if (modes.length === 0) return null; return modes.includes("status") ? "status" : "project"; } // Reads the host filter from any persisted shape: the current `hostFilters` array, or the // pre-v2 single `hostFilter` string (null/absent meant "all hosts"). -function readHostFilters(persistedState: Record): string[] { +function readHostFilters(persistedState: SidebarViewStorageState): string[] { const hostFilters = persistedState.hostFilters; - if (Array.isArray(hostFilters)) { - return hostFilters.filter((value): value is string => typeof value === "string"); + if (hostFilters) { + return hostFilters; } // COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted // sidebar state (a single `hostFilter` string) has aged out. const legacyHostFilter = persistedState.hostFilter; - return typeof legacyHostFilter === "string" ? [legacyHostFilter] : []; + return legacyHostFilter ? [legacyHostFilter] : []; } export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { - if (!isRecord(persistedState)) { + const result = SidebarViewPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { groupMode: "project", hostFilters: [] }; } + const state = result.data; - const legacyGroupMode = readLegacyGroupMode(persistedState); + const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { return { groupMode: legacyGroupMode, hostFilters: [] }; } return { - groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", - hostFilters: readHostFilters(persistedState), + groupMode: state.groupMode ?? "project", + hostFilters: readHostFilters(state), }; } @@ -116,7 +122,10 @@ export const useSidebarViewStore = create()( { name: SIDEBAR_VIEW_STORAGE_KEY, version: SIDEBAR_VIEW_STORE_VERSION, - storage: createJSONStorage(createSidebarViewStorage), + storage: createValidatedPersistStorage( + createSidebarViewStorage(), + SidebarViewPersistedStateSchema, + ), partialize: (state) => ({ groupMode: state.groupMode, hostFilters: state.hostFilters, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 24d162eae..4951d2868 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -15,6 +15,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { }; }); +import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { collectAllPanes, @@ -316,6 +317,24 @@ describe("workspace-layout-store actions", () => { }); }); + it("keeps layouts written before split sizes were persisted", async () => { + const legacyLayout = createDefaultLayout(); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { layoutByWorkspace: { legacy: legacyLayout } }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout); + expect(restored.getState().splitSizesByWorkspace).toEqual({}); + await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); + }); + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index dee6c5869..8631756a6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,7 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { defaultWorkspaceLayoutIds, @@ -42,6 +43,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export { collectAllPanes, @@ -123,6 +125,83 @@ interface WorkspaceFocusRestorationState { const MAX_TREE_DEPTH = 4; +const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), +}); +const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("draft"), + draftId: z.string(), + setup: WorkspaceDraftTabSetupStorageSchema.optional(), + }), + z.strictObject({ kind: z.literal("agent"), agentId: z.string() }), + z.strictObject({ + kind: z.literal("provider_subagent"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), + z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), + z.strictObject({ + kind: z.literal("file"), + path: z.string(), + lineStart: z.number().int().positive().optional(), + lineEnd: z.number().int().positive().optional(), + }), + z.strictObject({ + kind: z.literal("working_diff"), + focusPath: z.string().optional(), + focusRequestId: z.number().optional(), + // COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them. + mode: z.enum(["uncommitted", "base"]).optional(), + baseRef: z.string().nullable().optional(), + ignoreWhitespace: z.boolean().optional(), + }), + z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), + z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), +]); +const WorkspaceTabStorageSchema = z.strictObject({ + tabId: z.string(), + target: WorkspaceTabTargetStorageSchema, + createdAt: z.number(), +}); +const SplitNodeStorageSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("pane"), + pane: z.strictObject({ + id: z.string(), + tabIds: z.array(z.string()), + focusedTabId: z.string().nullable(), + tabs: z.array(WorkspaceTabStorageSchema).optional(), + }), + }), + z.strictObject({ + kind: z.literal("group"), + group: z.strictObject({ + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(SplitNodeStorageSchema), + sizes: z.array(z.number()), + }), + }), + ]), +); +const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject({ + root: SplitNodeStorageSchema, + focusedPaneId: z.string().nullable(), + parentTabIdByTabId: z.record(z.string(), z.string()).optional(), +}); +const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), + splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), +}); + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -942,7 +1021,7 @@ export function createWorkspaceLayoutStore( { name: "workspace-layout-state", version: 1, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), partialize: (state) => { const layoutByWorkspace: Record = {}; for (const key in state.layoutByWorkspace) { @@ -957,6 +1036,17 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: state.splitSizesByWorkspace, }; }, + merge: (persistedState, currentState) => { + const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); + if (!result.success) { + return currentState; + } + return { + ...currentState, + layoutByWorkspace: result.data.layoutByWorkspace, + splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, + }; + }, }, ), ); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 421f5868c..2105312e8 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -9,8 +9,9 @@ import { import { type HostAppearance, defaultHostAppearance, - normalizeStoredHostAppearance, + HostAppearanceSchema, } from "@/hosts/appearance"; +import { z } from "zod"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; @@ -293,54 +294,74 @@ export function connectionFromListen(listen: string): HostConnection | null { } } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const StoredHostConnectionSchema = z.discriminatedUnion("type", [ + z.strictObject({ + id: z.string().optional(), + type: z.literal("directTcp"), + endpoint: z.string(), + useTls: z.boolean().optional(), + password: z.string().optional(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directSocket"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directPipe"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("relay"), + relayEndpoint: z.string(), + useTls: z.boolean().optional(), + daemonPublicKeyB64: z.string(), + }), +]); +const StoredHostProfileSchema = z.strictObject({ + serverId: z.string().trim().min(1), + label: z.string().optional(), + appearance: HostAppearanceSchema.optional(), + lifecycle: z.strictObject({}).optional(), + connections: z.array(StoredHostConnectionSchema).min(1), + preferredConnectionId: z.string().nullable().optional(), + createdAt: z.string().datetime({ offset: true }).optional(), + updatedAt: z.string().datetime({ offset: true }).optional(), +}); +export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema); +type StoredHostConnection = z.infer; -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeStoredConnection(connection: unknown): HostConnection | null { - const record = toObjectRecord(connection); - if (!record) { - return null; - } - const type = record.type; - if (type === "directTcp") { +function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null { + if (connection.type === "directTcp") { try { - const endpoint = normalizeLoopbackToLocalhost( - normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""), - ); + const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); return DirectTcpHostConnectionSchema.parse({ id: `direct:${endpoint}`, type: "directTcp", endpoint, - useTls: record.useTls, - ...(typeof record.password === "string" ? { password: record.password } : {}), + useTls: connection.useTls, + ...(connection.password !== undefined ? { password: connection.password } : {}), }); } catch { return null; } } - if (type === "directSocket") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directSocket") { + const path = connection.path.trim(); return path ? { id: `socket:${path}`, type: "directSocket", path } : null; } - if (type === "directPipe") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directPipe") { + const path = connection.path.trim(); return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; } - if (type === "relay") { + if (connection.type === "relay") { try { - const relayEndpoint = normalizeHostPort( - typeof record.relayEndpoint === "string" ? record.relayEndpoint : "", - ); - const daemonPublicKeyB64 = ( - typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" - ).trim(); + const relayEndpoint = normalizeHostPort(connection.relayEndpoint); + const daemonPublicKeyB64 = connection.daemonPublicKeyB64.trim(); if (!daemonPublicKeyB64) return null; - const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined; + const useTls = connection.useTls; return { id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, type: "relay", @@ -357,17 +378,14 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { } export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { - const record = toObjectRecord(entry); - if (!record) { - return null; - } - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - if (!serverId) { + const result = StoredHostProfileSchema.safeParse(entry); + if (!result.success) { return null; } + const record = result.data; + const serverId = record.serverId; - const rawConnections = Array.isArray(record.connections) ? record.connections : []; - const connections = rawConnections + const connections = record.connections .map((connection) => normalizeStoredConnection(connection)) .filter((connection): connection is HostConnection => connection !== null); if (connections.length === 0) { @@ -375,12 +393,10 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { } const now = new Date().toISOString(); - const label = normalizeHostLabel( - typeof record.label === "string" ? record.label : null, - serverId, - ); + const label = normalizeHostLabel(record.label, serverId); const preferredConnectionId = - typeof record.preferredConnectionId === "string" && + record.preferredConnectionId !== null && + record.preferredConnectionId !== undefined && connections.some((connection) => connection.id === record.preferredConnectionId) ? record.preferredConnectionId : (connections[0]?.id ?? null); @@ -388,12 +404,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { return { serverId, label, - appearance: normalizeStoredHostAppearance(record.appearance), + appearance: record.appearance ?? defaultHostAppearance(), lifecycle: defaultLifecycle(), connections, preferredConnectionId, - createdAt: typeof record.createdAt === "string" ? record.createdAt : now, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : now, + createdAt: record.createdAt ?? now, + updatedAt: record.updatedAt ?? now, }; } diff --git a/packages/app/src/utils/client-id.test.ts b/packages/app/src/utils/client-id.test.ts index 647ea48b0..7184ab554 100644 --- a/packages/app/src/utils/client-id.test.ts +++ b/packages/app/src/utils/client-id.test.ts @@ -18,6 +18,9 @@ function inMemoryStorage(initial: Record = {}): InMemoryStorage storage.setCallCount += 1; items.set(key, value); }, + async removeItem(key) { + items.delete(key); + }, }; return storage; } diff --git a/packages/app/src/utils/client-id.ts b/packages/app/src/utils/client-id.ts index 52397e91b..d72111098 100644 --- a/packages/app/src/utils/client-id.ts +++ b/packages/app/src/utils/client-id.ts @@ -1,23 +1,20 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1"; export interface ClientIdStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface ClientIdResolver { getOrCreate(): Promise; } -function normalizeStoredClientId(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} +const ClientIdSchema = z.string().trim().min(1); export function createClientIdResolver(deps: { storage: ClientIdStorage; @@ -38,8 +35,7 @@ export function createClientIdResolver(deps: { } inFlight = (async () => { - const stored = await deps.storage.getItem(storageKey); - const existing = normalizeStoredClientId(stored); + const existing = await readValidatedString(deps.storage, storageKey, ClientIdSchema); if (existing) { cached = existing; return existing; @@ -61,7 +57,7 @@ export function createClientIdResolver(deps: { } function generateUuidFromGlobalCrypto(): string { - const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + const cryptoObj = globalThis.crypto; if (cryptoObj && typeof cryptoObj.randomUUID === "function") { return cryptoObj.randomUUID().replace(/-/g, ""); } diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts index 8fd3178a9..45a49c471 100644 --- a/packages/app/src/workspace-pins/store.ts +++ b/packages/app/src/workspace-pins/store.ts @@ -1,7 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface PinnedTargetsState { pinned: PinnedTabTarget[]; @@ -10,6 +12,15 @@ interface PinnedTargetsState { } const DEFAULT_PINNED_TARGETS: PinnedTabTarget[] = [{ kind: "terminal" }, { kind: "browser" }]; +const PinnedTabTargetSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("draft") }), + z.strictObject({ kind: z.literal("terminal") }), + z.strictObject({ kind: z.literal("browser") }), + z.strictObject({ kind: z.literal("profile"), profileId: z.string() }), +]); +const PinnedTargetsPersistedStateSchema = z.strictObject({ + pinned: z.array(PinnedTabTargetSchema), +}); function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] { const next = [...DEFAULT_PINNED_TARGETS]; @@ -22,7 +33,7 @@ function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] } export const usePinnedTargetsStore = create()( - persist( + persist>( (set, get) => ({ pinned: [], toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })), @@ -32,21 +43,21 @@ export const usePinnedTargetsStore = create()( name: "pinned-tab-targets", version: 1, merge: (persistedState, currentState) => { - const persisted = persistedState as Partial | null; + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); return { ...currentState, - ...persisted, - pinned: persisted?.pinned ?? applyDefaultPinnedTargets([]), + pinned: result.success ? result.data.pinned : applyDefaultPinnedTargets([]), }; }, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PinnedTargetsPersistedStateSchema), partialize: (state) => ({ pinned: state.pinned }), migrate: (persistedState, version) => { + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); + const pinned = result.success ? result.data.pinned : []; if (version === 0) { - const pinned = (persistedState as { pinned?: PinnedTabTarget[] } | null)?.pinned ?? []; return { pinned: applyDefaultPinnedTargets(pinned) }; } - return persistedState as PinnedTargetsState; + return { pinned }; }, }, ), diff --git a/packages/app/src/workspace-service-routes/store.test.ts b/packages/app/src/workspace-service-routes/store.test.ts index ba2bab1f2..719c4f9ac 100644 --- a/packages/app/src/workspace-service-routes/store.test.ts +++ b/packages/app/src/workspace-service-routes/store.test.ts @@ -32,7 +32,7 @@ describe("workspace service route preferences", () => { expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" }); }); - it("drops invalid route kinds from persisted storage", async () => { + it("clears the complete persisted value when one route kind is invalid", async () => { const storage = createMemoryStorage({ "workspace-service-route-preferences": JSON.stringify({ state: { byServerId: { desktop: "direct", broken: "unknown" } }, @@ -42,6 +42,7 @@ describe("workspace service route preferences", () => { const store = createWorkspaceServiceRoutePreferencesStore(storage); await store.persist.rehydrate(); - expect(store.getState().byServerId).toEqual({ desktop: "direct" }); + expect(store.getState().byServerId).toEqual({}); + expect(storage.values.has("workspace-service-route-preferences")).toBe(false); }); }); diff --git a/packages/app/src/workspace-service-routes/store.ts b/packages/app/src/workspace-service-routes/store.ts index 15479f08e..bf061effc 100644 --- a/packages/app/src/workspace-service-routes/store.ts +++ b/packages/app/src/workspace-service-routes/store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; interface WorkspaceServiceRoutePreferencesState { @@ -8,25 +10,19 @@ interface WorkspaceServiceRoutePreferencesState { setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void; } -function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind { - return value === "public" || value === "paseo" || value === "direct"; -} - -function sanitizePreferences(value: unknown): Record { - if (!value || typeof value !== "object") return {}; - const byServerId = (value as { byServerId?: unknown }).byServerId; - if (!byServerId || typeof byServerId !== "object") return {}; - - const result: Record = {}; - for (const [serverId, kind] of Object.entries(byServerId)) { - if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind; - } - return result; -} +const WorkspaceScriptLinkKindSchema = z.enum(["public", "paseo", "direct"]); +const WorkspaceServiceRoutePreferencesSchema = z.strictObject({ + byServerId: z.record(z.string(), WorkspaceScriptLinkKindSchema), +}); export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) { return create()( - persist( + persist< + WorkspaceServiceRoutePreferencesState, + [], + [], + z.infer + >( (set) => ({ byServerId: {}, setPreferredRoute: (serverId, kind) => @@ -35,12 +31,15 @@ export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorag { name: "workspace-service-route-preferences", version: 1, - storage: createJSONStorage(() => storage), + storage: createValidatedPersistStorage(storage, WorkspaceServiceRoutePreferencesSchema), partialize: (state) => ({ byServerId: state.byServerId }), - merge: (persistedState, currentState) => ({ - ...currentState, - byServerId: sanitizePreferences(persistedState), - }), + merge: (persistedState, currentState) => { + const result = WorkspaceServiceRoutePreferencesSchema.safeParse(persistedState); + return { + ...currentState, + byServerId: result.success ? result.data.byServerId : {}, + }; + }, }, ), );