Cap persisted browser history payloads (#1822)

This commit is contained in:
Neil 2026-05-14 01:22:46 -07:00 committed by GitHub
parent dbeb89436a
commit b0e7fe94ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 208 additions and 38 deletions

View File

@ -6,6 +6,7 @@ import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types'
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
// Shared mutable state so the electron mock can reference a per-test directory
const testState = { dir: '' }
@ -113,6 +114,23 @@ function makeSessionWithTerminalBuffers(): WorkspaceSessionState {
}
}
function makeSessionWithBrowserHistory(count: number): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserUrlHistory: Array.from({ length: count }, (_, index) => ({
url: `https://example.com/${index}`,
normalizedUrl: `https://example.com/${index}`,
title: `Example ${index} ${'x'.repeat(200)}`,
lastVisitedAt: 1_700_000_000_000 - index,
visitCount: 1
}))
}
}
describe('Store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
@ -1037,6 +1055,20 @@ describe('Store', () => {
})
})
it('caps oversized browser history when setting workspace session', async () => {
const store = await createStore()
const oversizedSession = makeSessionWithBrowserHistory(500)
const oversizedBytes = Buffer.byteLength(JSON.stringify(oversizedSession))
store.setWorkspaceSession(oversizedSession)
const session = store.getWorkspaceSession()
const prunedBytes = Buffer.byteLength(JSON.stringify(session))
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
expect(prunedBytes).toBeLessThan(oversizedBytes / 2)
})
it('keeps terminal scrollback buffers when the repo catalog is not hydrated yet', async () => {
const store = await createStore()
@ -1092,6 +1124,23 @@ describe('Store', () => {
})
})
it('caps oversized legacy browser history when loading workspace session', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: makeSessionWithBrowserHistory(500)
})
const store = await createStore()
const session = store.getWorkspaceSession()
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
})
it('does not restore cleared SSH bindings after a lease expired', async () => {
const store = await createStore()
store.upsertSshRemotePtyLease({

View File

@ -44,6 +44,7 @@ import {
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
function encrypt(plaintext: string): string {
@ -464,7 +465,9 @@ export class Store {
result = {
...result,
workspaceSession: pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
workspaceSession: pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
}
return this.migrateTelemetry(result, fileExistedOnLoad)
@ -1100,7 +1103,9 @@ export class Store {
}
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
session = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
// Why: closes the second half of the SIGKILL race (Issue #217). The
// renderer's debounced session writer captures its state BEFORE pty:spawn

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { BrowserHistoryEntry } from '../../../shared/types'
import { MAX_BROWSER_HISTORY_ENTRIES } from '../../../shared/workspace-session-browser-history'
import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './workspace-session'
function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSessionSnapshot {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
activeBrowserTabIdByWorktree: {},
browserUrlHistory,
unifiedTabsByWorktree: {},
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
sshConnectionStates: new Map(),
repos: [],
worktreesByRepo: {},
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {}
}
}
describe('workspace session browser history payloads', () => {
it('caps history and reduces serialized bytes at the payload boundary', () => {
const oversizedHistory = Array.from({ length: 500 }, (_, index) => ({
url: `https://example.com/${index}`,
normalizedUrl: `https://example.com/${index}`,
title: `${'Search result '.repeat(40)}${index}`,
lastVisitedAt: 1_700_000_000_000 - index,
visitCount: 1
}))
const uncappedPayload = {
...buildWorkspaceSessionPayload(createSnapshot([])),
browserUrlHistory: oversizedHistory
}
const payload = buildWorkspaceSessionPayload(createSnapshot(oversizedHistory))
expect(payload.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(payload.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThan(
Buffer.byteLength(JSON.stringify(uncappedPayload)) / 2
)
})
})

View File

@ -82,6 +82,7 @@ function createSnapshot(overrides: Partial<AppState> = {}): AppState {
}
]
},
browserUrlHistory: [],
...overrides
} as AppState
}

View File

@ -6,6 +6,7 @@ import type {
WorkspaceVisibleTabType
} from '../../../shared/types'
import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers'
import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-session-browser-history'
import type { AppState } from '../store'
import type { OpenFile } from '../store/slices/editor'
@ -264,7 +265,10 @@ export function buildWorkspaceSessionPayload(
snapshot.browserPagesByWorkspace,
snapshot.activeBrowserTabIdByWorktree
),
browserUrlHistory: snapshot.browserUrlHistory,
// Why: browser history is user-lifetime state. Enforce the storage cap at
// the payload boundary so stale renderer state cannot make every session
// write stringify an oversized legacy history array.
browserUrlHistory: normalizeBrowserHistoryEntries(snapshot.browserUrlHistory),
unifiedTabs: unifiedTabsByWorktree,
tabGroups: groupsByWorktree,
tabGroupLayouts: layoutByWorktree,

View File

@ -14,6 +14,11 @@ import type {
} from '../../../../shared/types'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import {
MAX_BROWSER_HISTORY_ENTRIES,
normalizeBrowserHistoryEntries,
normalizeBrowserHistoryUrl
} from '../../../../shared/workspace-session-browser-history'
import { pickNeighbor } from './tab-group-state'
import { destroyWorkspaceWebviews } from './browser-webview-cleanup'
@ -138,38 +143,6 @@ export type BrowserSlice = {
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
}
const MAX_BROWSER_HISTORY_ENTRIES = 200
function normalizeHistoryUrl(url: string): string {
try {
const parsed = new URL(redactKagiSessionToken(url))
parsed.hostname = parsed.hostname.toLowerCase()
parsed.protocol = parsed.protocol.toLowerCase()
let normalized = parsed.toString()
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1)
}
return normalized
} catch {
return redactKagiSessionToken(url).toLowerCase()
}
}
function deduplicateHistory(entries: BrowserHistoryEntry[]): BrowserHistoryEntry[] {
const seen = new Set<string>()
const deduped: BrowserHistoryEntry[] = []
for (const entry of entries) {
const safeUrl = redactKagiSessionToken(entry.url)
const key = normalizeHistoryUrl(safeUrl)
if (seen.has(key)) {
continue
}
seen.add(key)
deduped.push({ ...entry, url: safeUrl, normalizedUrl: key })
}
return deduped.slice(0, MAX_BROWSER_HISTORY_ENTRIES)
}
function normalizeUrl(url: string): string {
const trimmed = url.trim()
if (trimmed.length === 0) {
@ -1268,7 +1241,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
activeBrowserTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType,
browserUrlHistory: deduplicateHistory(session.browserUrlHistory ?? [])
browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? [])
}
})
@ -1494,7 +1467,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
return
}
const normalized = normalizeHistoryUrl(safeUrl)
const normalized = normalizeBrowserHistoryUrl(safeUrl)
set((s) => {
const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized)
let next: BrowserHistoryEntry[] = existing

View File

@ -0,0 +1,55 @@
import type { BrowserHistoryEntry, WorkspaceSessionState } from './types'
import { redactKagiSessionToken } from './browser-url'
export const MAX_BROWSER_HISTORY_ENTRIES = 200
export function normalizeBrowserHistoryUrl(url: string): string {
try {
const parsed = new URL(redactKagiSessionToken(url))
parsed.hostname = parsed.hostname.toLowerCase()
parsed.protocol = parsed.protocol.toLowerCase()
let normalized = parsed.toString()
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1)
}
return normalized
} catch {
return redactKagiSessionToken(url).toLowerCase()
}
}
export function normalizeBrowserHistoryEntries(
entries: readonly BrowserHistoryEntry[]
): BrowserHistoryEntry[] {
const seen = new Set<string>()
const normalizedEntries: BrowserHistoryEntry[] = []
for (const entry of entries) {
const safeUrl = redactKagiSessionToken(entry.url)
const key = normalizeBrowserHistoryUrl(safeUrl)
if (seen.has(key)) {
continue
}
seen.add(key)
normalizedEntries.push({ ...entry, url: safeUrl, normalizedUrl: key })
if (normalizedEntries.length >= MAX_BROWSER_HISTORY_ENTRIES) {
break
}
}
return normalizedEntries
}
export function pruneWorkspaceSessionBrowserHistory(
session: WorkspaceSessionState
): WorkspaceSessionState {
if (!session.browserUrlHistory) {
return session
}
const browserUrlHistory = normalizeBrowserHistoryEntries(session.browserUrlHistory)
if (
browserUrlHistory.length === session.browserUrlHistory.length &&
browserUrlHistory.every((entry, index) => entry === session.browserUrlHistory?.[index])
) {
return session
}
return { ...session, browserUrlHistory }
}

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { parseWorkspaceSession } from './workspace-session-schema'
import { MAX_BROWSER_HISTORY_ENTRIES } from './workspace-session-browser-history'
describe('parseWorkspaceSession', () => {
it('accepts a minimal valid session', () => {
@ -116,4 +117,27 @@ describe('parseWorkspaceSession', () => {
expect(result.value.lastVisitedAtByWorktreeId).toEqual({ good: 1_700_000_000_000 })
}
})
it('caps oversized browser history while parsing legacy workspace sessions', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserUrlHistory: Array.from({ length: 500 }, (_, index) => ({
url: `https://example.com/${index}`,
normalizedUrl: `https://example.com/${index}`,
title: `Example ${index}`,
lastVisitedAt: 1_700_000_000_000 - index,
visitCount: 1
}))
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(result.value.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
}
})
})

View File

@ -15,6 +15,7 @@ import type {
TerminalPaneLayoutNode,
WorkspaceSessionState
} from './types'
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
// ─── Terminal pane layout (recursive) ───────────────────────────────
@ -182,6 +183,10 @@ const browserHistoryEntrySchema = z.object({
visitCount: z.number()
})
const browserHistoryEntriesSchema = z
.array(browserHistoryEntrySchema)
.transform((entries) => normalizeBrowserHistoryEntries(entries))
// ─── Workspace session ──────────────────────────────────────────────
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
@ -197,7 +202,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(),
activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(),
browserUrlHistory: z.array(browserHistoryEntrySchema).optional(),
browserUrlHistory: browserHistoryEntriesSchema.optional(),
activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(),
tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(),