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

* fix(app): validate persisted client state

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

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

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

* fix(app): migrate legacy agent preferences

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

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
Mohamed Boudra
2026-08-13 12:02:39 +02:00
committed by GitHub
parent c190fed27c
commit 36312352db
69 changed files with 1801 additions and 800 deletions
@@ -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);
@@ -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<void> {
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 },
);
}
@@ -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<string, unknown> |
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
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({
@@ -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<void> {
async function seedCodexDefaultPreferences(page: Page): Promise<void> {
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 {
@@ -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);
@@ -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,
},
);
}
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
label: "restart daemon",
nowIso,
}),
preferences: buildCreateAgentPreferences(serverId),
preferences: buildCreateAgentPreferences(),
},
);
}
+1 -1
View File
@@ -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 }) => {
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
preferences: buildCreateAgentPreferences(),
};
}
@@ -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<string, ProviderPreferences>;
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<string, ProviderPreferences>;
export function buildCreateAgentPreferences(serverId: string) {
export function buildCreateAgentPreferences() {
return {
serverId,
provider: "mock" as const,
provider: "mock",
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
};
} satisfies FormPreferences;
}
@@ -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<void>((resolve) => {
+1 -1
View File
@@ -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 }) => {
@@ -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();
}