diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 27563d2bb..7ef005d35 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 068d33445..3f49a59fc 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts new file mode 100644 index 000000000..3d7153f1d --- /dev/null +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -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 + ) + }) +}) diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index a7416d770..28787da62 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -82,6 +82,7 @@ function createSnapshot(overrides: Partial = {}): AppState { } ] }, + browserUrlHistory: [], ...overrides } as AppState } diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index ffaac2f2c..ce8ac84fb 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -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, diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 92792ab24..d20473c29 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -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() - 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 = activeBrowserTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType, - browserUrlHistory: deduplicateHistory(session.browserUrlHistory ?? []) + browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []) } }) @@ -1494,7 +1467,7 @@ export const createBrowserSlice: StateCreator = 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 diff --git a/src/shared/workspace-session-browser-history.ts b/src/shared/workspace-session-browser-history.ts new file mode 100644 index 000000000..fd2e484d0 --- /dev/null +++ b/src/shared/workspace-session-browser-history.ts @@ -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() + 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 } +} diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 4a25933f4..0bcc95b48 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -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') + } + }) }) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 66b1f3b3c..cb17b297a 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -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 = z.object({ @@ -197,7 +202,7 @@ export const workspaceSessionStateSchema: z.ZodType = 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(),