fix: normalize invalid persisted renderer state

Validate renderer-facing crash state at read and write boundaries.
This commit is contained in:
OrcaWin
2026-08-09 19:14:08 -07:00
parent a3ca887a3c
commit f25b2bb237
11 changed files with 361 additions and 5 deletions
@@ -5,6 +5,7 @@ import { dirname } from 'node:path'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import { normalizePersistedProjectSourceRepoIds } from '../../shared/project-source-repo-id-normalization'
import type {
PersistedState,
Project,
@@ -101,8 +102,9 @@ function isRepoBackedProjectHostSetup(
}
export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState {
const projects = normalizePersistedProjectSourceRepoIds(state.projects).projects
const projection = projectHostSetupProjectionFromRepos(state.repos)
const existingProjectsById = new Map(state.projects.map((project) => [project.id, project]))
const existingProjectsById = new Map(projects.map((project) => [project.id, project]))
const currentRepoIds = new Set(state.repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
@@ -113,7 +115,7 @@ export function rebuildRepoBackedProjectState(state: TransferProfileState): Tran
return !isRepoBackedProjectHostSetup(setup, currentRepoIds)
})
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
const independentProjects = state.projects
const independentProjects = projects
.filter(
(project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id)
)
@@ -108,6 +108,42 @@ describe('profile project transfer', () => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('normalizes missing project source repo ids before rebuilding profile state', async () => {
const malformedState = makeState({
projects: [
{
id: 'folder-project',
displayName: 'Folder Project',
badgeColor: '#737373',
createdAt: 1,
updatedAt: 1
}
],
projectHostSetups: [
{
id: 'folder-project::local',
projectId: 'folder-project',
hostId: 'local',
repoId: '',
path: 'C:\\workspace\\folder-project',
displayName: 'Folder Project',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
]
} as Partial<PersistedState>)
writeProfileState('personal', malformedState)
const { readProfileState: readPersistedProfileState } =
await import('./profile-project-state-file')
expect(readPersistedProfileState('personal', testState.dir).projects).toContainEqual(
expect.objectContaining({ id: 'folder-project', sourceRepoIds: [] })
)
})
it('copies a project into another profile with a new repo id and re-keyed metadata', async () => {
const sourceWorktreeId = 'repo-1::/workspace/orca-feature'
writeProfileState(
+80
View File
@@ -562,6 +562,34 @@ describe('Store', () => {
expect(persisted.projectHostSetups).toContainEqual(independentSetup)
})
it('normalizes missing persisted project source repo ids before compatibility projection', async () => {
const independentProject = makeProject({ id: 'folder-project' })
const { sourceRepoIds: _sourceRepoIds, ...malformedProject } = independentProject
const independentSetup = makeProjectHostSetup({
id: 'folder-project::local',
projectId: independentProject.id,
repoId: '',
path: 'C:\\workspace\\folder-project'
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
projects: [malformedProject],
projectHostSetups: [independentSetup]
})
const store = await createStore()
expect(store.getProjects()).toContainEqual({ ...independentProject, sourceRepoIds: [] })
expect(() =>
store.getProjects().find((project) => project.sourceRepoIds.includes('missing-repo'))
).not.toThrow()
store.flush()
expect((readDataFile() as PersistedState).projects).toContainEqual({
...independentProject,
sourceRepoIds: []
})
})
it('updates and persists a project Windows runtime preference', async () => {
const project = makeProject({
id: 'project-1',
@@ -5621,6 +5649,42 @@ describe('Store', () => {
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
})
it('normalizes terminal cursor style before persistence and listener broadcasts', async () => {
const store = await createStore()
store.updateSettings({ terminalCursorStyle: 'underline' })
const listener = vi.fn()
store.onSettingsChanged(listener)
const invalid = store.updateSettings(
{ terminalCursorStyle: 'beam' as never },
{ notifyListeners: true }
)
expect(invalid.terminalCursorStyle).toBe('block')
expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true)
expect(listener).toHaveBeenCalledWith(
{
terminalCursorStyle: 'block'
},
expect.objectContaining({ terminalCursorStyle: 'block' }),
undefined
)
const valid = store.updateSettings(
{ terminalCursorStyle: 'underline' },
{ notifyListeners: true }
)
expect(valid.terminalCursorStyle).toBe('underline')
expect(listener).toHaveBeenLastCalledWith(
{ terminalCursorStyle: 'underline' },
expect.objectContaining({ terminalCursorStyle: 'underline' }),
undefined
)
store.flush()
expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('underline')
})
it('normalizes disabled TUI agents on load and update', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
@@ -7295,6 +7359,22 @@ describe('Store', () => {
expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('replaces an invalid persisted terminal cursor choice after migration', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalCursorStyle: 'beam', terminalCursorStyleDefaultedToBlock: true },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalCursorStyle).toBe('block')
store.flush()
expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('block')
})
it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => {
// 'false' never matched the old default — it was an explicit choice.
writeDataFile({
+22
View File
@@ -247,6 +247,7 @@ import {
normalizeTuiAgentEnvRecord
} from '../shared/tui-agent-launch-defaults'
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
import { normalizePersistedProjectSourceRepoIds } from '../shared/project-source-repo-id-normalization'
import {
normalizeOsc52ClipboardDefaultOn,
osc52ClipboardDefaultOnOverridesPersistedOff
@@ -3224,6 +3225,13 @@ export class Store {
parsed.settings
)
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
if (
parsed.settings?.terminalCursorStyle !==
migratedTerminalCursorStyle.terminalCursorStyle ||
parsed.settings?.terminalCursorStyleDefaultedToBlock !== true
) {
this.loadNeedsSave = true
}
const migratedTerminalLineHeight = normalizeTerminalLineHeight(
parsed.settings?.terminalLineHeight
)
@@ -3734,6 +3742,11 @@ export class Store {
this.loadNeedsSave = true
}
const normalizedProjects = normalizePersistedProjectSourceRepoIds(result.projects)
if (normalizedProjects.changed) {
this.loadNeedsSave = true
result = { ...result, projects: normalizedProjects.projects }
}
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
@@ -5804,6 +5817,15 @@ export class Store {
updates.terminalCustomThemes
)
}
if ('terminalCursorStyle' in updates) {
Object.assign(
sanitizedUpdates,
normalizeTerminalCursorStyleDefault(
{ terminalCursorStyle: updates.terminalCursorStyle },
{ preserveExplicitValue: true }
)
)
}
if ('terminalScrollbackRows' in updates) {
sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
updates.terminalScrollbackRows
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { i18n, setRendererPluginLanguagePacks } from '../i18n/i18n'
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
import { usePluginLanguagePackStore } from './plugin-language-packs'
afterEach(() => {
vi.restoreAllMocks()
setRendererPluginLanguagePacks([])
usePluginLanguagePackStore.setState({ packs: [], loaded: false })
vi.unstubAllGlobals()
})
describe('plugin language pack loading', () => {
it('rejects malformed persisted language-pack results at the renderer ingress', async () => {
vi.stubGlobal('window', {
api: {
plugins: {
listLanguagePacks: vi.fn().mockResolvedValue({ malformed: true })
}
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
const state = usePluginLanguagePackStore.getState()
expect(state.loaded).toBe(true)
expect(state.packs).toEqual([])
expect(() => state.packs.find(() => true)).not.toThrow()
})
it('filters malformed language-pack array members without changing valid order', async () => {
const first = {
id: 'plugin:first',
resourceLanguage: pluginLanguageResourceId('plugin:first'),
pluginKey: 'first',
locale: 'pt-BR',
catalog: { greeting: 'Olá' }
}
const second = {
id: 'plugin:second',
resourceLanguage: pluginLanguageResourceId('plugin:second'),
pluginKey: 'second',
locale: 'de',
catalog: {}
}
vi.stubGlobal('window', {
api: {
plugins: {
listLanguagePacks: vi
.fn()
.mockResolvedValue([null, first, { ...first, catalog: [] }, second])
}
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second])
})
it('rejects an inconsistent resource language before i18next owns its cleanup', async () => {
vi.stubGlobal('window', {
api: {
plugins: {
listLanguagePacks: vi.fn().mockResolvedValue([
{
id: 'plugin:bad',
resourceLanguage: 'plugin.bad',
pluginKey: 'bad',
locale: 'en',
catalog: {}
}
])
}
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().packs)
const removeResourceBundle = vi
.spyOn(i18n, 'removeResourceBundle')
.mockImplementation((language) => {
throw new Error(`invalid resource language: ${language}`)
})
expect(() => setRendererPluginLanguagePacks([])).not.toThrow()
expect(removeResourceBundle).not.toHaveBeenCalled()
})
it('keeps the latest request when an older language-pack response finishes last', async () => {
const older = {
id: 'plugin:older',
resourceLanguage: pluginLanguageResourceId('plugin:older'),
pluginKey: 'older',
locale: 'de',
catalog: {}
}
const latest = {
id: 'plugin:latest',
resourceLanguage: pluginLanguageResourceId('plugin:latest'),
pluginKey: 'latest',
locale: 'fr',
catalog: {}
}
let resolveOlder!: (value: unknown[]) => void
const olderResponse = new Promise<unknown[]>((resolve) => {
resolveOlder = resolve
})
vi.stubGlobal('window', {
api: {
plugins: {
listLanguagePacks: vi
.fn()
.mockReturnValueOnce(olderResponse)
.mockResolvedValueOnce([latest])
}
}
})
const olderFetch = usePluginLanguagePackStore.getState().fetchPacks()
await usePluginLanguagePackStore.getState().fetchPacks()
resolveOlder([older])
await olderFetch
expect(usePluginLanguagePackStore.getState().packs).toEqual([latest])
})
})
@@ -1,6 +1,9 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
import {
isPluginLanguagePackRegistration,
type PluginLanguagePackRegistration
} from '../../../shared/plugins/plugin-language-pack-artifact'
type PluginLanguagePackState = {
packs: PluginLanguagePackRegistration[]
@@ -24,7 +27,11 @@ export const usePluginLanguagePackStore = create<PluginLanguagePackState>()((set
return
}
try {
const packs = await api.listLanguagePacks()
const response = await api.listLanguagePacks()
const packs = Array.isArray(response) ? response.filter(isPluginLanguagePackRegistration) : []
if (!Array.isArray(response) || packs.length !== response.length) {
console.warn('[plugins] Ignoring malformed language-pack list')
}
if (generation === requestGeneration) {
set({ packs, loaded: true })
}
@@ -901,6 +901,26 @@ describe('web settings preload API', () => {
expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('normalizes terminal cursor style before web settings writes return or persist', async () => {
const { api, storage } = await installApi('Linux')
const invalid = await api.settings.set({ terminalCursorStyle: 'beam' as never })
const invalidStored = JSON.parse(storage.getItem('orca.web.settings.v1') ?? '{}') as {
terminalCursorStyle?: string
terminalCursorStyleDefaultedToBlock?: boolean
}
expect(invalid.terminalCursorStyle).toBe('block')
expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true)
expect(invalidStored.terminalCursorStyle).toBe('block')
expect(invalidStored.terminalCursorStyleDefaultedToBlock).toBe(true)
const valid = await api.settings.set({ terminalCursorStyle: 'bar' })
expect(valid.terminalCursorStyle).toBe('bar')
expect(JSON.parse(storage.getItem('orca.web.settings.v1') ?? '{}').terminalCursorStyle).toBe(
'bar'
)
})
it('migrates OSC 52 clipboard writes on for stored web settings once', async () => {
// Why: the web store is a second, independent settings store — the constants-level
// default flip only reaches profiles that never persisted the old `false` (#10567).
+9
View File
@@ -680,6 +680,15 @@ function createWebPreloadApi(): Partial<PreloadApi> {
if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) {
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
}
if ('terminalCursorStyle' in sanitizedUpdates) {
Object.assign(
sanitizedUpdates,
normalizeTerminalCursorStyleDefault(
{ terminalCursorStyle: sanitizedUpdates.terminalCursorStyle },
{ preserveExplicitValue: true }
)
)
}
const next = mergeSettings(getStoredSettings(), sanitizedUpdates, {
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
})
@@ -23,6 +23,26 @@ export type PluginLanguagePackRegistration = {
catalog: Record<string, unknown>
}
export function isPluginLanguagePackRegistration(
value: unknown
): value is PluginLanguagePackRegistration {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const pack = value as Record<string, unknown>
return (
typeof pack.id === 'string' &&
pack.id.startsWith('plugin:') &&
typeof pack.resourceLanguage === 'string' &&
pack.resourceLanguage === pluginLanguageResourceId(pack.id as `plugin:${string}`) &&
typeof pack.pluginKey === 'string' &&
typeof pack.locale === 'string' &&
typeof pack.catalog === 'object' &&
pack.catalog !== null &&
!Array.isArray(pack.catalog)
)
}
export function pluginLanguageResourceId(id: `plugin:${string}`): `plugin${string}` {
let encoded = ''
for (let index = 0; index < id.length; index += 1) {
@@ -0,0 +1,24 @@
import type { Project } from './types'
export function normalizePersistedProjectSourceRepoIds(projects: readonly Project[]): {
projects: Project[]
changed: boolean
} {
let changed = false
const normalizedProjects = projects.map((project) => {
if (
Array.isArray(project.sourceRepoIds) &&
project.sourceRepoIds.every((repoId) => typeof repoId === 'string')
) {
return project
}
changed = true
return {
...project,
sourceRepoIds: Array.isArray(project.sourceRepoIds)
? project.sourceRepoIds.filter((repoId): repoId is string => typeof repoId === 'string')
: []
}
})
return { projects: normalizedProjects, changed }
}
+8 -1
View File
@@ -5,6 +5,10 @@ type TerminalCursorStyleSettings = Pick<
'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock'
>
function isTerminalCursorStyle(value: unknown): value is GlobalSettings['terminalCursorStyle'] {
return value === 'bar' || value === 'block' || value === 'underline'
}
export function normalizeTerminalCursorStyleDefault(
settings: Partial<TerminalCursorStyleSettings> | undefined,
options: { preserveExplicitValue?: boolean } = {}
@@ -15,7 +19,10 @@ export function normalizeTerminalCursorStyleDefault(
return {
// Why: prior builds persisted the old bar default into profiles; migrate
// those inherited values once while preserving later explicit choices.
terminalCursorStyle: defaultedToBlock ? (settings?.terminalCursorStyle ?? 'block') : 'block',
terminalCursorStyle:
defaultedToBlock && isTerminalCursorStyle(settings?.terminalCursorStyle)
? settings.terminalCursorStyle
: 'block',
terminalCursorStyleDefaultedToBlock: true
}
}