fix: establish runtime identity for data directory gates

Use the packaged app version (not Electron's dev-mode 0.0.0) across all
compatibility checks. Make DatabaseManager require a RuntimeIdentity so
every gate write carries a verified version and kind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Sonnet 4.6 committed 2026-06-08 10:14:42 +08:00
1 parent 3f96d31819
commit 1c7781d85b
14 files changed
+89 -25

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chatlab-cli",
"version": "0.0.0",
"version": "0.25.1",
"description": "ChatLab CLI & Service - Chat history analysis tool",
"type": "module",
"bin": {
+2 -1
View File
@@ -8,6 +8,7 @@ import type { FastifyInstance } from 'fastify'
import { app } from 'electron'
import { successResponse } from '../errors'
import * as worker from '../../worker/workerManager'
import { getDesktopAppVersion } from '../../runtime-compat'
export function registerSystemRoutes(server: FastifyInstance): void {
server.get('/api/v1/status', async () => {
@@ -21,7 +22,7 @@ export function registerSystemRoutes(server: FastifyInstance): void {
return successResponse({
name: 'ChatLab API',
version: app.getVersion(),
version: getDesktopAppVersion(app.getVersion()),
uptime: Math.floor(process.uptime()),
sessionCount,
})
+2 -2
View File
@@ -22,7 +22,7 @@ import {
import { migrateAllDatabases, checkMigrationNeeded } from './database/core'
import { initLocale } from './i18n'
import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config'
import { assertDesktopDataDirCompatible } from './runtime-compat'
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat'
import {
applyCurrentTitleBarOverlay,
@@ -118,7 +118,7 @@ class MainProcess {
let runtime: RuntimeIdentity
try {
runtime = assertDesktopDataDirCompatible(getPathProvider(), app.getVersion())
runtime = assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion()))
} catch (error) {
console.error('[Main] Data directory compatibility check failed:', error)
dialog.showErrorBox(
+3 -3
View File
@@ -42,7 +42,7 @@ import { getManager as getAIChatManager } from './ai/chats'
import { getManager as getAssistantManager } from './ai/assistant/manager'
import { getManager as getSkillManager } from './ai/skills/manager'
import { createElectronRunAgentStream } from './ai/agent-stream-runner'
import { assertDesktopDataDirCompatible } from './runtime-compat'
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
export interface InternalEndpoint {
baseUrl: string
@@ -85,7 +85,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
try {
const token = `int_${randomBytes(32).toString('hex')}`
const { app } = await import('electron')
const runtime = assertDesktopDataDirCompatible(pathProvider, app.getVersion())
const runtime = assertDesktopDataDirCompatible(pathProvider, getDesktopAppVersion(app.getVersion()))
newDbManager = new DatabaseManager(pathProvider, { runtime })
const sessionAdapter = createDatabaseManagerAdapter(newDbManager)
@@ -142,7 +142,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
dbManager: newDbManager,
sessionAdapter,
pathProvider,
getVersion: () => app.getVersion(),
getVersion: () => getDesktopAppVersion(app.getVersion()),
mergeSessionCache: newMergeCache,
streamImport: electronStreamImport,
aiDataDir,
+2 -2
View File
@@ -12,6 +12,7 @@ import {
applyTitleBarOverlayColor,
resetCurrentTitleBarOverlayColor,
} from '../window-titlebar'
import { getDesktopAppVersion } from '../runtime-compat'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
// 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。
@@ -107,8 +108,7 @@ export function registerWindowHandlers(ctx: IpcContext): void {
// ==================== 应用信息 ====================
ipcMain.handle('app:getVersion', () => {
const v = app.getVersion()
return v && v !== '0.0.0' ? v : typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : v
return getDesktopAppVersion(app.getVersion())
})
// 重启应用
+25 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { assertDesktopDataDirCompatible } from './runtime-compat'
import { assertDesktopDataDirCompatible, resolveDesktopAppVersion } from './runtime-compat'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
@@ -25,6 +25,30 @@ function makePathProvider(userDataDir: string): PathProvider {
}
}
test('resolveDesktopAppVersion falls back to bundled app version when Electron reports 0.0.0', () => {
assert.equal(resolveDesktopAppVersion('0.0.0', '0.25.1'), '0.25.1')
})
test('assertDesktopDataDirCompatible accepts a gated data directory with the bundled fallback version', () => {
const userDataDir = makeTempDir()
fs.writeFileSync(
path.join(userDataDir, '.chatlab-meta.json'),
JSON.stringify({
formatVersion: 1,
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' },
updatedAt: 1780830000,
}),
'utf-8'
)
assert.doesNotThrow(() =>
assertDesktopDataDirCompatible(makePathProvider(userDataDir), resolveDesktopAppVersion('0.0.0', '0.25.1'))
)
})
test('assertDesktopDataDirCompatible formats a startup error for older desktop runtimes', () => {
const userDataDir = makeTempDir()
fs.writeFileSync(
+18
View File
@@ -5,6 +5,20 @@ import {
type RuntimeIdentity,
} from '@openchatlab/node-runtime/src/data-dir-compat'
export function resolveDesktopAppVersion(electronVersion: string | null | undefined, bundledVersion?: string): string {
const normalizedElectronVersion = normalizeVersion(electronVersion)
if (normalizedElectronVersion && normalizedElectronVersion !== '0.0.0') return normalizedElectronVersion
const normalizedBundledVersion = normalizeVersion(bundledVersion)
if (normalizedBundledVersion) return normalizedBundledVersion
return normalizedElectronVersion || '0.0.0'
}
export function getDesktopAppVersion(electronVersion: string | null | undefined): string {
return resolveDesktopAppVersion(electronVersion, typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined)
}
export function createDesktopRuntimeIdentity(version: string): RuntimeIdentity {
return { version, kind: 'desktop' }
}
@@ -29,6 +43,10 @@ export function assertDesktopDataDirCompatible(pathProvider: PathProvider, versi
return runtime
}
function normalizeVersion(version: string | null | undefined): string {
return typeof version === 'string' ? version.trim() : ''
}
function formatDesktopDataDirCompatibilityError(error: DataDirCompatibilityError, runtime: RuntimeIdentity): string {
return [
`ChatLab data directory requires ChatLab ${error.minRuntimeVersion} or newer.`,
+2 -1
View File
@@ -1,9 +1,10 @@
import { app, session } from 'electron'
import { getDesktopAppVersion } from '../runtime-compat'
export function buildChatLabUserAgentHeaders(): Record<string, string> {
const chatLabVersion = (() => {
try {
return app.getVersion() || 'dev'
return getDesktopAppVersion(app.getVersion()) || 'dev'
} catch {
return 'dev'
}
+2 -1
View File
@@ -12,6 +12,7 @@ import type { StreamImportResult } from './import'
import { getDatabaseDir, getCacheDir, getTempDir, ensureDir } from '../paths'
import { getNlpDir } from '../nlp/dictManager'
import { getDesktopAppVersion } from '../runtime-compat'
// Worker 实例
let worker: Worker | null = null
@@ -88,7 +89,7 @@ function checkAndResetAnalysisCache(): void {
}
const versionFile = path.join(queryCacheDir, '.cache_version')
const currentVersion = app.getVersion()
const currentVersion = getDesktopAppVersion(app.getVersion())
let lastVersion: string | null = null
try {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ChatLab",
"version": "0.25.0",
"version": "0.25.1",
"private": true,
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chatlab-mcp",
"version": "0.1.0",
"version": "0.25.1",
"description": "ChatLab MCP Server — shared core for CLI and Desktop, also usable standalone via npx",
"type": "module",
"main": "./dist/index.mjs",
+2 -3
View File
@@ -16,9 +16,8 @@ function main(): void {
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const dbManager = new DatabaseManager(pathProvider)
const version = process.env.npm_package_version ?? '0.1.0'
const version = process.env.npm_package_version ?? '0.25.1'
const dbManager = new DatabaseManager(pathProvider, { runtime: { version, kind: 'mcp' } })
startMcpServer({ version, dbManager }).catch((err) => {
console.error('[chatlab-mcp] Fatal error:', err)
@@ -30,6 +30,15 @@ function createPathProvider(root: string): PathProvider {
}
}
test('constructor rejects missing runtime unless the test-only bypass is explicit', () => {
const root = makeTempDir()
assert.throws(() => new DatabaseManager(createPathProvider(root), { nativeBinding }), /runtime identity is required/i)
assert.doesNotThrow(
() => new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
)
})
test('open migrates legacy member name columns before readonly queries', () => {
const root = makeTempDir()
const dbDir = path.join(root, 'data', 'databases')
@@ -67,7 +76,7 @@ test('open migrates legacy member name columns before readonly queries', () => {
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
const db = manager.open('legacy')
assert.ok(db)
@@ -127,7 +136,7 @@ test('open backfills FTS index when migrating legacy sessions', () => {
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
const db = manager.open('fts-legacy')
assert.ok(db)
@@ -182,7 +191,7 @@ test('open migrates v2 chat_session schema to current segment schema', () => {
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
const db = manager.open('v2-segment-schema')
assert.ok(db)
@@ -524,7 +533,7 @@ test('open preserves readonly access for current-schema databases', { skip: proc
fs.chmodSync(dbDir, 0o555)
try {
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
const db = manager.open('current-readonly')
assert.ok(db)
assert.equal(db.readonly, true)
@@ -556,7 +565,7 @@ test('listSessionIds ignores non-ChatLab sqlite databases without migrating them
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true })
assert.deepEqual(manager.listSessionIds(), [])
manager.closeAll()
+14 -3
View File
@@ -28,19 +28,30 @@ function createMigrationDeps(overrides?: MigrationDeps): MigrationDeps {
}
}
interface DatabaseManagerOptions {
nativeBinding?: string
migrationDeps?: MigrationDeps
runtime?: RuntimeIdentity
allowMissingRuntimeForTests?: boolean
}
export class DatabaseManager {
private cache = new Map<string, DatabaseAdapter>()
private nativeBinding?: string
private migrationDeps?: MigrationDeps
private runtime?: RuntimeIdentity
private runtime: RuntimeIdentity | null
constructor(
private pathProvider: PathProvider,
options?: { nativeBinding?: string; migrationDeps?: MigrationDeps; runtime?: RuntimeIdentity }
options: DatabaseManagerOptions = {}
) {
if (!options.runtime && !options.allowMissingRuntimeForTests) {
throw new Error('DatabaseManager runtime identity is required for data directory compatibility checks.')
}
this.nativeBinding = options?.nativeBinding
this.migrationDeps = createMigrationDeps(options?.migrationDeps)
this.runtime = options?.runtime
this.runtime = options?.runtime ?? null
}
/**