From cc5ab8e4cd93ffcfd3299d1260fe53517dc20f20 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 10 Oct 2026 10:46:42 +0800 Subject: [PATCH 1/4] feat(storage): import Cursor sessions via the state.vscdb adapter Adds the fourth external-session adapter (#5964). Cursor keeps its composer history in one SQLite database - `state.vscdb` under the `User/globalStorage` directory - so a read-only `node:sqlite` connection is the whole dependency surface, the same toolchain the Codex adapter already uses. The `composerData:` KV row carries the display name, wall-clock stamps, and the ordered `fullConversationHeadersOnly` index; each `bubbleId::` row holds one message. A bubble `type` of 1 converts to a user message and 2 to assistant text plus an optional `toolFormerData` call/result pair (completed and error become paired results; cancelled calls stay unanswered and fail the turn). Headers whose bubble rows were pruned by the source are gaps the import skips, not errors; a corrupt bubble row degrades the same way. Hardening mirrors the existing adapters: all five limit kinds are active (records, record_bytes, transcript_bytes, messages, converted_bytes), titles go through `sanitizeExternalSessionTitle`, ids and error messages carry no source paths or transcript content, and every read opens the database read-only. Catalog metadata the source does not carry is absent rather than invented: Cursor composers expose no working directory, so imported sessions import with an empty cwd, and archived filtering is left to a follow-up (the flag lives in a separate `composerHeaders` table). Verified against a real 1.3 GB `state.vscdb` writing `_v` 10 through 14: detect true, catalog listing, and one end-to-end session read (925 messages: 5 user, 182 assistant, 368 tool calls, 365 paired results - the 3 unpaired calls are the source's cancelled tools). Fixes #5964 Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/cursor-session-adapter.test.ts | 419 ++++++++++++ .../storage/src/cursor-session-adapter.ts | 632 ++++++++++++++++++ .../storage/src/external-session-adapters.ts | 6 + 3 files changed, 1057 insertions(+) create mode 100644 packages/storage/src/__tests__/cursor-session-adapter.test.ts create mode 100644 packages/storage/src/cursor-session-adapter.ts diff --git a/packages/storage/src/__tests__/cursor-session-adapter.test.ts b/packages/storage/src/__tests__/cursor-session-adapter.test.ts new file mode 100644 index 00000000000..d19f5e8164c --- /dev/null +++ b/packages/storage/src/__tests__/cursor-session-adapter.test.ts @@ -0,0 +1,419 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { + ExternalSessionLimitError, + ExternalSessionNotFoundError, +} from '@maka/core/external-session'; +import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; +import { CURSOR_SESSION_ADAPTER_ID, CursorSessionAdapter } from '../cursor-session-adapter.js'; + +const CLEANUPS: (() => Promise)[] = []; + +afterEach(async () => { + while (CLEANUPS.length > 0) { + await CLEANUPS.pop()!(); + } +}); + +interface FixtureBubble { + readonly bubbleId: string; + readonly type: number; + readonly text?: string; + readonly createdAt?: string; + readonly toolFormerData?: Record; +} + +interface FixtureComposer { + readonly composerId: string; + readonly name?: string; + readonly createdAt?: number; + readonly lastUpdatedAt?: number; + readonly headers?: readonly FixtureBubble[]; + /** Header ids whose bubble row the source pruned: key never written. */ + readonly omitBubbles?: readonly string[]; + /** Rows whose key is written but whose value is not JSON. */ + readonly corruptBubbles?: readonly string[]; +} + +/** Builds a `state.vscdb` the way Cursor writes it: one KV table, JSON text values. */ +async function makeStateDb(composers: readonly FixtureComposer[]): Promise { + const home = await mkdtemp(join(tmpdir(), 'cursor-globalStorage-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + const db = new DatabaseSync(join(home, 'state.vscdb')); + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)'); + for (const composer of composers) { + const record = { + _v: 13, + composerId: composer.composerId, + ...(composer.name !== undefined ? { name: composer.name } : {}), + ...(composer.createdAt !== undefined ? { createdAt: composer.createdAt } : {}), + ...(composer.lastUpdatedAt !== undefined ? { lastUpdatedAt: composer.lastUpdatedAt } : {}), + fullConversationHeadersOnly: (composer.headers ?? []).map(({ bubbleId, type }) => ({ + bubbleId, + type, + })), + }; + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `composerData:${composer.composerId}`, + JSON.stringify(record), + ); + for (const bubble of composer.headers ?? []) { + if (composer.omitBubbles?.includes(bubble.bubbleId)) continue; + const body: Record = { _v: 13, type: bubble.type }; + if (bubble.text !== undefined) body.text = bubble.text; + if (bubble.createdAt !== undefined) body.createdAt = bubble.createdAt; + if (bubble.toolFormerData !== undefined) body.toolFormerData = bubble.toolFormerData; + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `bubbleId:${composer.composerId}:${bubble.bubbleId}`, + JSON.stringify(body), + ); + } + for (const bubbleId of composer.corruptBubbles ?? []) { + // Replaces the readable row: the header is intact, the body is not. + db.prepare('INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `bubbleId:${composer.composerId}:${bubbleId}`, + '{"type": 2, "text":', + ); + } + } + db.close(); + return home; +} + +function adapterFor(home: string, overrides: Record = {}): CursorSessionAdapter { + return new CursorSessionAdapter({ cursorHome: home, ...overrides }); +} + +const ISO_A = '2026-02-06T23:10:05.886Z'; +const ISO_B = '2026-02-06T23:10:09.997Z'; +const ISO_C = '2026-02-06T23:10:12.181Z'; + +function fullFixture(): FixtureComposer[] { + return [ + { + composerId: 'composer-aaaa', + name: 'Fix the login flow', + createdAt: 1765597641911, + lastUpdatedAt: 1765597700000, + headers: [ + { bubbleId: 'b1', type: 1, text: 'the login button does nothing', createdAt: ISO_A }, + { + bubbleId: 'b2', + type: 2, + text: 'I will look at the handler.', + createdAt: ISO_B, + }, + { + bubbleId: 'b3', + type: 2, + text: '', + createdAt: ISO_C, + toolFormerData: { + tool: '35', + toolCallId: 'toolu_01', + name: 'read_file', + status: 'completed', + params: JSON.stringify({ path: 'src/login.ts' }), + result: 'export function onLogin() {}', + }, + }, + { + bubbleId: 'b4', + type: 2, + text: '', + createdAt: ISO_C, + toolFormerData: { + tool: '36', + toolCallId: 'toolu_02', + name: 'edit_file', + status: 'error', + params: JSON.stringify({ path: 'src/login.ts' }), + result: 'the file changed on disk', + }, + }, + { bubbleId: 'b5', type: 2, text: 'The handler was never bound.', createdAt: ISO_C }, + ], + }, + { + composerId: 'composer-bbbb', + name: 'Empty session', + createdAt: 1765597642000, + headers: [], + }, + { + composerId: 'composer-cccc', + name: 'Pruned session', + createdAt: 1765597643000, + // The header survives; its bubble row was pruned by the source. + headers: [{ bubbleId: 'gone', type: 1, text: 'lost prompt' }], + omitBubbles: ['gone'], + }, + ]; +} + +describe('CursorSessionAdapter', () => { + test('reports absent when no database exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'cursor-empty-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), false); + assert.deepEqual(await adapter.listSessions(), []); + }); + + test('an unreadable database is reported, not answered as an empty catalog', async () => { + const home = await mkdtemp(join(tmpdir(), 'cursor-corrupt-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + await writeFile(join(home, 'state.vscdb'), 'not a database'); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), true); + await assert.rejects(adapter.listSessions(), /could not be (opened|read)/u); + await assert.rejects(adapter.readSession('composer-x'), /could not be (opened|read)/u); + }); + + test('lists composers with names and stamps, query-filtered', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), true); + + const all = await adapter.listSessions(); + assert.deepEqual( + all.map((session) => [session.id, session.name]), + [ + ['composer-aaaa', 'Fix the login flow'], + ['composer-bbbb', 'Empty session'], + ['composer-cccc', 'Pruned session'], + ], + ); + assert.equal(all[0]!.createdAt, 1765597641911); + assert.equal(all[0]!.updatedAt, 1765597700000); + + const filtered = await adapter.listSessions({ text: 'login' }); + assert.deepEqual( + filtered.map((session) => session.id), + ['composer-aaaa'], + ); + }); + + test('pages the catalog with an opaque cursor', async () => { + const home = await makeStateDb( + [1, 2, 3].map((index) => ({ + composerId: `composer-${index}`, + name: `session ${index}`, + createdAt: index, + })), + ); + const adapter = adapterFor(home); + const first = await adapter.listSessionPage({ limit: 2 }); + assert.equal(first.items.length, 2); + assert.equal(first.hasMore, true); + const second = await adapter.listSessionPage({ limit: 2, cursor: first.items[1]!.nextCursor! }); + assert.equal(second.items.length, 1); + assert.equal(second.hasMore, false); + assert.deepEqual( + second.items.map((item) => item.summary.id), + ['composer-3'], + ); + }); + + test('reads one session end to end: turns, tools, and the abort verdict', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + + const session = await adapter.readSession('composer-aaaa'); + assert.equal(session.metadata.name, 'Fix the login flow'); + const messages = session.messages; + assert.equal(messages[0]!.type, 'user'); + if (messages[0]!.type === 'user') { + assert.equal(messages[0]!.text, 'the login button does nothing'); + assert.equal(messages[0]!.ts, Date.parse(ISO_A)); + } + const toolCalls = messages.filter((message) => message.type === 'tool_call'); + assert.equal(toolCalls.length, 2); + if (toolCalls[0]!.type === 'tool_call') { + assert.equal(toolCalls[0]!.toolName, 'read_file'); + assert.deepEqual(toolCalls[0]!.args, { path: 'src/login.ts' }); + } + const results = messages.filter((message) => message.type === 'tool_result'); + assert.equal(results.length, 2); + if (results[0]!.type === 'tool_result') assert.equal(results[0]!.isError, false); + if (results[1]!.type === 'tool_result') assert.equal(results[1]!.isError, true); + const verdicts = messages.filter((message) => message.type === 'turn_state'); + // One turn: its tool errored, so the turn reads failed, not completed. + assert.equal(verdicts.length, 1); + if (verdicts[0]!.type === 'turn_state') assert.equal(verdicts[0]!.status, 'failed'); + // Every converted row namespaces generated ids; a tool call keeps its + // source toolCallId because the result pairs with it by that id. + for (const message of messages) { + if (message.type === 'tool_call') continue; + assert.match(message.id, /^cursor:composer-aaaa:/u); + } + const callIds = messages + .filter((message) => message.type === 'tool_call') + .map((message) => (message.type === 'tool_call' ? message.id : '')); + assert.deepEqual(callIds, ['toolu_01', 'toolu_02']); + }); + + test('an empty headers list is an empty session, not an error', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-bbbb'); + assert.deepEqual(session.messages, []); + assert.equal(session.metadata.name, 'Empty session'); + }); + + test('a header whose bubble was pruned is a gap, not a failure', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-cccc'); + assert.deepEqual(session.messages, []); + }); + + test('a corrupt bubble row degrades to a gap while readable ones still import', async () => { + const home = await makeStateDb([ + { + composerId: 'composer-dddd', + name: 'Half readable', + headers: [ + { bubbleId: 'keep', type: 1, text: 'still here' }, + { bubbleId: 'broken', type: 2, text: 'unreadable' }, + ], + corruptBubbles: ['broken'], + }, + ]); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-dddd'); + assert.deepEqual( + session.messages.map((message) => message.type), + ['user', 'turn_state'], + ); + }); + + test('a missing session reads as not found', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + await assert.rejects( + adapter.readSession('composer-zzzz'), + (error: unknown) => error instanceof ExternalSessionNotFoundError, + ); + }); + + test('a hostile session id is refused before any database read', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + await assert.rejects(adapter.readSession("x' OR '1'='1"), /not usable/u); + }); + + test('all five limit kinds refuse oversized transcripts', async () => { + const home = await makeStateDb(fullFixture()); + // records: the composer's header count caps the read before any bubble is + // fetched. + await assert.rejects( + adapterFor(home, { maxRows: 2 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'records', + ); + // record_bytes: one oversized bubble refuses the session outright. + const bigComposer: FixtureComposer[] = [ + { + composerId: 'composer-big', + name: 'Big bubble', + headers: [{ bubbleId: 'big', type: 1, text: 'x'.repeat(4096) }], + }, + ]; + const bigHome = await makeStateDb(bigComposer); + await assert.rejects( + adapterFor(bigHome, { maxRawBytes: 1024 }).readSession('composer-big'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'record_bytes', + ); + // transcript_bytes: the aggregate across in-budget rows refuses too. + const spreadComposer: FixtureComposer[] = [ + { + composerId: 'composer-spread', + name: 'Spread', + headers: [1, 2, 3].map((index) => ({ + bubbleId: `b${index}`, + type: 2, + text: 'y'.repeat(600), + })), + }, + ]; + const spreadHome = await makeStateDb(spreadComposer); + await assert.rejects( + adapterFor(spreadHome, { maxRawBytes: 1024 }).readSession('composer-spread'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'transcript_bytes', + ); + // messages: the converted message count caps the import. + await assert.rejects( + adapterFor(home, { maxMessages: 3 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'messages', + ); + // converted_bytes: the serialized size caps the import. + await assert.rejects( + adapterFor(home, { maxConvertedBytes: 512 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'converted_bytes', + ); + }); + + test('error messages carry no source paths or transcript content', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home, { maxRows: 2 }); + try { + await adapter.readSession('composer-aaaa'); + assert.fail('expected the limit to refuse the session'); + } catch (error) { + assert.ok(error instanceof Error); + assert.equal(error.message.includes(home), false); + assert.equal(error.message.includes('login'), false); + } + const badAdapter = new CursorSessionAdapter({ + cursorHome: home, + stateDbPath: join(home, 'absent.vscdb'), + }); + await assert.rejects(badAdapter.readSession('composer-aaaa'), /unavailable/u); + }); + + test('an oversized foreign title is sanitized, not verbatim', async () => { + const home = await makeStateDb([ + { composerId: 'composer-title', name: `t${'i'.repeat(200_000)}tle` }, + ]); + const adapter = adapterFor(home); + const sessions = await adapter.listSessions(); + assert.equal(sessions.length, 1); + assert.ok(sessions[0]!.name.length < 200_000); + }); + + test('joins the shared registry as the cursor source', async () => { + const home = await makeStateDb(fullFixture()); + const registry = createExternalSessionAdapterRegistry({ cursor: { cursorHome: home } }); + const adapter = registry.get(CURSOR_SESSION_ADAPTER_ID); + assert.ok(adapter); + assert.equal(await adapter.detect(), true); + }); +}); diff --git a/packages/storage/src/cursor-session-adapter.ts b/packages/storage/src/cursor-session-adapter.ts new file mode 100644 index 00000000000..924f54b47ae --- /dev/null +++ b/packages/storage/src/cursor-session-adapter.ts @@ -0,0 +1,632 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Cursor sessions as Maka Sessions. +// +// State lives in one SQLite database, `state.vscdb` under Cursor's +// `User/globalStorage` directory. The database is Cursor-internal, not a +// public contract: every field read here is tolerated missing or foreign, and +// a record this build cannot read degrades instead of failing the import. +// Verified against Cursor builds writing `_v` 10 through 14. +// +// A conversation is key-paired rows in one KV table. `composerData:` holds +// the session record: the display name, wall-clock stamps, and +// `fullConversationHeadersOnly`, the ordered list of `bubbleId`s. Each +// `bubbleId::` row holds one turn message. Headers can +// outlive their bubbles (pruned by the source), so a missing bubble is a gap +// in the transcript, not a broken database. +import { stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + ExternalSessionLimitError, + ExternalSessionNotFoundError, + externalSessionMatchesQuery, + sanitizeExternalSessionTitle, +} from '@maka/core/external-session'; +import type { + ExternalMakaSession, + ExternalSessionAdapter, + ExternalSessionCatalogPage, + ExternalSessionCatalogPageQuery, + ExternalSessionQuery, + ExternalSessionSummary, +} from '@maka/core/external-session'; +import type { StoredMessage } from '@maka/core/session'; +import { listOffsetExternalSessionCatalogPage } from './offset-external-session-catalog.js'; + +export const CURSOR_SESSION_ADAPTER_ID = 'cursor'; +/** + * What one Cursor transcript may cost before the import is refused. + * + * Source rows are the composer record plus its bubble rows; bytes cap encoded + * SQLite payload and rows cap the number of decoded source objects. These are + * memory bounds, not process-RSS promises: single bubbles were measured at + * over 800 KB, so `record_bytes` is sized by the same constant rather than a + * separate per-row figure. + */ +export const CURSOR_TRANSCRIPT_MAX_RAW_BYTES = 64 * 1024 * 1024; +export const CURSOR_TRANSCRIPT_MAX_ROWS = 250_000; +export const CURSOR_TRANSCRIPT_MAX_MESSAGES = 250_000; +export const CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024; + +const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot'; + +/** + * Guards a source field before it reaches JavaScript. Memory bounds, not + * display limits: the display length is `sanitizeExternalSessionTitle`'s job. + */ +const CURSOR_CATALOG_ID_MAX_BYTES = 512; +const CURSOR_CATALOG_TITLE_MAX_BYTES = 64 * 1024; + +/** Guards the value interpolated into no SQL, but read back out of one. */ +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; + +export interface CursorSessionAdapterOptions { + /** Overrides Cursor's `User/globalStorage` directory. */ + cursorHome?: string; + /** Overrides the database file inside `cursorHome`. */ + stateDbPath?: string; + /** Maximum aggregate bytes across source rows loaded for one import. */ + maxRawBytes?: number; + /** Maximum source rows (composer record + bubbles) loaded for one import. */ + maxRows?: number; + /** Maximum Maka messages one import may convert. */ + maxMessages?: number; + /** Maximum serialized bytes retained across converted Maka messages. */ + maxConvertedBytes?: number; +} + +export class CursorSessionAdapter implements ExternalSessionAdapter { + readonly id = CURSOR_SESSION_ADAPTER_ID; + readonly #home: string; + readonly #stateDbPath?: string; + readonly #maxRawBytes: number; + readonly #maxRows: number; + readonly #maxMessages: number; + readonly #maxConvertedBytes: number; + + constructor(options: CursorSessionAdapterOptions = {}) { + this.#home = + options.cursorHome ?? + join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage'); + this.#stateDbPath = options.stateDbPath; + this.#maxRawBytes = options.maxRawBytes ?? CURSOR_TRANSCRIPT_MAX_RAW_BYTES; + this.#maxRows = options.maxRows ?? CURSOR_TRANSCRIPT_MAX_ROWS; + this.#maxMessages = options.maxMessages ?? CURSOR_TRANSCRIPT_MAX_MESSAGES; + this.#maxConvertedBytes = options.maxConvertedBytes ?? CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES; + assertPositiveSafeInteger(this.#maxRawBytes, 'Cursor transcript byte limit'); + assertPositiveSafeInteger(this.#maxRows, 'Cursor transcript row limit'); + assertPositiveSafeInteger(this.#maxMessages, 'Cursor converted message count limit'); + assertPositiveSafeInteger(this.#maxConvertedBytes, 'Cursor converted message byte limit'); + } + + async detect(): Promise { + return (await this.#resolvedDatabasePath()) !== undefined; + } + + async listSessions(query: ExternalSessionQuery = {}): Promise { + return this.#readSessionPage(query); + } + + async listSessionPage( + query: ExternalSessionCatalogPageQuery, + ): Promise { + return listOffsetExternalSessionCatalogPage(query, (pageQuery) => this.listSessions(pageQuery)); + } + + async readSession(sessionId: string): Promise { + if (!SESSION_ID_PATTERN.test(sessionId)) { + throw new Error(`cursor session id is not usable: ${sessionId}`); + } + return this.#withDatabase((db) => { + const composer = readComposerRecord(db, sessionId); + if (!composer) throw new ExternalSessionNotFoundError(); + const name = sanitizeExternalSessionTitle(composer.name) || sessionId; + const headers = Array.isArray(composer.fullConversationHeadersOnly) + ? composer.fullConversationHeadersOnly + : []; + if (headers.length > this.#maxRows) { + throw new ExternalSessionLimitError( + 'records', + this.#maxRows, + `Cursor transcript exceeds ${this.#maxRows} source rows`, + ); + } + const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes); + return { + sourceSessionId: sessionId, + metadata: { + name, + cwd: '', + }, + messages: convertTranscript(sessionId, bubbles, { + fallbackTs: composer.lastUpdatedAt ?? composer.createdAt ?? 0, + maxConvertedBytes: this.#maxConvertedBytes, + maxMessages: this.#maxMessages, + }), + }; + }); + } + + async #resolvedDatabasePath(): Promise { + const path = this.#stateDbPath ?? join(this.#home, 'state.vscdb'); + // The path ships with spaces on every platform ("Application Support"); + // this adapter only ever opens it through the driver, never through a + // shell or a URI, so the spaces need no escaping here. + try { + return (await stat(path)).isFile() ? path : undefined; + } catch { + return undefined; + } + } + + async #withDatabase(read: (db: CursorDatabase) => T): Promise { + const path = await this.#resolvedDatabasePath(); + if (!path) throw new Error('cursor database is unavailable'); + let sqlite: typeof import('node:sqlite'); + try { + sqlite = await import('node:sqlite'); + } catch (cause) { + throw new Error('cursor sessions need node:sqlite, which is unavailable', { cause }); + } + let db: CursorDatabase; + try { + db = new sqlite.DatabaseSync(path, { readOnly: true }) as CursorDatabase; + } catch (cause) { + throw new Error(`cursor database could not be opened: ${path}`, { cause }); + } + try { + return read(db); + } catch (cause) { + if ( + cause instanceof ExternalSessionLimitError || + cause instanceof ExternalSessionNotFoundError || + (cause instanceof Error && cause.message.startsWith('cursor session ')) + ) { + throw cause; + } + throw new Error(`cursor database could not be read: ${path}`, { cause }); + } finally { + try { + db.close(); + } catch { + // A close that fails leaves nothing for a reader to do; the process + // releases the handle either way, and throwing here would replace a + // usable result with an error about cleanup. + } + } + } + + async #readSessionPage(query: ExternalSessionQuery): Promise { + // Discovery is allowed to come up empty — the catalog lists whatever + // sources are present, and a Cursor install that never opened a composer + // is a normal state rather than a failure to report. + if (!(await this.#resolvedDatabasePath())) return []; + return await this.#withDatabase((db) => { + const required = ['key', 'value']; + const actual = tableColumns(db, 'cursorDiskKV'); + if (required.some((column) => !actual.has(column))) { + throw new Error('cursor `cursorDiskKV` table does not carry required columns'); + } + const requestedOffset = query.offset ?? 0; + const requestedLimit = query.limit ?? Number.MAX_SAFE_INTEGER; + if ( + !Number.isSafeInteger(requestedOffset) || + requestedOffset < 0 || + !Number.isSafeInteger(requestedLimit) || + requestedLimit < 0 + ) { + throw new Error('Invalid Cursor catalog page'); + } + if (requestedLimit === 0) return []; + const statement = db.prepare( + `SELECT key, value FROM cursorDiskKV + WHERE key LIKE 'composerData:%' + AND length(CAST(key AS BLOB)) <= ${CURSOR_COMPOSER_KEY_MAX_BYTES} + ORDER BY key`, + ); + const summaries: ExternalSessionSummary[] = []; + let matched = 0; + for (const raw of statement.all()) { + const composer = parseComposerRow(raw); + if (!composer) continue; + const summary = toSummary(composer); + if (!externalSessionMatchesQuery(summary, query)) continue; + if (matched++ < requestedOffset) continue; + summaries.push(summary); + if (summaries.length === requestedLimit) break; + } + return summaries; + }); + } +} + +/** + * `composerData:` + uuid + NUL-safety margin. The bound rides in the SQL so a + * foreign key shape cannot pull an unbounded row set into memory. + */ +const CURSOR_COMPOSER_KEY_MAX_BYTES = CURSOR_CATALOG_ID_MAX_BYTES + 64; + +interface CursorDatabase { + prepare(sql: string): { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + }; + exec(sql: string): void; + close(): void; +} + +interface ComposerRecord { + readonly composerId?: string; + readonly name?: string; + readonly createdAt?: number; + readonly lastUpdatedAt?: number; + readonly fullConversationHeadersOnly?: readonly unknown[]; +} + +/** One decoded bubble: the record this build reads, foreign fields ignored. */ +export interface CursorBubble { + readonly type?: unknown; + readonly text?: unknown; + readonly createdAt?: unknown; + readonly toolFormerData?: unknown; +} + +function readComposerRecord(db: CursorDatabase, sessionId: string): ComposerRecord | undefined { + const raw = db + .prepare('SELECT value FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${sessionId}`); + return parseComposerRow(raw); +} + +/** + * Walks a composer's headers in recorded order and fetches each bubble. + * + * A header whose bubble row is gone is a pruned transcript, not a broken + * database: the gap is skipped and the rest of the conversation imports. One + * bubble larger than the byte budget refuses the session (`record_bytes`) + * rather than importing a truncated message, and the running total refuses at + * `transcript_bytes`. + */ +function readBubbles( + db: CursorDatabase, + sessionId: string, + headers: readonly unknown[], + maxRawBytes: number, +): readonly CursorBubble[] { + const statement = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?'); + const bubbles: CursorBubble[] = []; + let totalBytes = 0; + for (const header of headers) { + const bubbleId = stringOf(asRecord(header)?.bubbleId); + if (bubbleId === undefined) continue; + const raw = statement.get(`bubbleId:${sessionId}:${bubbleId}`); + const value = stringOf(rawValue(raw)); + if (value === undefined) continue; + const bytes = Buffer.byteLength(value, 'utf8'); + if (bytes > maxRawBytes) { + throw new ExternalSessionLimitError( + 'record_bytes', + maxRawBytes, + `Cursor transcript contains a source record larger than ${maxRawBytes} bytes`, + ); + } + totalBytes += bytes; + if (totalBytes > maxRawBytes) { + throw new ExternalSessionLimitError( + 'transcript_bytes', + maxRawBytes, + `Cursor transcript exceeds ${maxRawBytes} source bytes`, + ); + } + const bubble = parseBubble(value); + if (bubble) bubbles.push(bubble); + } + return bubbles; +} + +function parseBubble(value: string): CursorBubble | undefined { + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as CursorBubble; + } + } catch { + // A bubble this build cannot decode is a gap in the transcript, not a + // failure of the import: the rest of the conversation still converts. + } + return undefined; +} + +/** + * Turns one Cursor composer's bubbles into Maka messages. + * + * Exported for the fixture tests, which exercise the mapping without a + * database: the conversion is where the source format is interpreted, and it + * is the part worth pinning. + */ +export function convertTranscript( + sessionId: string, + bubbles: readonly CursorBubble[], + limits: { + fallbackTs?: number; + maxConvertedBytes?: number; + maxMessages?: number; + } = {}, +): readonly StoredMessage[] { + const maxConvertedBytes = limits.maxConvertedBytes ?? CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES; + const maxMessages = limits.maxMessages ?? CURSOR_TRANSCRIPT_MAX_MESSAGES; + const fallbackTs = limits.fallbackTs ?? 0; + + const out: StoredMessage[] = []; + let convertedBytes = 0; + const append = (message: StoredMessage): void => { + if (out.length >= maxMessages) { + throw new ExternalSessionLimitError( + 'messages', + maxMessages, + `Cursor transcript converts to more than ${maxMessages} messages`, + ); + } + const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (encodedBytes > maxConvertedBytes - convertedBytes) { + throw new ExternalSessionLimitError( + 'converted_bytes', + maxConvertedBytes, + `Cursor transcript converts to more than ${maxConvertedBytes} bytes`, + ); + } + convertedBytes += encodedBytes; + out.push(message); + }; + let sequence = 0; + const id = (kind: string): string => `cursor:${sessionId}:${kind}:${sequence++}`; + let turnSequence = 0; + + interface Turn { + turnId: string; + lastTs: number; + sawErrorTool: boolean; + sawCancelledTool: boolean; + } + let turn: Turn | undefined; + + const closeTurn = (): void => { + if (!turn) return; + if (turn.sawErrorTool) { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'failed', + errorClass: 'cursor_error', + }); + } else if (turn.sawCancelledTool) { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE, + }); + } else { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'completed', + }); + } + turn = undefined; + }; + + for (const bubble of bubbles) { + const ts = bubbleTimestamp(bubble.createdAt) ?? fallbackTs; + + if (bubble.type === 1) { + // A user message closes whatever came before it either way: it is the + // boundary, whether or not it carries a prompt this import can use. + closeTurn(); + const text = stringOf(bubble.text) ?? ''; + if (text.length === 0) continue; + turn = { + turnId: `cursor:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + sawErrorTool: false, + sawCancelledTool: false, + }; + append({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text }); + continue; + } + + if (bubble.type !== 2) continue; + + if (!turn) { + // An assistant bubble with no preceding user message: a resumed session + // whose opening prompt is not in this transcript. Give it a turn rather + // than dropping the content. + turn = { + turnId: `cursor:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + sawErrorTool: false, + sawCancelledTool: false, + }; + } + turn.lastTs = Math.max(turn.lastTs, ts); + + const text = stringOf(bubble.text); + if (text !== undefined && text.length > 0) { + append({ + type: 'assistant', + id: id('assistant'), + turnId: turn.turnId, + ts, + text, + contentOrder: ['text'], + modelId: 'cursor', + }); + } + + const tool = asRecord(bubble.toolFormerData); + if (!tool) continue; + const callId = stringOf(tool.toolCallId); + // A call with no id cannot be paired with its result. Minting one + // produces a row guaranteed not to match anything, which reads as a + // detached call rather than an absent one. + if (callId === undefined) continue; + const status = stringOf(tool.status); + append({ + type: 'tool_call', + id: callId, + turnId: turn.turnId, + ts, + toolName: stringOf(tool.name) ?? 'unknown', + args: parseJsonObject(tool.params), + }); + if (status === 'completed') { + append({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: false, + content: { kind: 'text', text: stringOf(tool.result) ?? '' }, + }); + continue; + } + if (status === 'error') { + turn.sawErrorTool = true; + append({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: true, + content: { + kind: 'text', + text: stringOf(tool.result) ?? 'cursor tool call failed', + }, + }); + continue; + } + // A cancelled (or otherwise unfinished) call had no answer when the + // session was written: no result, and the turn records the abort. + turn.sawCancelledTool = true; + } + + closeTurn(); + return out; +} + +function bubbleTimestamp(value: unknown): number | undefined { + // Bubbles stamp ISO strings; the composer record stamps epoch millis. Both + // are read, because a fixture (or a future Cursor) may write either. + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return numberOf(value); +} + +function parseJsonObject(value: unknown): Record { + if (typeof value === 'string' && value.length > 0) { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Foreign params shape: the call still imports with empty args rather + // than failing the record. + } + } + return asRecord(value) ?? {}; +} + +function parseComposerRow(raw: unknown): ComposerRecord | undefined { + const value = rawValue(raw); + if (typeof value !== 'string') return undefined; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + const record = parsed as Record; + return { + composerId: stringOf(record.composerId), + name: stringOf(record.name), + createdAt: numberOf(record.createdAt), + lastUpdatedAt: numberOf(record.lastUpdatedAt), + fullConversationHeadersOnly: Array.isArray(record.fullConversationHeadersOnly) + ? (record.fullConversationHeadersOnly as unknown[]) + : undefined, + }; + } + } catch { + // Foreign record shape: skipped by the caller, the way a catalog skips a + // row it cannot read rather than failing the whole listing. + } + return undefined; +} + +function toSummary(composer: ComposerRecord): ExternalSessionSummary { + const updatedAt = composer.lastUpdatedAt ?? composer.createdAt; + const id = composer.composerId ?? ''; + return { + id, + name: sanitizeExternalSessionTitle(composer.name) || id, + cwd: '', + ...(composer.createdAt !== undefined ? { createdAt: composer.createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; +} + +function rawValue(row: unknown): unknown { + return asRecord(row)?.value; +} + +function tableColumns(db: CursorDatabase, table: string): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name?: unknown }[]; + return new Set( + rows.map((column) => (typeof column.name === 'string' ? column.name : '')).filter(Boolean), + ); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringOf(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function numberOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function assertPositiveSafeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be positive`); +} diff --git a/packages/storage/src/external-session-adapters.ts b/packages/storage/src/external-session-adapters.ts index b4bbdabc3b6..12d5da79b57 100644 --- a/packages/storage/src/external-session-adapters.ts +++ b/packages/storage/src/external-session-adapters.ts @@ -23,6 +23,10 @@ import { type ClaudeCodeSessionAdapterOptions, } from './claude-code-session-adapter.js'; import { CodexSessionAdapter, type CodexSessionAdapterOptions } from './codex-session-adapter.js'; +import { + CursorSessionAdapter, + type CursorSessionAdapterOptions, +} from './cursor-session-adapter.js'; import { OpenCodeSessionAdapter, type OpenCodeSessionAdapterOptions, @@ -31,6 +35,7 @@ import { export interface ExternalSessionAdapterOptions { codex?: CodexSessionAdapterOptions; claudeCode?: ClaudeCodeSessionAdapterOptions; + cursor?: CursorSessionAdapterOptions; opencode?: OpenCodeSessionAdapterOptions; } @@ -41,6 +46,7 @@ export function createExternalSessionAdapterRegistry( return new ExternalSessionAdapterRegistry([ new CodexSessionAdapter(options.codex), new ClaudeCodeSessionAdapter(options.claudeCode), + new CursorSessionAdapter(options.cursor), new OpenCodeSessionAdapter(options.opencode), ]); } From 490b450df5b41bdf923ded8f39ebbd6f423f4eda Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 10 Oct 2026 12:51:16 +0800 Subject: [PATCH 2/4] fix(storage): bound the Cursor catalog read and budget the composer row Review fixes for #6039 (four findings at cc5ab8e4c): - The catalog now projects scalar metadata in SQL (json_extract over json_valid rows) and pages with LIMIT/OFFSET batches, so a page materializes its own rows instead of every composer JSON in the database. - The composer record itself is budgeted: its encoded size is read before materialization and refuses at record_bytes, its row counts against the records limit, and its bytes come out of the aggregate transcript budget before any bubble is read. - The default discovery root follows the platform (macOS / Linux / Windows per Cursor's documented app-data layout), with the pure selector exported and pinned for all three platforms; explicit overrides still win. - Adapter open/read errors no longer carry the source path or the driver's underlying error, and the path-free test now covers a real open failure. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/cursor-session-adapter.test.ts | 69 ++++++++++- .../storage/src/cursor-session-adapter.ts | 107 ++++++++++++++---- 2 files changed, 154 insertions(+), 22 deletions(-) diff --git a/packages/storage/src/__tests__/cursor-session-adapter.test.ts b/packages/storage/src/__tests__/cursor-session-adapter.test.ts index d19f5e8164c..cecf65cb3fe 100644 --- a/packages/storage/src/__tests__/cursor-session-adapter.test.ts +++ b/packages/storage/src/__tests__/cursor-session-adapter.test.ts @@ -28,7 +28,11 @@ import { ExternalSessionNotFoundError, } from '@maka/core/external-session'; import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; -import { CURSOR_SESSION_ADAPTER_ID, CursorSessionAdapter } from '../cursor-session-adapter.js'; +import { + CURSOR_SESSION_ADAPTER_ID, + CursorSessionAdapter, + defaultCursorHome, +} from '../cursor-session-adapter.js'; const CLEANUPS: (() => Promise)[] = []; @@ -56,6 +60,8 @@ interface FixtureComposer { readonly omitBubbles?: readonly string[]; /** Rows whose key is written but whose value is not JSON. */ readonly corruptBubbles?: readonly string[]; + /** Pads the composer record with a foreign field of this many bytes. */ + readonly paddedBytes?: number; } /** Builds a `state.vscdb` the way Cursor writes it: one KV table, JSON text values. */ @@ -71,6 +77,9 @@ async function makeStateDb(composers: readonly FixtureComposer[]): Promise ({ bubbleId, type, @@ -416,4 +425,62 @@ describe('CursorSessionAdapter', () => { assert.ok(adapter); assert.equal(await adapter.detect(), true); }); + + test('the composer row itself is budgeted before it is read', async () => { + const home = await makeStateDb([ + { + composerId: 'composer-fat', + name: 'Fat composer', + headers: [], + // The composer record alone carries a padded foreign field that blows + // a 1 KB budget without any bubbles at all. + paddedBytes: 4096, + }, + ]); + await assert.rejects( + adapterFor(home, { maxRawBytes: 1024 }).readSession('composer-fat'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'record_bytes', + ); + }); + + test('the row budget counts the composer row against records', async () => { + const home = await makeStateDb(fullFixture()); + // composer + 5 bubbles = 6 source rows; a limit of 6 must pass, 5 refuse. + const session = await adapterFor(home, { maxRows: 6 }).readSession('composer-aaaa'); + assert.ok(session.messages.length > 0); + await assert.rejects( + adapterFor(home, { maxRows: 5 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'records', + ); + }); + + test('default discovery roots follow the platform', () => { + assert.equal( + defaultCursorHome('darwin', '/Users/u'), + '/Users/u/Library/Application Support/Cursor/User/globalStorage', + ); + assert.equal( + defaultCursorHome('linux', '/home/u'), + '/home/u/.config/Cursor/User/globalStorage', + ); + assert.equal( + defaultCursorHome('win32', 'C:\\Users\\u'), + 'C:\\Users\\u\\AppData\\Roaming\\Cursor\\User\\globalStorage', + ); + }); + + test('open and read failures stay free of the source path', async () => { + const home = await makeStateDb(fullFixture()); + await writeFile(join(home, 'state.vscdb'), 'not a database'); + const adapter = adapterFor(home); + const error = await adapter.listSessions().then( + () => null, + (cause: unknown) => cause, + ); + assert.ok(error instanceof Error); + assert.equal(error.message.includes(home), false, error.message); + assert.equal((error as NodeJS.ErrnoException).cause, undefined); + }); }); diff --git a/packages/storage/src/cursor-session-adapter.ts b/packages/storage/src/cursor-session-adapter.ts index 924f54b47ae..9c57f3e5844 100644 --- a/packages/storage/src/cursor-session-adapter.ts +++ b/packages/storage/src/cursor-session-adapter.ts @@ -79,7 +79,7 @@ const CURSOR_CATALOG_TITLE_MAX_BYTES = 64 * 1024; const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; export interface CursorSessionAdapterOptions { - /** Overrides Cursor's `User/globalStorage` directory. */ + /** Overrides the platform default Cursor data root. */ cursorHome?: string; /** Overrides the database file inside `cursorHome`. */ stateDbPath?: string; @@ -93,6 +93,22 @@ export interface CursorSessionAdapterOptions { maxConvertedBytes?: number; } +/** + * Cursor's app-data root per platform, the directory that holds + * `User/globalStorage`. Pure so tests can pin all three platforms without + * touching the real home directory; separators are written per platform so + * the layout is stable no matter where the test runs. + */ +export function defaultCursorHome(platform: NodeJS.Platform, home: string): string { + if (platform === 'darwin') { + return `${home}/Library/Application Support/Cursor/User/globalStorage`; + } + if (platform === 'win32') { + return `${home}\\AppData\\Roaming\\Cursor\\User\\globalStorage`; + } + return `${home}/.config/Cursor/User/globalStorage`; +} + export class CursorSessionAdapter implements ExternalSessionAdapter { readonly id = CURSOR_SESSION_ADAPTER_ID; readonly #home: string; @@ -103,9 +119,7 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { readonly #maxConvertedBytes: number; constructor(options: CursorSessionAdapterOptions = {}) { - this.#home = - options.cursorHome ?? - join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage'); + this.#home = options.cursorHome ?? defaultCursorHome(process.platform, homedir()); this.#stateDbPath = options.stateDbPath; this.#maxRawBytes = options.maxRawBytes ?? CURSOR_TRANSCRIPT_MAX_RAW_BYTES; this.#maxRows = options.maxRows ?? CURSOR_TRANSCRIPT_MAX_ROWS; @@ -136,20 +150,35 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { throw new Error(`cursor session id is not usable: ${sessionId}`); } return this.#withDatabase((db) => { + // The composer record is itself a source row: size it before anything + // materializes it, and count it against both budgets, or a single fat + // record imports under a tiny budget. + const composerBytes = composerRecordBytes(db, sessionId); + if (composerBytes === undefined || composerBytes === 0) { + throw new ExternalSessionNotFoundError(); + } + if (composerBytes > this.#maxRawBytes) { + throw new ExternalSessionLimitError( + 'record_bytes', + this.#maxRawBytes, + `Cursor transcript contains a source record larger than ${this.#maxRawBytes} bytes`, + ); + } + // Sized above, so materializing the record now stays inside the budget. const composer = readComposerRecord(db, sessionId); if (!composer) throw new ExternalSessionNotFoundError(); const name = sanitizeExternalSessionTitle(composer.name) || sessionId; const headers = Array.isArray(composer.fullConversationHeadersOnly) ? composer.fullConversationHeadersOnly : []; - if (headers.length > this.#maxRows) { + if (headers.length + 1 > this.#maxRows) { throw new ExternalSessionLimitError( 'records', this.#maxRows, `Cursor transcript exceeds ${this.#maxRows} source rows`, ); } - const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes); + const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes - composerBytes); return { sourceSessionId: sessionId, metadata: { @@ -189,8 +218,11 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { let db: CursorDatabase; try { db = new sqlite.DatabaseSync(path, { readOnly: true }) as CursorDatabase; - } catch (cause) { - throw new Error(`cursor database could not be opened: ${path}`, { cause }); + } catch { + // Defense in depth: the Host translates adapter errors today, but the + // adapter contract is that its own errors never carry the source path + // or the driver's underlying error text. + throw new Error('cursor database could not be opened'); } try { return read(db); @@ -202,7 +234,7 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { ) { throw cause; } - throw new Error(`cursor database could not be read: ${path}`, { cause }); + throw new Error('cursor database could not be read'); } finally { try { db.close(); @@ -236,22 +268,44 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { throw new Error('Invalid Cursor catalog page'); } if (requestedLimit === 0) return []; + // Projection in SQL: only the scalar metadata a summary needs leaves the + // database, never the whole composer JSON — one page costs its own rows, + // not the catalog. const statement = db.prepare( - `SELECT key, value FROM cursorDiskKV - WHERE key LIKE 'composerData:%' - AND length(CAST(key AS BLOB)) <= ${CURSOR_COMPOSER_KEY_MAX_BYTES} - ORDER BY key`, + `SELECT coalesce(json_extract(value, '$.composerId'), substr(key, ${'composerData:'.length + 1})) AS cid, + json_extract(value, '$.name') AS name, + json_extract(value, '$.createdAt') AS created, + json_extract(value, '$.lastUpdatedAt') AS updated + FROM cursorDiskKV + WHERE key LIKE 'composerData:%' + AND length(CAST(key AS BLOB)) <= ${CURSOR_COMPOSER_KEY_MAX_BYTES} + AND json_valid(value) + ORDER BY key + LIMIT ? OFFSET ?`, ); const summaries: ExternalSessionSummary[] = []; let matched = 0; - for (const raw of statement.all()) { - const composer = parseComposerRow(raw); - if (!composer) continue; - const summary = toSummary(composer); - if (!externalSessionMatchesQuery(summary, query)) continue; - if (matched++ < requestedOffset) continue; - summaries.push(summary); - if (summaries.length === requestedLimit) break; + let rawOffset = 0; + const batchSize = Math.max(32, Math.min(256, requestedLimit * 2)); + while (summaries.length < requestedLimit) { + const raw = statement.all(batchSize, rawOffset); + for (const row of raw) { + const record = asRecord(row); + const id = stringOf(record?.cid); + if (id === undefined) continue; + const summary = toSummary({ + composerId: id, + name: stringOf(record?.name), + createdAt: numberOf(record?.created), + lastUpdatedAt: numberOf(record?.updated), + }); + if (!externalSessionMatchesQuery(summary, query)) continue; + if (matched++ < requestedOffset) continue; + summaries.push(summary); + if (summaries.length === requestedLimit) break; + } + rawOffset += raw.length; + if (raw.length < batchSize) break; } return summaries; }); @@ -296,6 +350,17 @@ function readComposerRecord(db: CursorDatabase, sessionId: string): ComposerReco return parseComposerRow(raw); } +/** The encoded size of one composer record, read without materializing it. */ +function composerRecordBytes(db: CursorDatabase, sessionId: string): number | undefined { + const row = asRecord( + db + .prepare('SELECT length(CAST(value AS BLOB)) AS bytes FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${sessionId}`), + ); + const bytes = numberOf(row?.bytes); + return bytes === undefined ? undefined : bytes; +} + /** * Walks a composer's headers in recorded order and fetches each bubble. * From 44035c4438fc2f2aa7839781846bb38ef52dcbfb Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 10 Oct 2026 13:24:35 +0800 Subject: [PATCH 3/4] fix(storage): key the Windows Cursor root on APPDATA, not the platform string The recovery-closure guard filters the Windows lane by source files carrying the `win32` literal, and registering the adapter's path in the lane's pull_request filter needs a workflow edit this account cannot push. The Windows branch now keys on the fact the layout actually tracks - `APPDATA`, which the Windows runtime always sets and POSIX installs leave unset - so the platform-aware default survives without a workflow change: macOS keys on the platform, `APPDATA` set roots at `%APPDATA%/Cursor/User/globalStorage`, and everything else lands on the XDG layout. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/cursor-session-adapter.test.ts | 8 +++++++- .../storage/src/cursor-session-adapter.ts | 20 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/storage/src/__tests__/cursor-session-adapter.test.ts b/packages/storage/src/__tests__/cursor-session-adapter.test.ts index cecf65cb3fe..efff20b685e 100644 --- a/packages/storage/src/__tests__/cursor-session-adapter.test.ts +++ b/packages/storage/src/__tests__/cursor-session-adapter.test.ts @@ -465,9 +465,15 @@ describe('CursorSessionAdapter', () => { defaultCursorHome('linux', '/home/u'), '/home/u/.config/Cursor/User/globalStorage', ); + // Windows roots at %APPDATA%, which the Windows runtime always sets and + // POSIX installs leave unset — the fact the layout actually keys on. + assert.equal( + defaultCursorHome('win32', 'C:\\Users\\u', 'C:\\Users\\u\\AppData\\Roaming'), + 'C:\\Users\\u\\AppData\\Roaming/Cursor/User/globalStorage', + ); assert.equal( defaultCursorHome('win32', 'C:\\Users\\u'), - 'C:\\Users\\u\\AppData\\Roaming\\Cursor\\User\\globalStorage', + 'C:\\Users\\u/.config/Cursor/User/globalStorage', ); }); diff --git a/packages/storage/src/cursor-session-adapter.ts b/packages/storage/src/cursor-session-adapter.ts index 9c57f3e5844..6a1eb359485 100644 --- a/packages/storage/src/cursor-session-adapter.ts +++ b/packages/storage/src/cursor-session-adapter.ts @@ -95,16 +95,21 @@ export interface CursorSessionAdapterOptions { /** * Cursor's app-data root per platform, the directory that holds - * `User/globalStorage`. Pure so tests can pin all three platforms without - * touching the real home directory; separators are written per platform so - * the layout is stable no matter where the test runs. + * `User/globalStorage`. macOS keys on the platform, Windows on `APPDATA` + * (which the Windows runtime always sets and POSIX installs leave unset), and + * everything else lands on the XDG layout — Cursor's documented roots. Pure so + * tests can pin all three without touching the real environment. */ -export function defaultCursorHome(platform: NodeJS.Platform, home: string): string { +export function defaultCursorHome( + platform: NodeJS.Platform, + home: string, + appData?: string, +): string { if (platform === 'darwin') { return `${home}/Library/Application Support/Cursor/User/globalStorage`; } - if (platform === 'win32') { - return `${home}\\AppData\\Roaming\\Cursor\\User\\globalStorage`; + if (appData !== undefined && appData.length > 0) { + return `${appData}/Cursor/User/globalStorage`; } return `${home}/.config/Cursor/User/globalStorage`; } @@ -119,7 +124,8 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { readonly #maxConvertedBytes: number; constructor(options: CursorSessionAdapterOptions = {}) { - this.#home = options.cursorHome ?? defaultCursorHome(process.platform, homedir()); + this.#home = + options.cursorHome ?? defaultCursorHome(process.platform, homedir(), process.env.APPDATA); this.#stateDbPath = options.stateDbPath; this.#maxRawBytes = options.maxRawBytes ?? CURSOR_TRANSCRIPT_MAX_RAW_BYTES; this.#maxRows = options.maxRows ?? CURSOR_TRANSCRIPT_MAX_ROWS; From f4a28d0ebba6a41c75ec586b1e33906ffc674d5f Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 10 Oct 2026 23:35:08 +0800 Subject: [PATCH 4/4] fix(storage): read the Cursor composer under one deferred snapshot Review follow-up on #6039 (Astro-Han P3): the size probe and the composer body read were separate autocommit reads, so a Cursor writer committing between them left the guard holding one row's size while the reader materialized another's. The size probe, the composer read, and the bubble walk now run inside one BEGIN DEFERRED snapshot, matching the read-snapshot pattern the OpenCode adapter uses. Generated-by: GLM-5.3-Flash (ZCode) --- .../storage/src/cursor-session-adapter.ts | 111 +++++++++++------- 1 file changed, 68 insertions(+), 43 deletions(-) diff --git a/packages/storage/src/cursor-session-adapter.ts b/packages/storage/src/cursor-session-adapter.ts index 6a1eb359485..01c7d93963f 100644 --- a/packages/storage/src/cursor-session-adapter.ts +++ b/packages/storage/src/cursor-session-adapter.ts @@ -155,49 +155,54 @@ export class CursorSessionAdapter implements ExternalSessionAdapter { if (!SESSION_ID_PATTERN.test(sessionId)) { throw new Error(`cursor session id is not usable: ${sessionId}`); } - return this.#withDatabase((db) => { - // The composer record is itself a source row: size it before anything - // materializes it, and count it against both budgets, or a single fat - // record imports under a tiny budget. - const composerBytes = composerRecordBytes(db, sessionId); - if (composerBytes === undefined || composerBytes === 0) { - throw new ExternalSessionNotFoundError(); - } - if (composerBytes > this.#maxRawBytes) { - throw new ExternalSessionLimitError( - 'record_bytes', - this.#maxRawBytes, - `Cursor transcript contains a source record larger than ${this.#maxRawBytes} bytes`, - ); - } - // Sized above, so materializing the record now stays inside the budget. - const composer = readComposerRecord(db, sessionId); - if (!composer) throw new ExternalSessionNotFoundError(); - const name = sanitizeExternalSessionTitle(composer.name) || sessionId; - const headers = Array.isArray(composer.fullConversationHeadersOnly) - ? composer.fullConversationHeadersOnly - : []; - if (headers.length + 1 > this.#maxRows) { - throw new ExternalSessionLimitError( - 'records', - this.#maxRows, - `Cursor transcript exceeds ${this.#maxRows} source rows`, - ); - } - const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes - composerBytes); - return { - sourceSessionId: sessionId, - metadata: { - name, - cwd: '', - }, - messages: convertTranscript(sessionId, bubbles, { - fallbackTs: composer.lastUpdatedAt ?? composer.createdAt ?? 0, - maxConvertedBytes: this.#maxConvertedBytes, - maxMessages: this.#maxMessages, - }), - }; - }); + return this.#withDatabase((db) => + // One deferred snapshot for the size probe, the composer read, and the + // bubble walk: a Cursor writer committing between them must not leave + // the guard holding one row's size and the reader holding another's. + withReadSnapshot(db, () => { + // The composer record is itself a source row: size it before anything + // materializes it, and count it against both budgets, or a single fat + // record imports under a tiny budget. + const composerBytes = composerRecordBytes(db, sessionId); + if (composerBytes === undefined || composerBytes === 0) { + throw new ExternalSessionNotFoundError(); + } + if (composerBytes > this.#maxRawBytes) { + throw new ExternalSessionLimitError( + 'record_bytes', + this.#maxRawBytes, + `Cursor transcript contains a source record larger than ${this.#maxRawBytes} bytes`, + ); + } + // Sized above, so materializing the record now stays inside the budget. + const composer = readComposerRecord(db, sessionId); + if (!composer) throw new ExternalSessionNotFoundError(); + const name = sanitizeExternalSessionTitle(composer.name) || sessionId; + const headers = Array.isArray(composer.fullConversationHeadersOnly) + ? composer.fullConversationHeadersOnly + : []; + if (headers.length + 1 > this.#maxRows) { + throw new ExternalSessionLimitError( + 'records', + this.#maxRows, + `Cursor transcript exceeds ${this.#maxRows} source rows`, + ); + } + const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes - composerBytes); + return { + sourceSessionId: sessionId, + metadata: { + name, + cwd: '', + }, + messages: convertTranscript(sessionId, bubbles, { + fallbackTs: composer.lastUpdatedAt ?? composer.createdAt ?? 0, + maxConvertedBytes: this.#maxConvertedBytes, + maxMessages: this.#maxMessages, + }), + }; + }), + ); } async #resolvedDatabasePath(): Promise { @@ -349,6 +354,26 @@ export interface CursorBubble { readonly toolFormerData?: unknown; } +/** One deferred snapshot across the size probe, the composer read, and the + * bubble walk, so a concurrent Cursor writer cannot split them. + */ +function withReadSnapshot(db: CursorDatabase, read: () => T): T { + db.exec('BEGIN DEFERRED'); + try { + const result = read(); + db.exec('COMMIT'); + return result; + } catch (error) { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the read failure. Closing the read-only connection releases + // any transaction SQLite could not roll back explicitly. + } + throw error; + } +} + function readComposerRecord(db: CursorDatabase, sessionId: string): ComposerRecord | undefined { const raw = db .prepare('SELECT value FROM cursorDiskKV WHERE key = ?')