fix(server/providers): harden and correct session history normalization/pagination

Address correctness and safety issues in provider session adapters while
preserving existing normalized message shapes.

Claude sessions:
- Ensure user text content parts generate unique normalized message ids.
- Replace duplicate `${baseId}_text` ids with index-suffixed ids to avoid
  collisions when one user message contains multiple text segments.

Cursor sessions:
- Add session id sanitization before constructing SQLite paths to prevent
  path traversal via crafted session ids.
- Enforce containment by resolving the computed DB path and asserting it stays
  under ~/.cursor/chats/<cwdId>.
- Refactor blob parsing to a two-pass flow: first build blobMap and collect
  JSON blobs, then parse binary parent refs against the fully populated map.
- Fix pagination semantics so limit=0 returns an empty page instead of full
  history, with consistent total/hasMore/offset/limit metadata.

Gemini sessions:
- Honor FetchHistoryOptions pagination by reading limit/offset and slicing
  normalized history accordingly.
- Return consistent hasMore/offset/limit metadata for paged responses.

Validation:
- eslint passed for touched files.
- server TypeScript check passed (tsc --noEmit -p server/tsconfig.json).
This commit is contained in:
Haileyesus
2026-04-21 13:24:52 +03:00
parent d297dd2271
commit 1d885076e9
3 changed files with 81 additions and 33 deletions

View File

@@ -70,7 +70,8 @@ export class ClaudeSessionsProvider implements IProviderSessions {
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (const part of raw.message.content) {
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
const part = raw.message.content[partIndex];
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
@@ -88,7 +89,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
id: `${baseId}_text_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,

View File

@@ -25,6 +25,24 @@ type CursorMessageBlob = {
content: AnyRecord;
};
function sanitizeCursorSessionId(sessionId: string): string {
const normalized = sessionId.trim();
if (!normalized) {
throw new Error('Cursor session id is required.');
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid cursor session id "${sessionId}".`);
}
return normalized;
}
export class CursorSessionsProvider implements IProviderSessions {
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
@@ -35,9 +53,17 @@ export class CursorSessionsProvider implements IProviderSessions {
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const safeSessionId = sanitizeCursorSessionId(sessionId);
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
const resolvedStoreDbPath = path.resolve(storeDbPath);
const relativeStorePath = path.relative(resolvedBaseChatsPath, resolvedStoreDbPath);
if (relativeStorePath.startsWith('..') || path.isAbsolute(relativeStorePath)) {
throw new Error(`Invalid cursor session path for "${sessionId}".`);
}
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
const db = new Database(resolvedStoreDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all();
@@ -57,28 +83,35 @@ export class CursorSessionsProvider implements IProviderSessions {
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
} else if (blob.data) {
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
for (const blob of allBlobs) {
if (!blob.data || blob.data[0] === 0x7B) {
continue;
}
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
@@ -192,14 +225,20 @@ export class CursorSessionsProvider implements IProviderSessions {
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const total = allNormalized.length;
if (limit !== null && limit > 0) {
if (limit !== null) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
const page = limit === 0
? []
: allNormalized.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
total,
hasMore,
offset,
limit,
};
@@ -207,7 +246,7 @@ export class CursorSessionsProvider implements IProviderSessions {
return {
messages: allNormalized,
total: allNormalized.length,
total,
hasMore: false,
offset: 0,
limit: null,

View File

@@ -113,8 +113,10 @@ export class GeminiSessionsProvider implements IProviderSessions {
*/
async fetchHistory(
sessionId: string,
_options: FetchHistoryOptions = {},
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let rawMessages: AnyRecord[];
try {
rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[];
@@ -208,12 +210,18 @@ export class GeminiSessionsProvider implements IProviderSessions {
}
}
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
return {
messages: normalized,
messages,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start,
limit: pageLimit,
};
}
}