From 1c7781d85bb1eb9c86d1927a3e27cf29f0510b69 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 7 Jun 2026 22:23:03 +0800 Subject: [PATCH] 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 --- apps/cli/package.json | 2 +- apps/desktop/main/api/routes/system.ts | 3 ++- apps/desktop/main/index.ts | 4 +-- apps/desktop/main/internal-api.ts | 6 ++--- apps/desktop/main/ipc/window.ts | 4 +-- apps/desktop/main/runtime-compat.test.ts | 26 ++++++++++++++++++- apps/desktop/main/runtime-compat.ts | 18 +++++++++++++ apps/desktop/main/utils/httpHeaders.ts | 3 ++- apps/desktop/main/worker/workerManager.ts | 3 ++- package.json | 2 +- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/bin.ts | 5 ++-- .../node-runtime/src/database-manager.test.ts | 19 ++++++++++---- packages/node-runtime/src/database-manager.ts | 17 +++++++++--- 14 files changed, 89 insertions(+), 25 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 21ada094..bdb3ef6b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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": { diff --git a/apps/desktop/main/api/routes/system.ts b/apps/desktop/main/api/routes/system.ts index 54b9d613..f3c50c24 100644 --- a/apps/desktop/main/api/routes/system.ts +++ b/apps/desktop/main/api/routes/system.ts @@ -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, }) diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 236295da..6786d0ca 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -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( diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index b1554d0b..fb1b6061 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -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 app.getVersion(), + getVersion: () => getDesktopAppVersion(app.getVersion()), mergeSessionCache: newMergeCache, streamImport: electronStreamImport, aiDataDir, diff --git a/apps/desktop/main/ipc/window.ts b/apps/desktop/main/ipc/window.ts index a32a2686..b45ee940 100644 --- a/apps/desktop/main/ipc/window.ts +++ b/apps/desktop/main/ipc/window.ts @@ -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()) }) // 重启应用 diff --git a/apps/desktop/main/runtime-compat.test.ts b/apps/desktop/main/runtime-compat.test.ts index bb339d55..e0ba908e 100644 --- a/apps/desktop/main/runtime-compat.test.ts +++ b/apps/desktop/main/runtime-compat.test.ts @@ -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( diff --git a/apps/desktop/main/runtime-compat.ts b/apps/desktop/main/runtime-compat.ts index 954b0943..6b317e90 100644 --- a/apps/desktop/main/runtime-compat.ts +++ b/apps/desktop/main/runtime-compat.ts @@ -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.`, diff --git a/apps/desktop/main/utils/httpHeaders.ts b/apps/desktop/main/utils/httpHeaders.ts index be8d10e3..86dea81b 100644 --- a/apps/desktop/main/utils/httpHeaders.ts +++ b/apps/desktop/main/utils/httpHeaders.ts @@ -1,9 +1,10 @@ import { app, session } from 'electron' +import { getDesktopAppVersion } from '../runtime-compat' export function buildChatLabUserAgentHeaders(): Record { const chatLabVersion = (() => { try { - return app.getVersion() || 'dev' + return getDesktopAppVersion(app.getVersion()) || 'dev' } catch { return 'dev' } diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index 4b49d896..f5db326b 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -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 { diff --git a/package.json b/package.json index e2866af5..1c2f686d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ChatLab", - "version": "0.25.0", + "version": "0.25.1", "private": true, "description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆", "repository": { diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index b35d6fdb..c81d90bd 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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", diff --git a/packages/mcp-server/src/bin.ts b/packages/mcp-server/src/bin.ts index 498ffc46..26f0d535 100644 --- a/packages/mcp-server/src/bin.ts +++ b/packages/mcp-server/src/bin.ts @@ -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) diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index 2adea5c8..a3468654 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -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() diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index b43771c6..96e33476 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -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() 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 } /**