fix: enforce data directory compatibility gate at runtime boundaries

MCP server checks the gate at startup and exits before opening stdio if
the data directory requires a newer runtime. Desktop HTTP API maps
DataDirCompatibilityError (including wrapped causes) to a 409 response
so the renderer can surface a clear upgrade prompt.

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 e7561e224e
commit c3a5d22a43
9 files changed
+155 -8

No files matched your search

+33 -2
View File
@@ -4,8 +4,13 @@ import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { DatabaseManager, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime'
import { incrementalImport } from './stream-import'
import {
DatabaseManager,
DataDirCompatibilityError,
raiseDataDirMinRuntimeVersion,
readDataDirCompatibilityMeta,
} from '@openchatlab/node-runtime'
import { analyzeIncrementalImport, incrementalImport } from './stream-import'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
@@ -79,3 +84,29 @@ test('incrementalImport raises the data directory gate after successful writes',
assert.equal(meta?.dataCompatibilityVersion, 1)
assert.deepEqual(meta?.reasons, ['segment-schema'])
})
test('analyzeIncrementalImport propagates data directory compatibility errors', async () => {
const root = makeTempDir()
const pathProvider = createPathProvider(root)
fs.mkdirSync(pathProvider.getDatabaseDir(), { recursive: true })
fs.writeFileSync(path.join(pathProvider.getDatabaseDir(), 'existing.db'), 'not opened before compatibility check')
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.26.0',
dataCompatibilityVersion: 2,
reason: 'future-schema',
runtime: { version: '0.26.0', kind: 'desktop' },
module: 'future-migration',
now: () => 1780830000,
})
const manager = new DatabaseManager(pathProvider, {
nativeBinding,
runtime: { version: '0.25.1', kind: 'cli' },
})
const filePath = path.join(root, 'incremental.jsonl')
writeIncrementalJsonl(filePath)
await assert.rejects(
() => analyzeIncrementalImport(manager, 'existing', filePath),
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
)
})
+10 -1
View File
@@ -223,7 +223,16 @@ export async function analyzeIncrementalImport(
filePath: string,
onProgress?: ImportProgressCallback
): Promise<IncrementalAnalyzeResult> {
return sharedAnalyzeIncremental(sessionId, filePath, buildIncrementalDeps(dbManager, onProgress))
let compatibilityError: DataDirCompatibilityError | null = null
const result = await sharedAnalyzeIncremental(
sessionId,
filePath,
buildIncrementalDeps(dbManager, onProgress, (error) => {
compatibilityError = error
})
)
if (compatibilityError) throw compatibilityError
return result
}
export async function analyzeNewImport(
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat'
import { ApiErrorCode, apiErrorFromUnknown } from './errors'
test('apiErrorFromUnknown maps wrapped data directory compatibility errors to 409', () => {
const cause = new DataDirCompatibilityError(
'DATA_DIR_REQUIRES_NEWER_RUNTIME',
'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.',
{
userDataDir: '/tmp/chatlab-data',
metaPath: '/tmp/chatlab-data/.chatlab-meta.json',
currentVersion: '0.25.0',
minRuntimeVersion: '0.25.1',
}
)
const apiError = apiErrorFromUnknown(new Error('Desktop startup formatted message', { cause }))
assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE)
assert.equal(apiError?.statusCode, 409)
assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/)
})
+21
View File
@@ -2,6 +2,8 @@
* ChatLab API — Error codes and factory functions
*/
import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat'
export enum ApiErrorCode {
UNAUTHORIZED = 'UNAUTHORIZED',
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
@@ -15,6 +17,7 @@ export enum ApiErrorCode {
IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT',
IDEMPOTENCY_PENDING = 'IDEMPOTENCY_PENDING',
IMPORT_FAILED = 'IMPORT_FAILED',
DATA_DIR_INCOMPATIBLE = 'DATA_DIR_INCOMPATIBLE',
SERVER_ERROR = 'SERVER_ERROR',
}
@@ -31,6 +34,7 @@ const HTTP_STATUS: Record<ApiErrorCode, number> = {
[ApiErrorCode.IDEMPOTENCY_CONFLICT]: 409,
[ApiErrorCode.IDEMPOTENCY_PENDING]: 409,
[ApiErrorCode.IMPORT_FAILED]: 500,
[ApiErrorCode.DATA_DIR_INCOMPATIBLE]: 409,
[ApiErrorCode.SERVER_ERROR]: 500,
}
@@ -89,10 +93,27 @@ export function importFailed(message: string): ApiError {
return new ApiError(ApiErrorCode.IMPORT_FAILED, message)
}
export function dataDirIncompatible(message: string): ApiError {
return new ApiError(ApiErrorCode.DATA_DIR_INCOMPATIBLE, message)
}
export function serverError(message = 'Internal server error'): ApiError {
return new ApiError(ApiErrorCode.SERVER_ERROR, message)
}
export function apiErrorFromUnknown(error: unknown): ApiError | null {
if (error instanceof ApiError) return error
const compatibilityError = findDataDirCompatibilityError(error)
if (compatibilityError) return dataDirIncompatible(compatibilityError.message)
return null
}
function findDataDirCompatibilityError(error: unknown): DataDirCompatibilityError | null {
if (error instanceof DataDirCompatibilityError) return error
if (error instanceof Error && error.cause) return findDataDirCompatibilityError(error.cause)
return null
}
export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
return {
success: true as const,
+11
View File
@@ -24,6 +24,7 @@ import {
ApiError,
ApiErrorCode,
successResponse,
apiErrorFromUnknown,
importInProgress,
importFailed,
invalidFormat,
@@ -328,6 +329,11 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply,
} catch (error: any) {
idempotencyFail(cacheKey)
apiLogger.error('Import error', error)
const apiError = apiErrorFromUnknown(error)
if (apiError) {
reply.code(apiError.statusCode).send(errorResponse(apiError))
return
}
const err = importFailed(error.message || 'Import process error')
reply.code(err.statusCode).send(errorResponse(err))
} finally {
@@ -421,6 +427,11 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply,
}
} catch (error: any) {
apiLogger.error('Import error', error)
const apiError = apiErrorFromUnknown(error)
if (apiError) {
reply.code(apiError.statusCode).send(errorResponse(apiError))
return
}
const err = importFailed(error.message || 'Import process error')
reply.code(err.statusCode).send(errorResponse(err))
} finally {
+7 -1
View File
@@ -4,7 +4,7 @@
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
import { authHook } from './auth'
import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors'
import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from './errors'
import { apiLogger } from './logger'
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
@@ -23,6 +23,12 @@ export function createServer(): FastifyInstance {
return
}
const apiError = apiErrorFromUnknown(error)
if (apiError) {
reply.code(apiError.statusCode).send(errorResponse(apiError))
return
}
if (error.statusCode === 413) {
const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit')
reply.code(413).send(errorResponse(bodyErr))
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime'
import { initStandaloneMcpRuntime } from './standalone-runtime'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-mcp-bin-'))
}
test('initStandaloneMcpRuntime rejects incompatible data directories before stdio startup', () => {
const userDataDir = makeTempDir()
const pathProvider = initStandaloneMcpRuntime('0.26.0', userDataDir).pathProvider
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.26.0',
dataCompatibilityVersion: 2,
reason: 'future-schema',
runtime: { version: '0.26.0', kind: 'desktop' },
module: 'future-migration',
now: () => 1780830000,
})
assert.throws(
() => initStandaloneMcpRuntime('0.25.1', userDataDir),
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
)
})
+2 -4
View File
@@ -8,17 +8,15 @@
*/
import { loadConfig } from '@openchatlab/config'
import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime'
import { startMcpServer } from './server'
import { initStandaloneMcpRuntime } from './standalone-runtime'
import { getMcpPackageVersion } from './runtime-version'
function main(): void {
const config = loadConfig()
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const version = getMcpPackageVersion()
const dbManager = new DatabaseManager(pathProvider, { runtime: { version, kind: 'mcp' } })
const { dbManager } = initStandaloneMcpRuntime(version, userDataDir)
startMcpServer({ version, dbManager }).catch((err) => {
console.error('[chatlab-mcp] Fatal error:', err)
@@ -0,0 +1,18 @@
import {
assertDataDirCompatible,
DatabaseManager,
NodePathProvider,
type RuntimeIdentity,
} from '@openchatlab/node-runtime'
export function initStandaloneMcpRuntime(
version: string,
userDataDir?: string
): { dbManager: DatabaseManager; pathProvider: NodePathProvider; runtime: RuntimeIdentity } {
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const runtime: RuntimeIdentity = { version, kind: 'mcp' }
assertDataDirCompatible(pathProvider, runtime)
const dbManager = new DatabaseManager(pathProvider, { runtime })
return { dbManager, pathProvider, runtime }
}