Files
hermes-ui/tests/server/hermes-db-readonly.test.ts
T
toor cc644c59f6 Phase 008: close the test and CI loop around the migration
Adds the checks that make the constraints from Phase 001-007 mechanical
instead of tribal knowledge, and clears the legacy brand text that was still
reachable at runtime.

New red-line tests:
  - no-legacy-brand mirrors the harness scan under vitest, and asserts its
    allow-list has not drifted from harness-check.mjs
  - hermes-db-readonly classifies every new DatabaseSync call site by
    following the path it opens, and fails if a state.db handle drops
    readOnly. It also pins the one legitimately writable database.
  - i18n-parity compares the raw zh/en locale objects both ways. The
    existing coverage test merged en into zh at import time, so a key
    missing from zh could never fail there.

harness:check gains the four missing gates from the phase brief - retired
package directories, retired package scripts, a single UI library, and the
required root files. Each was verified to fail by mutating the repo.

The online-update channel had hardcoded upstream URLs. It now reads
HERMES_WEB_UI_VERSION_MANIFEST_URL and friends and degrades with a named
error, because a reimplementation has nowhere to pull releases from.

The remaining legacy strings are deliberate and each carries a reason in the
allow-list: the global-agent relay address and the OpenRouter referer are
load-bearing, the socket role identifier is a wire contract, and the pre-rename
DB filenames would orphan existing deployments if changed. AGENTS.md now says
so explicitly, so a future agent does not "clean them up".

Also removes a dead electron mock alias, an i18n exemption that had stopped
exempting anything, and a teardown race in plugins-view.test.ts where a-spin
animation frames outlived the jsdom environment.

Verified self-contained: with reference_code/ moved aside, harness:check,
build, test and test:e2e all pass, so the directory can now be deleted.
2026-10-19 00:23:19 +08:00

321 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* 红线:Hermes Agent 的 `state.db` 对 Node 侧是**只读**的(AGENTS.md Red Lines)。
*
* 现状(Phase 008 §3.2 调研结论):
* - `packages/server/src/db/hermes/sessions-db.ts` —— 7 处 `new DatabaseSync(...)`,
* 路径来自同文件的 `sessionDbPath()` / `sessionDbPathForProfile()`;
* - `packages/server/src/db/hermes/conversations-db.ts` —— 1 处,路径来自
* 同文件的 `conversationDbPath()`;
* - 两者全部带 `readOnly: true`。
*
* 判断「这个 DatabaseSync 打开的是不是 state.db」的做法(不使用「state.db 字符串
* 出现次数」这种脆判定):
* 1. 先在**同一个文件**里找出所有「路径构造函数」——函数体里出现 `'state.db'`
* 字面量的函数(当前是 sessionDbPath / sessionDbPathForProfile / conversationDbPath);
* 2. 对每个 `new DatabaseSync(` 调用点取第一个实参,判定它的来源:
* 实参里出现 `'state.db'` 字面量 → state.db;
* 实参里调用了上述某个路径构造函数 → state.db(覆盖
* `profile ? sessionDbPathForProfile(profile) : sessionDbPath()` 这类三元表达式);
* 实参是裸标识符 → 在同文件里找到它的 `const/let/var` 声明并**递归**解析
* (sessions-db.ts 的 `const dbPath = ...` 就是这种形态),用 visited 集合防环;
* 3. 解析不出来 → 归为 `unknown` 并**直接判失败**,要求显式分类。
*
* 第 3 步是关键:宁可误报也不漏报。这样将来有人把路径来源改成一个新形态
* (比如从配置读路径),测试会立刻失败并要求写明该调用点的归类,而不是
* 静悄悄地跳过检查、让红线形同虚设。
*/
const SERVER_SRC = 'packages/server/src'
/**
* 唯一允许可写的 SQLite 打开点:Web UI 自己的库。
*
* 豁免原因:`packages/server/src/db/index.ts` 打开的是 Web UI 自有的 SQLite 库
* (文件名早于 hermes-ui 改名,见该文件顶部注释),保存的是本项目自己的会话、
* 设置、设备等数据,**必须**可写;它与 Hermes Agent 的 state.db 无关。
* 这也是全仓唯一被允许写开的库——新增豁免前请先确认新库不是 Hermes 侧的数据。
*/
const WRITABLE_EXEMPT_FILES = new Set(['packages/server/src/db/index.ts'])
type Classification = 'state-db' | 'own-db' | 'unknown'
type CallSite = {
file: string
line: number
/** 第一个实参原文,用于报错定位 */
pathExpr: string
/** 第二个实参(options)原文 */
optionsExpr: string
classification: Classification
}
const toRelative = (file: string) => file.split('\\').join('/')
function listSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
// Python 桥接层是 vendored 只读代码,且不直接用 sqlite
if (entry.name === 'python' || entry.name === '__pycache__') continue
listSourceFiles(full, out)
} else if (full.endsWith('.ts')) {
out.push(toRelative(full))
}
}
return out
}
const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
/** 从 openIndex 处的括号开始做配对,返回配对括号之后的下标 */
function matchBracket(source: string, openIndex: number): number {
const close = OPENERS[source[openIndex]]
let depth = 0
for (let i = openIndex; i < source.length; i += 1) {
if (source[i] === source[openIndex]) depth += 1
else if (source[i] === close) {
depth -= 1
if (depth === 0) return i
}
}
return -1
}
/**
* 从 start 处读出一个「实参 / 初始化表达式」:在括号深度为 0 的位置遇到
* 逗号、分号或换行即停止,因此既能吃下 `dbPath, { ... }` 里的 `dbPath`,
* 也能吃下跨行的三元表达式。
*/
function readExpression(source: string, start: number): string {
let i = start
for (; i < source.length; i += 1) {
const ch = source[i]
if (OPENERS[ch]) {
const close = matchBracket(source, i)
// 括号不配对(理论上不会发生)时保守退出,避免整文件扫描被拖死
if (close === -1) return source.slice(start, i)
i = close
continue
}
// 括号外的逗号 / 分号 / 换行即表达式结束
if (ch === ',' || ch === ';' || ch === '\n') break
}
return source.slice(start, i).trim()
}
/** 按顶层逗号切分实参列表(忽略括号内的逗号) */
function splitArgs(source: string): string[] {
const args: string[] = []
let current = ''
let i = 0
while (i < source.length) {
const ch = source[i]
if (OPENERS[ch]) {
const close = matchBracket(source, i)
if (close === -1) break
current += source.slice(i, close + 1)
i = close + 1
continue
}
if (ch === ',') {
args.push(current)
current = ''
i += 1
continue
}
current += ch
i += 1
}
if (current.trim()) args.push(current)
return args.map(arg => arg.trim())
}
/** 收集同文件里所有「路径构造函数」:函数体中出现 'state.db' 字面量的函数名 */
function collectStateDbPathFunctions(source: string): Set<string> {
const names = new Set<string>()
const declRe = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g
for (const match of source.matchAll(declRe)) {
const braceIndex = source.indexOf('{', match.index + match[0].length)
if (braceIndex === -1) continue
const end = matchBracket(source, braceIndex)
if (end === -1) continue
if (/'state\.db'|"state\.db"/.test(source.slice(braceIndex, end))) names.add(match[1])
}
return names
}
/** 收集同文件里所有 const/let/var 声明 -> 初始化表达式原文 */
function collectBindings(source: string): Map<string, string> {
const bindings = new Map<string, string>()
const declRe = /(?:^|[\s;{(])(?:const|let|var)\s+(\w+)\s*(?::[^=\n]+)?=\s*/g
for (const match of source.matchAll(declRe)) {
const expr = readExpression(source, match.index + match[0].length)
if (expr) bindings.set(match[1], expr)
}
return bindings
}
function classify(
pathExpr: string,
stateDbFunctions: Set<string>,
bindings: Map<string, string>,
visited: Set<string>,
): Classification {
// 1. 实参直接写了 state.db 路径
if (/'state\.db'|"state\.db"/.test(pathExpr)) return 'state-db'
// 2. 实参调用了同文件的某个 state.db 路径构造函数(含三元表达式等组合形态)
for (const name of stateDbFunctions) {
if (new RegExp(`\\b${name}\\s*\\(`).test(pathExpr)) return 'state-db'
}
// 3. 实参是裸标识符:回到同文件的声明处递归解析
if (/^\w+$/.test(pathExpr)) {
if (visited.has(pathExpr)) return 'unknown'
visited.add(pathExpr)
const initializer = bindings.get(pathExpr)
if (initializer === undefined) return 'unknown'
return classify(initializer, stateDbFunctions, bindings, visited)
}
return 'unknown'
}
function findCallSites(): CallSite[] {
const sites: CallSite[] = []
for (const file of listSourceFiles(SERVER_SRC)) {
const source = readFileSync(file, 'utf8')
const stateDbFunctions = collectStateDbPathFunctions(source)
const bindings = collectBindings(source)
for (const match of source.matchAll(/new\s+DatabaseSync\s*\(/g)) {
const openIndex = match.index + match[0].length - 1
const closeIndex = matchBracket(source, openIndex)
const args = closeIndex === -1 ? [] : splitArgs(source.slice(openIndex + 1, closeIndex))
sites.push({
file,
line: source.slice(0, match.index).split('\n').length,
pathExpr: args[0] ?? '',
optionsExpr: args[1] ?? '',
// 豁免文件不参与 state.db 归类:它打开的是自有库,恒为可写
classification: WRITABLE_EXEMPT_FILES.has(file)
? 'own-db'
: classify(args[0] ?? '', stateDbFunctions, bindings, new Set()),
})
}
}
return sites
}
function describeSite(site: CallSite): string {
return `${site.file}:${site.line} new DatabaseSync(${site.pathExpr}${
site.optionsExpr ? `, ${site.optionsExpr}` : ''
})`
}
describe('Hermes state.db stays read-only from Node', () => {
const callSites = findCallSites()
const stateDbSites = callSites.filter(site => site.classification === 'state-db')
const ownDbSites = callSites.filter(site => site.classification === 'own-db')
const unknownSites = callSites.filter(site => site.classification === 'unknown')
it('finds every DatabaseSync call site it is meant to guard', () => {
// 防止扫描规则本身失效(比如 collectBindings / matchBracket 改坏)导致整文件空转
expect(callSites.length).toBeGreaterThanOrEqual(9)
expect(stateDbSites.length).toBeGreaterThanOrEqual(8)
expect(
unknownSites.map(describeSite),
'无法判定数据库路径来源,因此无法确认是否只读。请把该调用点的路径来源改成'
+ '本文件里的 state.db 路径构造函数,或在写明原因后加入 WRITABLE_EXEMPT_FILES。',
).toEqual([])
})
it('opens every state.db handle with readOnly: true', () => {
const writable = stateDbSites
.filter(site => !/readOnly\s*:\s*true/.test(site.optionsExpr))
.map(describeSite)
expect(
writable,
writable.length === 0 ? '' : [
'以下调用打开了 Hermes 的 state.db 但没有 readOnly: true。',
'state.db 归 Hermes Agent 所有,Node 侧只能读;',
'打开方式应与 sessions-db.ts 现有写法一致:',
' new DatabaseSync(<path>, { open: true, readOnly: true })',
'需要写入的数据请落到 Web UI 自有库,不要写进 Hermes 的库。',
].join('\n'),
).toEqual([])
})
it('exempts only the Web UI own database from read-only', () => {
// 自有库必须仍然是可写的:把它改成 readOnly 会让 Web UI 自己的数据写不进去,
// 那说明豁免的前提(自有库可写)已经不成立,需要重新审视这条豁免。
expect(ownDbSites.map(site => site.file)).toEqual([...WRITABLE_EXEMPT_FILES])
for (const site of ownDbSites) {
expect(
site.optionsExpr,
`${site.file} 是自有库豁免点,必须保持可写(不要加 readOnly: true)`,
).not.toMatch(/readOnly\s*:\s*true/)
}
})
it('never reaches for the retired better-sqlite3 API', () => {
// 全仓已统一到 node:sqlite 的 DatabaseSync。这几条是防回退:
// better-sqlite3 的 `new Database(...)` 默认就是可写打开,且需要再调 .open(),
// 一旦回退,state.db 就会被无声地以读写模式打开。
const offenders: string[] = []
for (const file of listSourceFiles(SERVER_SRC)) {
const source = readFileSync(file, 'utf8')
source.split(/\r?\n/).forEach((line, index) => {
if (/\bnew\s+Database\s*\(/.test(line)) {
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 new Database(...): ${line.trim()}`)
}
if (/better-sqlite3/.test(line)) {
offenders.push(`${file}:${index + 1} 引用了 better-sqlite3: ${line.trim()}`)
}
if (/\.open\s*\(/.test(line)) {
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 .open(): ${line.trim()}`)
}
})
}
expect(
offenders,
offenders.length === 0 ? '' : [
'数据库访问必须统一走 node:sqlite 的 DatabaseSync:',
" import { DatabaseSync } from 'node:sqlite'",
'打开 Hermes 的 state.db 时必须带 { open: true, readOnly: true }。',
].join('\n'),
).toEqual([])
})
it('keeps the state.db path constructors in sync with the red line', () => {
// 只读红线的前提是「路径构造函数仍然指向 state.db」。如果有人把 state.db 的
// 路径改成可写来源或改名,这里会先一步报出来,避免红线测试被悄悄架空。
const found = new Set<string>()
for (const file of listSourceFiles(SERVER_SRC)) {
for (const name of collectStateDbPathFunctions(readFileSync(file, 'utf8'))) {
found.add(`${file}#${name}`)
}
}
expect(
[...found].sort(),
'state.db 路径构造函数集合变了:如果新增了构造函数,请确认它同样只被以 '
+ 'readOnly: true 打开;如果是被删掉/改名了,请同步更新本测试。',
).toEqual([
'packages/server/src/db/hermes/conversations-db.ts#conversationDbPath',
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPath',
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPathForProfile',
])
})
})