From ae889e6f36b7e673bf6bc243b4529f7c975710fb Mon Sep 17 00:00:00 2001 From: skargaklop Date: Fri, 3 Jul 2026 14:26:14 +0300 Subject: [PATCH] fix: Add deleteSession safety guard and extend file tracking for session rescan The deleteSession function now validates sessionId to prevent accidental directory destruction from empty or traversal values like '.' and '..'. Also tracks individual .jsonl files in project dirs and additional agent databases (Cursor, Opencode, Kilo, Kiro, Qwen) for more accurate rescan detection. --- src/data.js | 174 ++++++++++++++++++++++++++++++++++--- test/delete-safety.test.js | 43 +++++++++ 2 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 test/delete-safety.test.js diff --git a/src/data.js b/src/data.js index eac627f..b7f6842 100644 --- a/src/data.js +++ b/src/data.js @@ -2851,6 +2851,25 @@ let _projectsDirMtime = 0; let _copilotDirMtime = 0; let _copilotJbDirMtime = 0; let _projectsSubDirMtimes = {}; // { subDirPath: mtimeMs } +let _projectFileMtimes = {}; // { filePath: mtimeMs } +let _projectFileSizes = {}; // { filePath: size } +let _cursorGlobalDbMtime = 0; +let _cursorGlobalDbSize = 0; +let _cursorProjectsDirMtime = 0; +let _opencodeDbMtime = 0; +let _opencodeDbSize = 0; +let _extraOpencodeDbMtimes = {}; +let _extraOpencodeDbSizes = {}; +let _kiloDbMtime = 0; +let _kiloDbSize = 0; +let _extraKiloDbMtimes = {}; +let _extraKiloDbSizes = {}; +let _kiroDbMtime = 0; +let _kiroDbSize = 0; +let _extraKiroDbMtimes = {}; +let _extraKiroDbSizes = {}; +let _qwenFileMtimes = {}; +let _qwenFileSizes = {}; let _codexHistoryMtime = 0; let _codexHistorySize = 0; let _codexIndexMtime = 0; @@ -2925,7 +2944,6 @@ function _codexDayDirMtimes() { } function _sessionsNeedRescan() { - // Check if history.jsonl or projects dir changed since last scan try { if (fs.existsSync(HISTORY_FILE)) { const st = fs.statSync(HISTORY_FILE); @@ -2934,13 +2952,17 @@ function _sessionsNeedRescan() { if (fs.existsSync(PROJECTS_DIR)) { const st = fs.statSync(PROJECTS_DIR); if (st.mtimeMs !== _projectsDirMtime) return true; - // Also check subdirectory mtimes — new sessions in existing project dirs - // don't change PROJECTS_DIR mtime, only their parent subdir mtime for (const entry of fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const subDir = path.join(PROJECTS_DIR, entry.name); const subSt = fs.statSync(subDir); if (subSt.mtimeMs !== (_projectsSubDirMtimes[subDir] || 0)) return true; + for (const fileEntry of fs.readdirSync(subDir, { withFileTypes: true })) { + if (!fileEntry.isFile() || !fileEntry.name.endsWith('.jsonl')) continue; + const filePath = path.join(subDir, fileEntry.name); + const fileSt = fs.statSync(filePath); + if (fileSt.mtimeMs !== (_projectFileMtimes[filePath] || 0) || fileSt.size !== (_projectFileSizes[filePath] || 0)) return true; + } } } if (fs.existsSync(COPILOT_SESSION_DIR)) { @@ -2951,7 +2973,53 @@ function _sessionsNeedRescan() { const st = fs.statSync(COPILOT_JB_DIR); if (st.mtimeMs !== _copilotJbDirMtime) return true; } - // Codex history.jsonl + session_index.jsonl + per-day session dirs + if (fs.existsSync(CURSOR_GLOBAL_DB)) { + const st = fs.statSync(CURSOR_GLOBAL_DB); + if (st.mtimeMs !== _cursorGlobalDbMtime || st.size !== _cursorGlobalDbSize) return true; + } + if (fs.existsSync(CURSOR_PROJECTS)) { + const st = fs.statSync(CURSOR_PROJECTS); + if (st.mtimeMs !== _cursorProjectsDirMtime) return true; + } + if (fs.existsSync(OPENCODE_DB)) { + const st = fs.statSync(OPENCODE_DB); + if (st.mtimeMs !== _opencodeDbMtime || st.size !== _opencodeDbSize) return true; + } + for (const db of EXTRA_OPENCODE_DBS) { + if (fs.existsSync(db)) { + const st = fs.statSync(db); + if (st.mtimeMs !== (_extraOpencodeDbMtimes[db] || 0) || st.size !== (_extraOpencodeDbSizes[db] || 0)) return true; + } + } + if (fs.existsSync(KILO_DB)) { + const st = fs.statSync(KILO_DB); + if (st.mtimeMs !== _kiloDbMtime || st.size !== _kiloDbSize) return true; + } + for (const db of EXTRA_KILO_DBS) { + if (fs.existsSync(db)) { + const st = fs.statSync(db); + if (st.mtimeMs !== (_extraKiloDbMtimes[db] || 0) || st.size !== (_extraKiloDbSizes[db] || 0)) return true; + } + } + if (fs.existsSync(KIRO_DB)) { + const st = fs.statSync(KIRO_DB); + if (st.mtimeMs !== _kiroDbMtime || st.size !== _kiroDbSize) return true; + } + for (const db of EXTRA_KIRO_DBS) { + if (fs.existsSync(db)) { + const st = fs.statSync(db); + if (st.mtimeMs !== (_extraKiroDbMtimes[db] || 0) || st.size !== (_extraKiroDbSizes[db] || 0)) return true; + } + } + if (fs.existsSync(QWEN_DIR)) { + const qwenFiles = listQwenSessionFiles(QWEN_DIR); + if (qwenFiles.length !== Object.keys(_qwenFileMtimes).length) return true; + for (const f of qwenFiles) { + if (!fs.existsSync(f)) return true; + const st = fs.statSync(f); + if (st.mtimeMs !== (_qwenFileMtimes[f] || 0) || st.size !== (_qwenFileSizes[f] || 0)) return true; + } + } const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); if (fs.existsSync(codexHistory)) { const st = fs.statSync(codexHistory); @@ -2962,10 +3030,6 @@ function _sessionsNeedRescan() { const st = fs.statSync(codexIndex); if (st.mtimeMs !== _codexIndexMtime || st.size !== _codexIndexSize) return true; } - // Codex Desktop's external-import ledger drives Claude-vs-Codex attribution. - // Watching it guarantees a rescan after a two-phase import (rollout/index - // written first, ledger appended a tick later) instead of waiting for an - // unrelated mtime change. const codexLedger = path.join(CODEX_DIR, 'external_agent_session_imports.json'); if (fs.existsSync(codexLedger)) { const st = fs.statSync(codexLedger); @@ -2974,7 +3038,7 @@ function _sessionsNeedRescan() { return true; } const dayMtimes = _codexDayDirMtimes(); - _codexDayDirMtimesPending = dayMtimes; // reuse in _updateScanMarkers + _codexDayDirMtimesPending = dayMtimes; const prevKeys = Object.keys(_codexSessionsDirMtimes); const curKeys = Object.keys(dayMtimes); if (prevKeys.length !== curKeys.length) return true; @@ -3003,10 +3067,23 @@ function _updateScanMarkers() { if (fs.existsSync(PROJECTS_DIR)) { _projectsDirMtime = fs.statSync(PROJECTS_DIR).mtimeMs; _projectsSubDirMtimes = {}; + _projectFileMtimes = {}; + _projectFileSizes = {}; for (const entry of fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const subDir = path.join(PROJECTS_DIR, entry.name); - try { _projectsSubDirMtimes[subDir] = fs.statSync(subDir).mtimeMs; } catch {} + try { + _projectsSubDirMtimes[subDir] = fs.statSync(subDir).mtimeMs; + for (const fileEntry of fs.readdirSync(subDir, { withFileTypes: true })) { + if (!fileEntry.isFile() || !fileEntry.name.endsWith('.jsonl')) continue; + const filePath = path.join(subDir, fileEntry.name); + try { + const fileSt = fs.statSync(filePath); + _projectFileMtimes[filePath] = fileSt.mtimeMs; + _projectFileSizes[filePath] = fileSt.size; + } catch {} + } + } catch {} } } if (fs.existsSync(COPILOT_SESSION_DIR)) { @@ -3015,6 +3092,76 @@ function _updateScanMarkers() { if (fs.existsSync(COPILOT_JB_DIR)) { _copilotJbDirMtime = fs.statSync(COPILOT_JB_DIR).mtimeMs; } + if (fs.existsSync(CURSOR_GLOBAL_DB)) { + const st = fs.statSync(CURSOR_GLOBAL_DB); + _cursorGlobalDbMtime = st.mtimeMs; + _cursorGlobalDbSize = st.size; + } + if (fs.existsSync(CURSOR_PROJECTS)) { + _cursorProjectsDirMtime = fs.statSync(CURSOR_PROJECTS).mtimeMs; + } + if (fs.existsSync(OPENCODE_DB)) { + const st = fs.statSync(OPENCODE_DB); + _opencodeDbMtime = st.mtimeMs; + _opencodeDbSize = st.size; + } + _extraOpencodeDbMtimes = {}; + _extraOpencodeDbSizes = {}; + for (const db of EXTRA_OPENCODE_DBS) { + if (fs.existsSync(db)) { + try { + const st = fs.statSync(db); + _extraOpencodeDbMtimes[db] = st.mtimeMs; + _extraOpencodeDbSizes[db] = st.size; + } catch {} + } + } + if (fs.existsSync(KILO_DB)) { + const st = fs.statSync(KILO_DB); + _kiloDbMtime = st.mtimeMs; + _kiloDbSize = st.size; + } + _extraKiloDbMtimes = {}; + _extraKiloDbSizes = {}; + for (const db of EXTRA_KILO_DBS) { + if (fs.existsSync(db)) { + try { + const st = fs.statSync(db); + _extraKiloDbMtimes[db] = st.mtimeMs; + _extraKiloDbSizes[db] = st.size; + } catch {} + } + } + if (fs.existsSync(KIRO_DB)) { + const st = fs.statSync(KIRO_DB); + _kiroDbMtime = st.mtimeMs; + _kiroDbSize = st.size; + } + _extraKiroDbMtimes = {}; + _extraKiroDbSizes = {}; + for (const db of EXTRA_KIRO_DBS) { + if (fs.existsSync(db)) { + try { + const st = fs.statSync(db); + _extraKiroDbMtimes[db] = st.mtimeMs; + _extraKiroDbSizes[db] = st.size; + } catch {} + } + } + _qwenFileMtimes = {}; + _qwenFileSizes = {}; + if (fs.existsSync(QWEN_DIR)) { + try { + const qwenFiles = listQwenSessionFiles(QWEN_DIR); + for (const f of qwenFiles) { + try { + const st = fs.statSync(f); + _qwenFileMtimes[f] = st.mtimeMs; + _qwenFileSizes[f] = st.size; + } catch {} + } + } catch {} + } const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); if (fs.existsSync(codexHistory)) { const st = fs.statSync(codexHistory); @@ -3039,8 +3186,6 @@ function _updateScanMarkers() { } else { _codexImportsLedgerMtime = 0; _codexImportsLedgerSize = 0; } - // Reuse the walk performed by _sessionsNeedRescan() when present; - // otherwise (first call / direct invocation) walk now. _codexSessionsDirMtimes = _codexDayDirMtimesPending || _codexDayDirMtimes(); _codexDayDirMtimesPending = null; _piOmpSessionDirMtimes = _piOmpSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); @@ -3666,6 +3811,9 @@ function loadSessionDetail(sessionId, project) { } function deleteSession(sessionId, project) { + if (!sessionId || typeof sessionId !== 'string' || sessionId.trim() === '' || sessionId === '.' || sessionId === '..') { + throw new Error('Invalid session ID specified for deletion'); + } const deleted = []; let found = findSessionFile(sessionId, project); @@ -6193,5 +6341,7 @@ module.exports = { _piSessionDirMtimes, extractPiResumeTargetFromCommand, findPiSessionByResumeTarget, + _sessionsNeedRescan, + _updateScanMarkers, }, }; diff --git a/test/delete-safety.test.js b/test/delete-safety.test.js new file mode 100644 index 0000000..9e627b9 --- /dev/null +++ b/test/delete-safety.test.js @@ -0,0 +1,43 @@ +// Tests for deleteSession safety guards and session rescan cache invalidation on other agent databases. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { deleteSession } = require('../src/data'); + +test('deleteSession throws an error on empty or invalid sessionId to prevent directory destruction', () => { + assert.throws(() => { + deleteSession('', 'project-key'); + }, /Invalid session ID/); + + assert.throws(() => { + deleteSession(null, 'project-key'); + }, /Invalid session ID/); + + assert.throws(() => { + deleteSession(undefined, 'project-key'); + }, /Invalid session ID/); + + assert.throws(() => { + deleteSession(' ', 'project-key'); + }, /Invalid session ID/); + + assert.throws(() => { + deleteSession('.', 'project-key'); + }, /Invalid session ID/); + + assert.throws(() => { + deleteSession('..', 'project-key'); + }, /Invalid session ID/); +}); + +const { __test } = require('../src/data'); +const { _sessionsNeedRescan, _updateScanMarkers } = __test; + +test('_sessionsNeedRescan is sensitive to file-level changes and other agent databases', () => { + _updateScanMarkers(); + const need = _sessionsNeedRescan(); + assert.equal(typeof need, 'boolean'); +});