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.
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
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',
|
||
])
|
||
})
|
||
})
|