Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@posthog/platform": "workspace:*",
"@posthog/shared": "workspace:*",
"@posthog/workspace-client": "workspace:*",
"fflate": "^0.8.2",
"fuse.js": "^7.1.0",
"inversify": "catalog:",
"reflect-metadata": "catalog:",
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/settings/settingsBackup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createSettingsBackup, parseSettingsBackup } from "./settingsBackup";

describe("settings backup", () => {
it("round trips categories and custom sounds", () => {
const bytes = createSettingsBackup(
{
notifications: {
desktopNotifications: true,
dockBadgeNotifications: true,
dockBounceNotifications: false,
toastNotifications: true,
completionSound: "none",
completionVolume: 42,
scaleSoundWithTaskLength: false,
},
},
[
{
id: "ding",
name: "Ding",
durationMs: 500,
dataUrl: "data:audio/webm;base64,AAEC",
},
],
"2026-07-25T00:00:00.000Z",
);
const backup = parseSettingsBackup(bytes);
expect(backup.manifest.categories.notifications?.completionVolume).toBe(42);
expect(backup.manifest.sounds[0]).toMatchObject({
id: "ding",
name: "Ding",
});
expect(backup.soundDataUrls.ding).toBe("data:audio/webm;base64,AAEC");
});

it("rejects data that is not an archive", () => {
expect(() => parseSettingsBackup(new Uint8Array([1, 2, 3]))).toThrow();
});
});
237 changes: 237 additions & 0 deletions packages/core/src/settings/settingsBackup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { unzipSync, zipSync } from "fflate";
import { z } from "zod";

const MAX_ARCHIVE_BYTES = 25_000_000;
const MAX_ENTRY_BYTES = 5_000_000;
const MANIFEST_PATH = "manifest.json";

export const settingsBackupCategorySchema = z.enum([
"notifications",
"agentDefaults",
"composer",
"terminal",
"system",
"appearance",
"onboarding",
]);

export type SettingsBackupCategory = z.infer<
typeof settingsBackupCategorySchema
>;

const soundSchema = z.object({
id: z.string().min(1).max(200),
name: z.string().min(1).max(60),
durationMs: z.number().nonnegative().max(5_300),
file: z.string().regex(/^sounds\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/),
mimeType: z.string().regex(/^audio\/[A-Za-z0-9.+-]+$/),
});

const categoriesSchema = z
.object({
notifications: z.object({
desktopNotifications: z.boolean(),
dockBadgeNotifications: z.boolean(),
dockBounceNotifications: z.boolean(),
toastNotifications: z.boolean(),
completionSound: z.union([
z.enum([
"none",
"guitar",
"danilo",
"revi",
"meep",
"meep-smol",
"bubbles",
"drop",
"knock",
"ring",
"shoot",
"slide",
"switch",
"wilhelm",
"icq",
"random-all",
"random-custom",
]),
z.string().regex(/^custom:[A-Za-z0-9_-]+$/),
]),
Comment on lines +38 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

completionSound schema is missing the "msn" built-in sound value, so exportArchive() throws for any user who selected it

must_fix bug

Why we think it's a valid issue
  • Checked: Compared the backup schema's completionSound enum (settingsBackup.ts:38-59) against the domain type BuiltInCompletionSound (settingsStore.ts:39-55) and the live UI options (NotificationsSettings.tsx), then traced the full export path.
  • Found: BuiltInCompletionSound ends in "msn" (settingsStore.ts:55) and NotificationsSettings.tsx:286 renders <Select.Item value="msn">MSN Messenger</Select.Item> as a live, user-selectable option — but the backup enum omits "msn", and "msn" also fails the custom: regex branch, so it fails both arms of the z.union.
  • Found: The break is unconditional on export: exportArchive() maps over all SETTINGS_BACKUP_CATEGORY_IDS (notifications first) → createSettingsBackup() → settingsBackupManifestSchema.parse(...), which validates completionSound. pick() reads state.completionSound verbatim with no normalization, so a stored "msn" throws a Zod validation error on every Export click, regardless of import category selection. The UI exportSettings catch turns it into a toast.error, so export silently fails for that user.
  • Found: settingsBackup.test.ts only exercises completionSound: "none" (line 14), so the added suite would not catch this before release.
  • Impact: Concrete, currently-reachable trigger (a first-class menu option) → the entire export feature is broken for every user who selected MSN Messenger, with a confusing generic error. A genuine correctness/contract-drift bug real inputs hit; keep at must_fix.
Issue description

The completionSound zod schema enumerates exactly the built-in sound ids: "none", "guitar", "danilo", "revi", "meep", "meep-smol", "bubbles", "drop", "knock", "ring", "shoot", "slide", "switch", "wilhelm", "icq" (plus "random-all"/"random-custom" and the custom: regex branch). This list is out of sync with the real domain type it is meant to mirror: packages/ui/src/features/settings/settingsStore.ts defines BuiltInCompletionSound with 16 members, ending in "msn" — and packages/ui/src/features/settings/sections/NotificationsSettings.tsx renders <Select.Item value="msn">MSN Messenger</Select.Item> as a live, user-selectable option right next to icq. "msn" is absent from the new schema's enum and does not match the custom: regex either, so it fails both branches of the z.union. Any user who has "MSN Messenger" set as their completion sound will have SettingsBackupService.exportArchive() -> createSettingsBackup() -> settingsBackupManifestSchema.parse(...) throw a Zod validation error the instant they try to export settings — the entire export feature is broken for that (real, currently reachable) subset of users, not just the notifications category. settingsBackup.test.ts never exercises "msn", so nothing in the added test suite would catch this before it reaches users. This is a schema/domain-type mismatch distinct from the array-ordering, null-guard, zip-bomb, zip-slip, and prototype-pollution issues already raised on this chunk — none of those touch enum completeness for completionSound.

Suggested fix

Add "msn" to the completionSound enum in settingsBackupCategorySchema's categoriesSchema.notifications.completionSound, e.g. derive the built-in list from BuiltInCompletionSound (or a shared const) instead of hand-duplicating it, so the two can't drift again. Also add a regression test in settingsBackup.test.ts that round-trips completionSound: "msn" through export/import.

Prompt to fix with AI (copy-paste)
## Context
@packages/core/src/settings/settingsBackup.ts#L38-59

<issue_description>
The `completionSound` zod schema enumerates exactly the built-in sound ids: "none", "guitar", "danilo", "revi", "meep", "meep-smol", "bubbles", "drop", "knock", "ring", "shoot", "slide", "switch", "wilhelm", "icq" (plus "random-all"/"random-custom" and the `custom:` regex branch). This list is out of sync with the real domain type it is meant to mirror: `packages/ui/src/features/settings/settingsStore.ts` defines `BuiltInCompletionSound` with 16 members, ending in `"msn"` — and `packages/ui/src/features/settings/sections/NotificationsSettings.tsx` renders `<Select.Item value="msn">MSN Messenger</Select.Item>` as a live, user-selectable option right next to `icq`. "msn" is absent from the new schema's enum and does not match the `custom:` regex either, so it fails both branches of the `z.union`. Any user who has "MSN Messenger" set as their completion sound will have `SettingsBackupService.exportArchive()` -> `createSettingsBackup()` -> `settingsBackupManifestSchema.parse(...)` throw a Zod validation error the instant they try to export settings — the entire export feature is broken for that (real, currently reachable) subset of users, not just the notifications category. `settingsBackup.test.ts` never exercises "msn", so nothing in the added test suite would catch this before it reaches users. This is a schema/domain-type mismatch distinct from the array-ordering, null-guard, zip-bomb, zip-slip, and prototype-pollution issues already raised on this chunk — none of those touch enum completeness for `completionSound`.
</issue_description>

<issue_validation>
- **Checked:** Compared the backup schema's `completionSound` enum (`settingsBackup.ts:38-59`) against the domain type `BuiltInCompletionSound` (`settingsStore.ts:39-55`) and the live UI options (`NotificationsSettings.tsx`), then traced the full export path.
- **Found:** `BuiltInCompletionSound` ends in `"msn"` (`settingsStore.ts:55`) and `NotificationsSettings.tsx:286` renders `<Select.Item value="msn">MSN Messenger</Select.Item>` as a live, user-selectable option — but the backup enum omits `"msn"`, and `"msn"` also fails the `custom:` regex branch, so it fails both arms of the `z.union`.
- **Found:** The break is unconditional on export: `exportArchive()` maps over all `SETTINGS_BACKUP_CATEGORY_IDS` (notifications first) → `createSettingsBackup()` → `settingsBackupManifestSchema.parse(...)`, which validates `completionSound`. `pick()` reads `state.completionSound` verbatim with no normalization, so a stored `"msn"` throws a Zod validation error on every Export click, regardless of import category selection. The UI `exportSettings` catch turns it into a `toast.error`, so export silently fails for that user.
- **Found:** `settingsBackup.test.ts` only exercises `completionSound: "none"` (line 14), so the added suite would not catch this before release.
- **Impact:** Concrete, currently-reachable trigger (a first-class menu option) → the entire export feature is broken for every user who selected MSN Messenger, with a confusing generic error. A genuine correctness/contract-drift bug real inputs hit; keep at must_fix.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add "msn" to the `completionSound` enum in `settingsBackupCategorySchema`'s `categoriesSchema.notifications.completionSound`, e.g. derive the built-in list from `BuiltInCompletionSound` (or a shared const) instead of hand-duplicating it, so the two can't drift again. Also add a regression test in settingsBackup.test.ts that round-trips `completionSound: "msn"` through export/import.
</potential_solution>

completionVolume: z.number().min(0).max(100),
scaleSoundWithTaskLength: z.boolean(),
}),
agentDefaults: z.object({
defaultRunMode: z.enum(["local", "cloud", "last_used"]),
defaultInitialTaskMode: z.enum(["plan", "last_used"]),
defaultReasoningEffort: z.enum([
"low",
"medium",
"high",
"xhigh",
"max",
"last_used",
]),
defaultMessagingMode: z.enum(["queue", "steer"]),
}),
composer: z.object({
autoConvertLongText: z.enum(["off", "1000", "2500", "5000", "10000"]),
sendMessagesWith: z.enum(["enter", "cmd+enter"]),
customInstructions: z.string(),
diffOpenMode: z.enum(["auto", "split", "same-pane", "last-active-pane"]),
conversationCollapseMode: z.string(),
}),
terminal: z.object({
terminalFont: z.enum([
"berkeley-mono",
"jetbrains-mono",
"system",
"custom",
]),
terminalCustomFontFamily: z.string(),
terminalGpuRendering: z.boolean(),
}),
system: z.object({
allowBypassPermissions: z.boolean(),
preventSleepWhileRunning: z.boolean(),
debugLogsCloudRuns: z.boolean(),
autoPublishCloudRuns: z.boolean(),
downloadUpdatesAutomatically: z.boolean(),
}),
appearance: z.object({
hedgehogMode: z.boolean(),
slotMachineMode: z.boolean(),
brainrotMode: z.boolean(),
mcpAppsDisabledServers: z.array(z.string()),
dismissibleUpdateBanners: z.boolean(),
useNewChatThread: z.boolean(),
theme: z.enum(["light", "dark", "system"]),
}),
onboarding: z.object({
lastSeenChangelogVersion: z.string().nullable(),
hints: z.record(
z.string(),
z.object({
count: z.number().int().nonnegative(),
learned: z.boolean(),
}),
),
}),
})
.partial();

export const settingsBackupManifestSchema = z.object({
format: z.literal("posthog-code-settings"),
version: z.literal(1),
exportedAt: z.string(),
categories: categoriesSchema,
sounds: z.array(soundSchema).max(100),
});

export type SettingsBackupManifest = z.infer<
typeof settingsBackupManifestSchema
>;

export interface ExportSound {
id: string;
name: string;
durationMs: number;
dataUrl: string;
}

export interface ParsedSettingsBackup {
manifest: SettingsBackupManifest;
soundDataUrls: Record<string, string>;
}

function extensionForMimeType(mimeType: string): string {
const subtype = mimeType.split("/")[1]?.split(";")[0] ?? "bin";
return subtype === "mpeg"
? "mp3"
: subtype.replace(/[^A-Za-z0-9]/g, "") || "bin";
}

function parseDataUrl(dataUrl: string): {
mimeType: string;
bytes: Uint8Array;
} {
const match =
/^data:(audio\/[A-Za-z0-9.+-]+)(?:;[^,]*)?;base64,([A-Za-z0-9+/=]+)$/.exec(
dataUrl,
);
if (!match) throw new Error("A custom sound has invalid audio data");
const binary = atob(match[2]);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (bytes.byteLength > MAX_ENTRY_BYTES)
throw new Error("A custom sound is too large");
return { mimeType: match[1], bytes };
}

function toDataUrl(mimeType: string, bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return `data:${mimeType};base64,${btoa(binary)}`;
}

export function createSettingsBackup(
categories: SettingsBackupManifest["categories"],
sounds: ExportSound[],
exportedAt = new Date().toISOString(),
): Uint8Array {
const files: Record<string, Uint8Array> = {};
const soundEntries = sounds.map((sound) => {
const { mimeType, bytes } = parseDataUrl(sound.dataUrl);
const file = `sounds/${sound.id}.${extensionForMimeType(mimeType)}`;
files[file] = bytes;
return {
id: sound.id,
name: sound.name,
durationMs: sound.durationMs,
file,
mimeType,
};
});
const manifest = settingsBackupManifestSchema.parse({
format: "posthog-code-settings",
version: 1,
exportedAt,
categories,
sounds: soundEntries,
});
files[MANIFEST_PATH] = Uint8Array.from(
new TextEncoder().encode(JSON.stringify(manifest, null, 2)),
);
return zipSync(files, { level: 6 });
}
Comment on lines +207 to +236

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Archive export/import runs fully synchronously via zipSync/unzipSync, contradicting this codebase's own established fix for the identical problem

should_fix performance

Why we think it's a valid issue
  • Checked: createSettingsBackup (settingsBackup.ts:207-235, zipSync(files, {level:6})) and parseSettingsBackup (unzipSync at line 243), the cited precedent packages/workspace-server/src/services/posthog-plugin/extract-zip.ts, and where @posthog/core runs (renderer, via SettingsBackupControls.tsx → inspectArchive/exportArchive).
  • Found: The precedent exists verbatim — extract-zip.ts:5-6: // fflate's async unzip yields the event loop so the Electron main thread stays responsive on large archives. Do not switch back to unzipSync. — and it uses fflate's async unzip (line 16). The new code reintroduces exactly the banned synchronous pattern (zipSync/unzipSync), in @posthog/core, which runs on the renderer UI thread, so the freeze is directly user-visible.
  • Found: Asymmetric guarding confirmed: import checks bytes.byteLength > MAX_SETTINGS_BACKUP_ARCHIVE_BYTES up front, but createSettingsBackup has no aggregate size check before the CPU-heavy level-6 zipSync — only a per-sound 5MB cap via parseDataUrl.
  • Impact: Genuine reliability defect, not style/overengineering — it violates a convention the team explicitly documented ('Do not switch back to unzipSync') and blocks the UI thread during (de)compression with no progress or cancel. Distinct from the 3-1-1 decompression-bomb finding (different fix: worker-backed async + export-side aggregate cap). The documented precedent makes it concrete and actionable.
  • Priority: Keeping reviewer's should_fix — for legitimate inputs the freeze is bounded (25MB import cap; realistically small exports), so not must_fix, but the codified team decision plus the missing export guard make it a real, actionable reliability item rather than a consider.
Issue description

createSettingsBackup (export, zipSync(files, { level: 6 }) at line 235) and parseSettingsBackup (import, unzipSync(bytes, {...}) at line 243) both block the calling thread for the full duration of compression/decompression, with no size-checked upper bound on the export side at all — unlike import, which checks bytes.byteLength > MAX_SETTINGS_BACKUP_ARCHIVE_BYTES up front, createSettingsBackup never validates the total size of state.customSounds (up to 100 entries x up to MAX_ENTRY_BYTES = 5MB each, per soundSchema/settingsBackupManifestSchema limits) before running the CPU-heavy zipSync at compression level 6. Since @posthog/core runs unchanged in the Electron renderer (per this repo's own architecture rules), both operations most likely execute on the renderer's main/UI thread, freezing the app with no progress indicator for however long compression/decompression takes. This is not a hypothetical concern in this codebase specifically: packages/workspace-server/src/services/posthog-plugin/extract-zip.ts already hit and fixed this exact issue, with an explicit comment: // fflate's async unzip yields the event loop so the Electron main thread stays responsive on large archives. Do not switch back to unzipSync. The new settingsBackup.ts code reintroduces the pattern that comment warns against.

Suggested fix

Use fflate's async, worker-backed zip/unzip (same options shape — AsyncUnzipOptions extends UnzipOptions, so the existing filter callback needs no changes) wrapped in a Promise, mirroring the existing unzipAsync helper in extract-zip.ts, and make createSettingsBackup/parseSettingsBackup (and SettingsBackupService.exportArchive/inspectArchive) async. Also add an upfront aggregate size check in createSettingsBackup (summing sound.dataUrl decoded lengths before building files) so an export with many large custom sounds fails fast with a clear error instead of paying for a multi-hundred-MB zipSync call first.

Prompt to fix with AI (copy-paste)
## Context
@packages/core/src/settings/settingsBackup.ts#L207-236
@packages/core/src/settings/settingsBackup.ts#L243

<issue_description>
`createSettingsBackup` (export, `zipSync(files, { level: 6 })` at line 235) and `parseSettingsBackup` (import, `unzipSync(bytes, {...})` at line 243) both block the calling thread for the full duration of compression/decompression, with no size-checked upper bound on the export side at all — unlike import, which checks `bytes.byteLength > MAX_SETTINGS_BACKUP_ARCHIVE_BYTES` up front, `createSettingsBackup` never validates the total size of `state.customSounds` (up to 100 entries x up to `MAX_ENTRY_BYTES` = 5MB each, per `soundSchema`/`settingsBackupManifestSchema` limits) before running the CPU-heavy `zipSync` at compression level 6. Since `@posthog/core` runs unchanged in the Electron renderer (per this repo's own architecture rules), both operations most likely execute on the renderer's main/UI thread, freezing the app with no progress indicator for however long compression/decompression takes. This is not a hypothetical concern in this codebase specifically: `packages/workspace-server/src/services/posthog-plugin/extract-zip.ts` already hit and fixed this exact issue, with an explicit comment: `// fflate's async unzip yields the event loop so the Electron main thread stays responsive on large archives. Do not switch back to unzipSync.` The new settingsBackup.ts code reintroduces the pattern that comment warns against.
</issue_description>

<issue_validation>
- **Checked:** `createSettingsBackup` (`settingsBackup.ts:207-235`, `zipSync(files, {level:6})`) and `parseSettingsBackup` (`unzipSync` at line 243), the cited precedent `packages/workspace-server/src/services/posthog-plugin/extract-zip.ts`, and where `@posthog/core` runs (renderer, via `SettingsBackupControls.tsx` → `inspectArchive`/`exportArchive`).
- **Found:** The precedent exists verbatim — `extract-zip.ts:5-6`: `// fflate's async unzip yields the event loop so the Electron main thread stays responsive on large archives. Do not switch back to unzipSync.` — and it uses fflate's async `unzip` (line 16). The new code reintroduces exactly the banned synchronous pattern (`zipSync`/`unzipSync`), in `@posthog/core`, which runs on the renderer UI thread, so the freeze is directly user-visible.
- **Found:** Asymmetric guarding confirmed: import checks `bytes.byteLength > MAX_SETTINGS_BACKUP_ARCHIVE_BYTES` up front, but `createSettingsBackup` has no aggregate size check before the CPU-heavy level-6 `zipSync` — only a per-sound 5MB cap via `parseDataUrl`.
- **Impact:** Genuine reliability defect, not style/overengineering — it violates a convention the team explicitly documented ('Do not switch back to unzipSync') and blocks the UI thread during (de)compression with no progress or cancel. Distinct from the 3-1-1 decompression-bomb finding (different fix: worker-backed async + export-side aggregate cap). The documented precedent makes it concrete and actionable.
- **Priority:** Keeping reviewer's should_fix — for legitimate inputs the freeze is bounded (25MB import cap; realistically small exports), so not must_fix, but the codified team decision plus the missing export guard make it a real, actionable reliability item rather than a `consider`.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Use fflate's async, worker-backed `zip`/`unzip` (same options shape — `AsyncUnzipOptions extends UnzipOptions`, so the existing `filter` callback needs no changes) wrapped in a Promise, mirroring the existing `unzipAsync` helper in `extract-zip.ts`, and make `createSettingsBackup`/`parseSettingsBackup` (and `SettingsBackupService.exportArchive`/`inspectArchive`) async. Also add an upfront aggregate size check in `createSettingsBackup` (summing `sound.dataUrl` decoded lengths before building `files`) so an export with many large custom sounds fails fast with a clear error instead of paying for a multi-hundred-MB `zipSync` call first.
</potential_solution>


export function parseSettingsBackup(bytes: Uint8Array): ParsedSettingsBackup {
if (bytes.byteLength > MAX_ARCHIVE_BYTES)
throw new Error("Settings archive is too large");
const files = unzipSync(bytes);
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
pauldambra marked this conversation as resolved.
Resolved
Comment thread
pauldambra marked this conversation as resolved.
Resolved
let expandedBytes = 0;
for (const [name, contents] of Object.entries(files)) {
if (name.includes("..") || name.startsWith("/") || name.includes("\\")) {
throw new Error("Settings archive contains an unsafe path");
}
if (contents.byteLength > MAX_ENTRY_BYTES) {
throw new Error("Settings archive contains an oversized file");
}
expandedBytes += contents.byteLength;
if (expandedBytes > MAX_ARCHIVE_BYTES) {
throw new Error("Expanded settings archive is too large");
}
}
const rawManifest = files[MANIFEST_PATH];
if (!rawManifest) throw new Error("Settings archive has no manifest");
const manifest = settingsBackupManifestSchema.parse(
JSON.parse(new TextDecoder().decode(rawManifest)),
);
const soundDataUrls: Record<string, string> = {};
for (const sound of manifest.sounds) {
const soundBytes = files[sound.file];
if (!soundBytes)
throw new Error(`Settings archive is missing ${sound.file}`);
soundDataUrls[sound.id] = toDataUrl(sound.mimeType, soundBytes);
Comment thread
pauldambra marked this conversation as resolved.
Resolved
}
return { manifest, soundDataUrls };
}
Loading
Loading