mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix: normalize invalid persisted renderer state
Validate renderer-facing crash state at read and write boundaries.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user