# 变更审计与回滚(0.8.0) > 状态:**已全部落地(随 0.8.0 发布)**。`0.8.0-a`(纯函数层)、`0.8.0-b`(提交即留痕)、 > `0.8.0-c`(四条 HTTP 路由)、`0.8.0-d`(设置页「变更记录」分组)均已实现 —— 各节末标了当前进度。 > 发布前又做了两轮打磨:**双入口**(主入口迁到宿主插件详情页,设置页只留指引,见 §14.4) > 与**保留条数可自定义**(§14.5)。 > > 前置阅读:`lib/config.mjs`(配置 schema 与白名单)、`lib/store.mjs`(既有的原子写与损坏处理)、 > `docs/DESIGN-persona-scope.md`(同类设计文档的写法,以及「宿主契约必须先查证」的做法)。 > > 本文件同时是**实现说明**:`0.8.0-a` 阶段推翻了两条草案决策(§4.4 的回滚语义、 > §4.2 的 `fields` 字段),推翻的理由保留在正文里 —— 只看结论会让人以为那两条 > 本来就想清楚了,下次还会照错的那版写。 ## 1. 目标 任何一次配置写入都留下一条不可篡改的记录,用户可以在设置页看到「谁在什么时候改了什么」, 并把活动配置**撤销**回历史中的任意一个状态。 「撤销回某个状态」的实现是倒着回放变更(§4.4),最终落到「点的那次改动发生之前」—— 从用户角度看仍然是「回到历史上的某一点」。 非目标:不做预设库的版本管理(见 §4.3),不做多用户审计,不做远程上报。 ## 2. 为什么值得做 现状有三个具体缺口,不是「用户体验优化」而是「数据可恢复性」: 1. **配置被改坏了没有退路。** `set_persona` 工具、`soulConfig.updateConfig` 服务、 `/soul set`、设置页保存 —— 四条写路径都能整体改写配置,而用户只能靠 `soul-config.json` 的手动备份。 2. **「为什么变成这样了」无法回答。** 配置是扁平对象、上一次写入的值就地被覆盖, 没有任何地方留旧值。用户看到 `style: humorous` 时无法区分「我两周前改的」 与「刚才某个 Agent 调工具改的」。 3. **已有一次真实事故的教训。** 「应用预设后点保存,人设被打回默认」这个 bug 的 排查耗时很长,根本原因是**中间状态不可观测** —— 如果有审计, 一次 `GET /api/soul/history` 就能看到 `style` 在两条记录之间来回跳。 ## 3. 埋点位置:唯一漏斗 `index.mjs:383` 的 `commitConfig(mutate)` 是**全部**配置写路径的收敛点。 grep 实测 14 个调用点(不含定义与注释),全部经过它: | 行 | 来源 (`source` / `action`) | | --- | --- | | `index.mjs:429` | **穿透自调用点**(`resetConfigWithEscape` 内部,见下表) | | `index.mjs:690` | `web` / `save`(HTTP `POST /api/soul/config`) | | `index.mjs:934` | **穿透自调用点**(`applyPersonaEntry` 内部,见下表) | | `index.mjs:1000` | `web` / `savePreset` | | `index.mjs:1079` | `web` / `deletePreset` | | `index.mjs:1386` | `command` / `nickname` | | `index.mjs:1439` | `command` / `enable` | | `index.mjs:1445` | `command` / `disable` | | `index.mjs:1456` | `command` / `savePreset` | | `index.mjs:1520` | `command` / `deletePreset` | | `index.mjs:1558` | `command` / `set` | | `index.mjs:1567` | `command` / `confirm` | | `index.mjs:1927` | `tool` / `setPersona` | | `index.mjs:1973` | `service` / `update` | 两个间接调用者必须把来源**穿透**下去(它们的 `commitConfig` 调用点 `:429` / `:934` 自己不带来源,来源由下面这些入口决定): | 间接调用者(定义) | 调用点 | 来源 / 动作 | | --- | --- | --- | | `resetConfigWithEscape()`(`index.mjs:423`) | `index.mjs:803`(HTTP reset) | `web` / `reset` | | 同上 | `index.mjs:1430`(`/soul reset`) | `command` / `reset` | | 同上 | `index.mjs:1985`(`soulConfig.resetConfig`) | `service` / `reset` | | `applyPersonaEntry()`(`index.mjs:931`) | `index.mjs:1038`(HTTP personas/use) | `persona` / `use` | | 同上 | `index.mjs:1474`(`/soul use`) | `persona` / `use` | ⇒ **`commitConfig` 必须加第二个参数**:`commitConfig(mutate, source)`, 其中 `source` 形如 `{ source, action }`。 **来源与动作都是白名单枚举**(`lib/history.mjs` 的 `HISTORY_SOURCES` / `HISTORY_ACTIONS`), 写进条目时校验,读回来时再校验一次。游离的字符串会让 UI 的徽标无从渲染, 而「渲染不出来就空着」正是让审计失去可信度的第一步。 ## 4. 数据模型 ### 4.1 存储位置 `$DSH_HOME/soul-config.history.jsonl` —— 配置文件的兄弟文件,路径由 `historyPathFor(configPath())` 推导(把尾部的 `.json` 换成 `.history.jsonl`)。 选 JSONL 而不是 JSON 数组的三个理由: 1. **追加是 O(1)。** 每写一次配置就追加一行,不需要读回整个历史再重写。 2. **崩溃安全。** 进程在追加中途被杀,最坏情况是最后一行截断;其余行完好。 读侧丢弃「解析不了的行」,历史自然收敛到最后一个完整状态。 3. **天然审计语义。** 追加-only 的文件,人能直接用文本工具读,也能用 `git diff` 之类的工具链处理。 ### 4.2 条目结构 ```json { "v": 1, "id": "lq3k9z2a-4f1b2c", "at": "2026-02-14T09:31:07.123Z", "source": "web", "action": "save", "configHash": "9f2c…(64 位十六进制)", "before": { "style": "professional", "emoji": "default" }, "after": { "style": "humorous", "emoji": "more" }, "personas": { "added": [], "updated": [], "removed": [] } } ``` - `id` = `Date.now().toString(36)` + `-` + 6 个随机十六进制字符。`at` 的毫秒精度不足以保证 唯一(写队列串行但两次写入可以落在同一毫秒),所以需要独立的 id。 - `before` / `after` 只装**发生变化的字段**,不是完整配置快照。理由:一次 `style` 改动 不该在磁盘上留下 4.8 KB;而回滚需要的「改成什么」本来就是逐字段的。 - `configHash` 是**这次写入之后**配置的指纹,回滚靠它做链式校验(见 §4.4)。 指纹只覆盖 `DEFAULT_CONFIG` 的键(代码里的 `AUDIT_FIELDS`),`personas` 刻意不在其中 —— 否则「存一个预设」会让「撤销一次 style」因为哈希对不上而被拒绝。 哈希前先对键名递归排序再序列化:配置在「写入时构造」与「读盘后重建」两条路径上 键序可能不同,不排序会算出两个指纹,把正常状态误判成「记录与配置不同步」。 - `personas` 记录预设库的增删改(见 §4.3),无变化时为 `{ added: [], updated: [], removed: [] }`。 - **没有 `fields` 字段**(草案曾有)。`Object.keys(before)` 已经能算出来, 多存一份就多一处可能与 `before` 不一致的地方;读 JSONL 的人本来就要看 `before`/`after`。 ### 4.3 预设库只记事件,不做快照 `index.mjs:399` 的 `diffKeys` 里有 `if (key === 'personas') continue` —— 预设库的增删 **不进 `changed`**,因此也不会自动进审计。这是刻意设计(预设库不是活动配置、 不影响提示词),审计必须显式处理,否则「新增了一个预设」会静默漏记。 处理方式:记录事件,不记录内容。 - `personas.added`: `["实验性重构"]` - `personas.updated`: `[{ "name": "旧的", "entry": { "style": "casual", "emoji": "more" } }]` - `personas.removed`: `[{ "name": "旧的", "entry": { "style": "casual", "emoji": "more" } }]` `updated` 与 `removed` 带条目内容,因为**它们都会丢原内容**:删除是明显的不可逆操作, 而「存为预设」用一个已存在的名字就会静默覆盖 —— 后者更容易被忽略。 新增一个预设后悔了可以再删,删掉或覆盖一个预设后悔了没有任何出路。 这是唯一一处值得存内容的例外,成本可忽略(单个预设几百字节)。 判断「内容有没有变」时只比 `PERSONA_FIELDS` 与元数据,**刻意不比 `updatedAt`**: 它每次保存都会变,算进来会让「原样再存一遍」留下一条假的「覆盖了预设 X」, 而时间线一旦有假记录就失去可信度。 **整表回滚不恢复预设库,但删除可以单独撤销。** 分开处理的理由: - 「恢复活动配置到那一刻」语义清晰;「把预设库恢复到那一刻」语义不明 —— 要不要删掉之后新建的?要不要顺带恢复期间删掉的?对**集合**做时间旅行没有唯一解。 - 而「撤销这一次删除」语义唯一且用户确实需要。所以删除事件的条目上给一个 「恢复此预设」按钮,恢复一个命名条目,不动其它任何东西。 - 与 `defaultConfigPreservingPersonas`(`index.mjs:406`)「重置不清空预设」的既有 取舍一致:预设是资产,重组资产的动作永远由用户单点发起。 ### 4.4 回滚的语义:**撤销**目标条目以及它之后的所有改动 回滚 = 反向重放:从最新一条往回,逐条应用 `before`,一路撤到目标条目(含)。 结果状态是「目标条目发生**之前**」的样子。 ▶ 草案最初写的是「把目标条目的 `after` 应用到当前配置」(= 恢复到那一条刚做完的样子)。 实现阶段推翻了它,理由有两条**硬的**: 1. **点最新一条会变成空操作。** 你正处在最新状态,应用它的 `after` 什么也没改 —— 而最新那条恰恰是用户最想撤的(「刚才那下改错了」)。 最初的辩护是「中间条目上『恢复到那个状态』才是心智模型」,但那省略了最新条目这一最常见情形。 2. **结果会与行内容相反。** 假设记录是 ①改 `style`、②改 `emoji`。点 ① 那一行, 草案的算法把配置设回「①刚做完」的状态 —— 于是 ① 的 `style` 留着(没撤), 反而是 ② 的 `emoji` 被退回去了。列表上写着「style: professional → humorous」, 点「恢复」却改掉了 `emoji`,这是**行内容与行为直接矛盾**。 为什么必须连带撤销它之后的:条目只记变化字段,后一条的 `before` 是它自己那次改动的前提。 跳过中间某条去单独还原更早的字段,会算出一份从未存在过的混合状态。 **链式指纹校验。** 上面的算法假设「记录与当前配置同步」。一旦不同步 (用户手改了 `soul-config.json`、或进程死在「配置已写、审计未写」之间), 撤销出来的就是那份混合状态 —— 比拒绝更糟。所以每一步都先验指纹: 当前状态的 `hashConfig` 必须等于该条目的 `configHash`,对不上就返回 `reason='diverged'`,由调用方明确报错,而不是算出一份从未存在过的配置。 值一律过 `sanitizeConfig` 白名单后再合并:审计文件是**不可信输入**(用户可手改、 可被其它工具写坏),绝不 `{ ...current, ...entry.before }` 直接展开。 校验不通过的字段进 `skipped` 如实回报(沿用 `applyPersonaEntry` 的「部分应用 + 明示」约定), 不静默丢弃。 回滚的返回值里 `reversed` 是被撤销的条目 id 列表(从新到旧,**含目标条目**), 供 UI 在动手前给一句「将撤销最近 N 次改动」。 ## 5. 追加时机与失败处理 追加发生在 `commitConfig` 的**队列任务内、`saveConfig` 成功之后**: ``` loadConfig → mutate → diffFields/diffPersonas → saveConfig → recordHistoryEntry ``` - 在队列内 ⇒ 追加与写入天然串行,不需要第二次加锁 (`lib/history.mjs` 的临时文件名与 `writeConfigFile` 一样固定、不带随机后缀, 正是靠这一点才不必并发去重)。 - 在 `saveConfig` 之后 ⇒ 只有真正落盘的变更才有记录(写失败的提交不留假记录)。 - **追加失败不让提交失败**:审计是次级设施,一次磁盘满不该让用户连设置都存不了。 失败记进模块级 `lastHistoryFailure`,由 `GET /api/soul/history` 的 `warning` 字段 回传给 UI —— 不静默、也不阻塞。 - **空提交不入账**:`before` + `after` + 预设事件全为空就不写行。 否则用户每点一次「保存设置」都刷一条噪声,列表很快没法看 (这也是 `normalizeHistoryEntry` 把空条目判为无效的原因 —— 读侧同样拦一遍, 手改出来的空行也不认)。 - ▶ **「什么都没变」必须在构造条目之前用字段差分判掉**,不能靠 `buildHistoryEntry` 返回 `null` 来表达。后者会把两种情况混成同一个返回值: 真的没变,和条目因为 shape 不对被拒(例如某个调用点漏标 `source`)。 后者是代码缺陷,混进去的表现就是「配置改了、记录里没有、也没有任何告警」—— 审计最该避免的正是这一类。所以有变化却构造不出条目时,带原因记进 `lastHistoryFailure`,把缺陷从静默变响亮。这条是被判断力探针 ⑪ 逼出来的: 最初的写法能通过全部断言,直到把告警行改成 `lastHistoryFailure = null` 才发现 没有任何断言在守着它。 - 条目里的字段差分**不是**直接复用 `diffKeys`:后者只给字段名、 且刻意跳过 `personas`(§4.3)。审计要的是旧值与新值,以及预设库事件。 ## 6. 容量与压实 - `MAX_ENTRIES = 200`(压实后保留的条数)。 - 追加前先 `stat` 文件大小;**只有**超过 `MAX_BYTES = 512 * 1024` 时才读取全文、 保留最新 `MAX_ENTRIES` 条、原子重写(临时文件 + rename,同 `writeConfigFile`)。 - 幸福路径只有一次 `stat`,压实是低频操作:按每条几百字节算, 512 KB 大约在 2000+ 条时才触发,压到 200 条。 - 压实会**重新序列化合法条目**,被丢弃的坏行随之消失。这是有意为之(自愈), 代价是坏行不再出现 —— 所以坏行计数必须在**读取时**就报给用户, 不能等到压实才发现「有几行不见了」。 - 「文件里有多少行」与「实际读回来多少条」是两个数。读取侧还有两个上限 (`MAX_READ_BYTES = 4 MiB` 只读尾部、`MAX_READ_LINES = 1000`), 它们切掉的是**合法**行,绝不能和「解析不了的行」混为一谈(§8)。 ## 7. HTTP 契约 四条路由,**一律先校验 `req.method`** (教训:`/api/soul/config/reset` 曾完全没有方法校验,任何方法包括 GET 都会执行破坏性写入)。 | 路由 | 方法 | 请求 | 响应 | | --- | --- | --- | --- | | `/api/soul/history` | GET | `?limit=`(可选) | `{ ok, entries: [...最新在前...], max, warning? }` | | `/api/soul/history/rollback` | POST | `{ id }` | `{ ok, config, changed, skipped, reversed, entry }` | | `/api/soul/history/restore-persona` | POST | `{ id }` | `{ ok, ...personaLibrary }` | | `/api/soul/history/clear` | POST | — | `{ ok, removed }` | - `rollback` 找不到 `id` ⇒ 400(而不是静默成功)。 - `rollback` 的 `skipped` 列出「审计里有、但当前校验不通过」的字段名(沿用 `applyPersonaEntry` 的「部分应用 + 明示」约定)。 - `rollback` 遇到 `reason='diverged'`(配置与记录不同步)⇒ 409,文案说明 「记录与当前配置对不上,可能被手工改过」,**不**给出一个猜出来的结果。 - `restore-persona` 只接受 `personas.removed` 非空的条目,且**逐个**走 `sanitizePersonaName` + 内置名拒绝 + 同名已存在时拒绝(不静默覆盖用户后来新建的同名预设)。 - 回滚与恢复预设都走 `commitConfig`(带上 `source: 'rollback'` / `'restorePersona'`), 于是**每一个回滚动作自己也留下一条记录** —— 审计天然形成链条, 「我恢复过、然后又改回去」在列表里看得见。 - `entries` 在服务端就按时间倒序返回,客户端不再排序(只排一次,且两边排序规则必然漂移)。 ## 8. 安全与边界 - **历史文件当不可信输入。** 逐行校验:`v` 必须是 1、`id` 匹配 `/^[0-9a-z]{1,12}-[0-9a-f]{6}$/`、 `at` 能被 `Date.parse` 解析、`source`/`action` 在白名单内、`configHash` 是 64 位十六进制、 `before`/`after` 只接受已知配置键。任一处不对**整条**丢弃并计入 `dropped` (不抛错 —— 一条坏行不该让整个页面打不开)。宁可少一条历史,也不写回一个谁都没见过的配置。 - **三种「少了几条」必须分开报。** `dropped` 是「读到了但解析不了」(坏行), `trimmed` 是「因为条数上限没读到」(合法行),`trimmedHead` 是「只读了文件尾部」。 把后两者算进 `dropped`,UI 就会对着一个没有任何坏行的文件说「有 8000 行无法解析」—— 这个误报是本模块自己的断言抓出来的(§14 的 6b)。 - **拒绝原型污染键。** `__proto__` / `constructor` / `prototype` 一律丢弃。 与 `lib/config.mjs` 的 `FORBIDDEN_PERSONA_NAMES` 是**两处独立防线**: 那边管预设名,这边管配置字段与预设事件名 —— 同名常量放在两个模块里各自成立, 不共享是为了让任一侧被绕过时另一侧仍然拦得住。 - **内容绝不进提示词。** 审计记录不参与 `compilePrompt`,与「绑定内容不进提示词」同理。 `verify-history` 里有一条断言专门扫这件事:剔掉注释行后, `lib/history.mjs` 的代码里不出现 `prompt` / 提示词编译相关标识符。 - **长度上限。** 单行超过 `MAX_LINE_BYTES = 64 * 1024` 直接丢弃;单次读取上限 `MAX_READ_BYTES = 4 MiB`(只读尾部,丢掉被窗口截断的首行); 读取行数上限 `MAX_READ_LINES = 1000`(防手写一个 10 万行的文件把内存吃满)。 - **时间戳不参与任何逻辑判定。** `at` 只用于显示;排序与定位都用文件内的物理顺序 + `id`。 连 `pruneEntries` 都不顺手 `sort` —— 按时间排会在同毫秒或时钟回拨时搅乱顺序, 而物理顺序正是 `planRollback` 的判据。 - **读取是流式的,不做「一次 `readFile` 全读进来」。** 这是 `MAX_READ_BYTES` 的意义: 文件可能被外部工具撑大,先把 4 MiB 之后的都读完再截断,等于没有上限。 ## 9. 影响的文件 | 文件 | 改动 | 状态 | | --- | --- | --- | | `lib/history.mjs` | **新增**。路径推导 / 条目构造与校验 / JSONL 解析 / 压实 / 回滚规划(纯函数,可独立测试) | ✅ 0.8.0-a | | `lib/config.mjs` | 新增 `historyMaxEntries`(含 `HISTORY_MAX_ENTRIES_MIN/MAX/DEFAULT` 与 `clampHistoryMaxEntries`);`migrateConfig` / `sanitizeConfig` 同步 | ✅ 打磨轮 | | `scripts/verify-history.mjs` | **新增**。`lib/history.mjs` 的纯函数断言组(43 项:含条数上限可由调用方指定、非法值回退常量) | ✅ 0.8.0-a | | `package.json` | `verify` 链加入 `verify-history` + `verify:history` 单跑脚本 | ✅ 已实现 | | `index.mjs` | `commitConfig(mutate, source)`;16 个调用点补来源(其中 2 个由 `resetConfigWithEscape` / `applyPersonaEntry` 形参穿透);追加与压实 ✅ 0.8.0-b;四条路由 ✅ 0.8.0-c;`NON_PROMPT_FIELDS` 改为推导 ✅ 打磨轮 | ✅ | | `client/index.mjs` | 变更记录区块 **`SoulHistorySection`**(宿主插件详情页槽位)+ 设置页的去向指引;**保留条数下拉在详情页区块内**(0.8.1 从设置页迁入,就地改即时落盘);回滚 / 恢复预设 / 清空的二次确认;文案键 | ✅ 0.8.0-d → 0.8.1 双入口 | | `scripts/verify-config.mjs` | 来源标注(每个调用点各自带对)与「提交即留痕」断言;四条路由的契约断言 ✅ 0.8.0-b / 0.8.0-c;保留条数四条契约 + `NON_PROMPT_FIELDS` 推导契约 ✅ 打磨轮(125 → **132 项**) | ✅ | | `scripts/verify-e2e-prompt.mjs` | 真实挂载跑一遍四条路由:留痕、撤销、恢复预设、清空、405 / 400 / 409;E10c 对 5 个非提示词字段走真实写路径(38 → **77 项**) | ✅ | | `scripts/verify-client.mjs` | 区块渲染、倒序、二次确认、回滚请求体断言(23 → **28 项**,C24–C28) | ✅ 0.8.0-d / 打磨轮 | | `package.json` 版本号 | 0.7.2 → 0.8.0 | ✅ 收尾 | ## 10. 分层与实施顺序 每一步可独立验证、独立提交。 | 步 | 内容 | 验证 | 状态 | | --- | --- | --- | --- | | 0.8.0-a | `lib/history.mjs` 纯函数层 | `verify-history`(40 项:坏行、超长行、原型污染、两个读取窗口边界、压实、回滚链) | ✅ | | 0.8.0-b | `commitConfig` 加来源 + 追加 + 两个间接调用者穿透 | `verify-config` 的来源标注断言(14 个调用点逐个归属 + 4 条时机/隔离断言)+ 11 组变异探针全部被拦 | ✅ | | 0.8.0-c | 四条 HTTP 路由(含方法校验、`skipped`、`restore-persona`) | `verify-config` 的契约断言 + `verify-e2e-prompt` 的 19 项真实路由探针(含 405、非法 id、手改历史文件)+ 12 组变异探针全部被拦 | ✅ | | 0.8.0-d | 设置页「变更记录」分组 | `verify-client`(渲染 / 倒序 / 二次确认 / 请求体) | ✅(0.8.1 双入口后主入口迁至插件详情页,设置页只留去向指引;保留条数下拉随之迁入详情页区块) | ## 11. 已知风险 1. **审计文件与配置文件的原子性不共享。** 配置写入成功、追加失败时,两者会短暂不一致。 缓解:失败有回传(§5),且截断容忍(§4.1)保证不一致只会「少一条记录」,不会让 历史文件不可读。 2. **历史会泄露用户写过的内容。** 用户可能改过一段不想留痕的 `customInstructions`。 缓解:「清空记录」是一等操作,且审计文件与配置同目录、同样在 `$DSH_HOME` 下, 不引入新的泄露面。 3. **回滚不是万能的。** 只恢复活动配置的 16 个字段,预设库不回滚(§4.3)。 缓解:UI 上明确写「恢复人设配置,不影响人设预设库」。 4. **200 条上限会滚动丢弃最老的记录。** 缓解:上限是常量、UI 显示「最多保留 200 条」。 5. **「恢复此预设」可能撞上同名条目。** 用户删了 A,之后又新建了一个同名的 A, 此时点旧记录的「恢复」会静默覆盖新版。缓解:同名已存在时**拒绝**并给出文案, 不静默覆盖(覆盖是不可逆的,而拒绝只是让用户自己先改名)。 6. **审计只覆盖 `commitConfig` 这条路。** 手改配置文件、别的插件直接写同一个文件, 都不留痕。这不是缺陷而是边界(§12)—— 假称覆盖了全部改动比不覆盖更危险。 7. **手改配置会让回滚整体失效。** 链式指纹(§4.4)的代价:用户手工编辑一次 `soul-config.json` 之后,`hashConfig(current)` 与最新记录的指纹对不上, 于是**任何**回滚都会返回 `diverged`。 缓解:这是刻意的取舍 —— 相比「算出一份从未存在过的配置」,明确报错是可恢复的 (用户可以清空记录从头开始,或者手动改回去)。UI 的文案要说清是「记录与当前配置对不上」, 而不是「出错了」,否则用户会反复重试同一个必然失败的动作。 ## 12. 明确不做 - **不做预设库的整表快照与回滚**(§4.3)。删除的单点撤销是例外,且有单独的语义与按钮。 - **不做审计的导出 / 导入**:想要的人可以直接拷 `soul-config.history.jsonl`。 - **不做文件变更监听与外部改动审计**:只记录经由 `commitConfig` 的写入。 手改 `soul-config.json` 不留痕 —— 那属于「fs 层的事」,本插件没有义务也没有能力覆盖。 - **不做「谁改的」到 Agent 身份级的归因**:工具调用记录 `source: 'tool'`, 不细分到具体会话 / 消息 id(会话 id 会随子代理变化,归因不可靠且泄露面更大)。 - **不做定时清理 / 自动过期**:只按条数压实,不按时间删。 - **不做配置的版本分支 / 命名快照**:那是「预设」已有的职责,两套东西会打架。 ## 13. 附录:客户端 UI 细节(设置页第 5 组) 分组标题「变更记录」/「Change history」,排在「Agent 工具」(`client/index.mjs:2300`) 之后,是最后一组。走既有手风琴(`openSection === 'history'`)。 三块内容: 1. **顶部一行状态**:「最近 N 条 · 最多保留 200 条」,右侧一个「清空记录」文字钮 (内联二次确认:armed 态 + 5s 超时,与删除预设同一套,**不用原生 `confirm`**)。 2. **记录列表**(最多渲染 20 条,其余折叠为「还有 N 条」)。 每行:相对时间 · 来源徽标 · 字段名列表。展开后逐字段显示 `旧值 → 新值`。 3. **行尾操作**:每条记录一个「恢复」文字钮(纯文字样式,`color: var(--dsw-alias-state-error-primary)` 留给破坏性动作),`personas.removed` 非空的记录额外给一个「恢复此预设」。 数据取自 `GET /api/soul/history`。分组展开时才拉取(与「人设预设」同款懒加载), 回滚 / 恢复 / 清空后重新拉取并重置 armed 态。 文案键(zh / en 各一份): | 键 | zh | | --- | --- | | `group.history` | 变更记录 | | `history.empty` | 还没有任何变更记录。 | | `history.summary` | 最近 {count} 条 · 最多保留 {max} 条 | | `history.more` | 还有 {count} 条 | | `history.clear` | 清空记录 | | `history.clearArmed` | 确认清空? | | `history.restore` | 撤销这一次 | | `history.restoreArmed` | 撤销? | | `history.restoreDone` | 已撤销 {count} 次改动。 | | `history.restorePreset` | 恢复此预设 | | `history.restorePresetDone` | 已恢复预设「{name}」。 | | `history.diverged` | 记录与当前配置对不上,无法撤销。可能有人手工改过配置文件。 | | `history.warning` | 记录可能不完整:{reason} | | `history.footer` | 撤销人设配置,不影响人设预设库。 | 按钮文案用「撤销」而不是「恢复」,与 §4.4 的语义一致:点一条记录的结果是 **把这一次改动撤掉**(连同它之后的),不是「切到那天的状态」。 `history.restoreDone` 回报 `{count}`(实际撤销了几次),因为点击中间那条会连带撤销 它之后的改动 —— 不报数的话用户会觉得「我只点了一次,怎么变了这么多」。 来源徽标文案:`web` 网页保存 / `command` 斜杠命令 / `tool` Agent 工具 / `service` 插件调用 / `persona` 应用预设 / `rollback` 撤销 / `restorePersona` 恢复预设。 ## 14. 判断力探针(每个行为改动都要配一批) 断言通过不等于断言有效。以下每一条都写成一个**能制造真实故障的变异** (在临时副本上改坏源码,期望至少一条断言变红),照既有做法落在一个 shell 驱动里, 带控制组先行 / `trap` 兜底 / `mktemp` 不删三条防呆。 | # | 变异 | 期望变红的断言 | | --- | --- | --- | | 1 | `commitConfig` 里把 `appendHistory` 挪到 `saveConfig` **之前** | 「配置写失败时不得留下审计条目」 ✅ ③ 已验 | | 2 | 删掉 `diffKeys` 里的 `personas` 跳过判断 | 「仅预设变更必须出现在时间线上」(这是核心陷阱) | | 3 | 追加失败时把异常向上抛(而不是只记 `lastHistoryFailure`) | 「审计写失败配置仍成功」 ✅ ⑤/⑥ 已验 | | 4 | 回滚时 `{ ...current, ...entry.before }` 直接展开(不过 `sanitizeConfig`) | 「记录里的非法值不得写入配置」 | | 5 | 撤销链的循环上界从 `i >= index` 改成 `i > index`(不撤目标条目自己) | 「撤销最新一条必须真的改掉它」(这正是实现阶段被推翻的那条草案语义) | | 5b | 撤销链去掉 `hashConfig` 指纹校验 | 「配置与记录不同步 ⇒ 拒绝」与「链条中间断掉也要拒绝」 | | 6 | 删掉历史文件的坏行过滤 | 「一条坏行不得让整个列表打不开」 | | 6b | 把「超出读取窗口的合法行」计进 `dropped` 而不是 `trimmed` | 不得报成「有 N 行无法解析」(本文件被自己的断言抓到的真实误报) | | 6c | `readHistoryFile` 去掉「起点正好落在行首 ⇒ 保留首行」的一字节探测 | 「切在行首 ⇒ 首行是完整的,必须保留」 | | 6d | `personaSignature` 把 `updatedAt` 也算进内容指纹 | 「原样再存一遍不留假记录」 | | 7 | 删掉「同名预设已存在则拒绝」 | 「恢复预设不得静默覆盖同名条目」 | | 8 | `rollback` 路由删掉 `req.method` 校验 | 「GET `/rollback` 必须 405」(对照 `config/reset` 的既有教训) | | 9 | 把 `source` 参数默认成一个固定值(丢掉 14 处标注) | 「每个调用点各自的来源必须落在条目里」 ✅ ② 已验 | | 10 | 压实阈值改成永久不触发 | 「超过上限时必须压实」 | `0.8.0-a` 已落地的部分(`lib/history.mjs` + `scripts/verify-history.mjs`,40 项断言) 走的是**离线断言**而不是 shell 变异探针:纯函数层的每一条边界都能在断言里直接构造, 不需要改坏源码再跑。变异探针留给 `0.8.0-b`–`0.8.0-d` —— 那三步断言的是 「宿主行为」与「调用点归属」,只有在集成层才验得动。 ### 14.1 `0.8.0-b` 实际跑过的 11 组变异(全部被拦) 驱动脚本 `judge.mjs`:把仓库拷进临时目录、**先跑一遍控制组**(不改任何东西必须全绿, 否则所有「被拦」都可能来自环境),再逐条在独立副本上改一个锚点、跑一次 `verify-config`,锚点没命中就如实报「变异未生效」而不是算作被拦。 | # | 变异 | 被哪条断言拦下 | | --- | --- | --- | | ① | 去掉某个调用点的第二个实参(.history 记成「不知道谁改的」) | 提交即留痕:唯一漏斗的每个调用点都自带来源归属 | | ② | `source` 形参加上默认值(忘了标注的调用点静默落进默认桶) | 同上 | | ③ | 留痕挪到 `saveConfig` 之前(配置写失败却留下一条记录) | 留痕时机在配置落盘之后 | | ④ | 差分基准换成 `mutate` 的产物(把 `tone`/`presets` 等废弃字段误报成变更) | 同上 | | ⑤ | 审计失败向上抛(「记不下来」升级成「改不了」) | 留痕时机…且失败不得连带配置写失败 | | ⑥ | 审计失败静默吞掉(不记 `lastHistoryFailure`,用户看不到) | 同上 | | ⑦ | 重置的 `source` 不穿透(三个重置入口退化成同一个来源) | 重置是配置损坏时的逃生口 | | ⑧ | **张冠李戴**:把 `confirm` 的标注挪给 `set`(数量对、位置错) | 提交即留痕(这条说明只数「有几个 `source:`」是不够的) | | ⑨ | `index.mjs` 自己拼历史文件名(改一处忘一处会写到别的文件) | 留痕时机… | | ⑩ | `applyPersonaEntry` 的来源标成 `web`(应用预设认不出是预设应用) | 提交即留痕 | | ⑪ | 「条目构造失败」并回「无变化」(漏标 `source` 变成静默丢失记录) | 留痕时机… | ▶ **⑪ 是这份表里唯一最开始失败过的一条,且失败的是探针本身而不是实现。** 初版变异在原有告警行**之前**插了一行 `lastHistoryFailure = null`,紧接着的原始行 又把它覆盖回真实原因 —— 根本没制造出「静默跳过」这个故障,却让人误以为 「这条断言守不住」。**教训:变异没被拦时,先证明变异真的制造了故障, 再怀疑断言。** 改成直接替换掉告警赋值之后立即被拦。 ### 14.2 `0.8.0-c` 实际跑过的 12 组变异(初跑 11/12,漏网一条已补断言) `0.8.0-b/c` 的断言面不同:`b` 验的是「调用点归属」(源文本正则就够), `c` 验的是「分支真的会走对」—— 405 / 400 / 409 是判定逻辑,源码里有这行代码 不等于这条分支会被触发。所以这一批的落点是 `verify-e2e-prompt`:真实挂载 + 真实临时 `DSH_HOME`,把请求打进去看返回码。 | # | 变异 | 被哪条断言拦下 | | --- | --- | --- | | M1 | 删掉 `GET /api/soul/history` 的 method 校验 | 变更记录四条路由 / G7(POST 列表必须 405) | | M2 | 删掉 `rollback` 的 method 校验(GET 也能撤销) | 同上 / G7 | | M3 | `diverged`(409)降级成「当作空补丁成功」 | 同上 / H2 | | M4 | 找不到 id 也回 200 而不是 400 | 同上 / G11 | | M5 | 丢掉 `read.error` 的中止(记录读不出来按空记录继续) | 同上 | | M6 | 撤销后不刷新提示词注入(配置改了但 Agent 还按旧的说话) | 同上 | | M7 | 恢复预设静默覆盖同名条目 | 同上 / G13 | | M8 | 恢复预设不拒绝内置名 | 服务端五条写路径都拒绝内置名 | | M9 | 清空不入队(与并发追加撞车,「刚清完又冒出一条」) | 同上 | | M10 | 撤销的 `source` 标成 `web/save`(回滚动作伪装成普通保存) | 提交即留痕:唯一调用点归属 / G10 | | M11 | 记录读进队列外(规划建立在可能已被改过的配置上) | 变更记录四条路由 | | M12 | `restore-persona` 的 `skipped` 不回报(只报成功的) | **初跑漏网** —— 见下 | ▶ **M12 是这一批唯一漏网的一条,它暴露的是断言覆盖面的缺口而不是实现缺陷。** `skipped` 原先只钉了 `reason: 'exists'`,于是删掉 `invalid-name` 那条分支没人发现。 那条分支**可达**:`normalizePersonaEvents` 只看「非空且不超长」,三条空格 `' '` 满足它,而 `sanitizePersonaName` 会拒绝 —— 恰恰是最该回报的一类(用户只看到 「恢复了 2 个」,不知道有 1 个被丢掉了)。 补法有两层:`verify-config` 加一条源文本断言,`verify-e2e-prompt` 加 **G16** —— 往记录文件里追加一条**手工伪造**的条目(含空白名与合法名),要求既恢复合法的那个、 也把空白名报进 `skipped`。后者同时是「记录文件按不可信输入处理」这条设计前提的 第一个端到端证据。补完后 12/12。 ### 14.3 `0.8.0-d` 实际跑过的 18 组变异(初跑 15/18,三条已补断言) `d` 段验的是**界面**,而界面断言最容易退化成「源码里有这段字符串」的同义反复: `assert.match(src, /soul-history-list/)` 只能证明字符串存在,证明不了用户看得见什么。 所以这一批的每条变异都写成「另一种看起来也合理的写法」,落点是 `verify-client` 的 C24–C27(按**用户看到的字**断言,用 React 垫片真渲染)与 `verify-config` 的字段标签覆盖率 check。 | # | 变异 | 被哪条断言拦下 | | --- | --- | --- | | ① | 不展开也去拉记录(每个会话白白多打一次接口) | C24:未展开「变更记录」时不得请求 `/api/soul/history` | | ② | 相对时间退化成原样输出 ISO 串 | C24:两条记录必须是「1 分钟前」/「3 小时前」 | | ③ | 时钟回拨不再归一(界面出现「-3 分钟前」) | **初跑漏网** —— 见下 | | ④ | 空值不再显示「(空)」 | C25:被清空的字段必须有占位,不是留白 | | ⑤ | 逐字段差分只遍历 `after` | C25:只在 `before` 里的键也必须占一行(期望 5 行,实际 4) | | ⑥ | 字段名不再查表 | C24:字段摘要必须是词典里的名字,不是裸键名 `trailWidth` | | ⑦ | 枚举值不查词典(白名单条件删掉) | **初跑漏网** —— 见下 | | ⑧ | 布尔字段渲染成 `true`/`false` | C25:必须是「已开启 / 未开启」 | | ⑨ | 撤销去掉二次确认 | C26:第一次点后按钮应变成「撤销?」且不得发请求 | | ⑩ | 清空去掉二次确认 | C26:第一次点后按钮应变成「确认清空?」且不得发请求 | | ⑪ | 一次铺开全部记录 | C27:22 条时最多只渲染 20 条 + 「还有 2 条」 | | ⑫ | 同时展开多行(展开态改存布尔) | C25:展开第二条时第一条必须收起 | | ⑬ | 拉取失败静默(折叠摘要不再说加载失败) | C24:失败后折叠摘要必须是「加载失败」,不能留空 | | ⑭ | 「恢复此预设」挂到每一条上 | C25:只出现在删过预设的那条(期望 1 个,实际 2 个) | | ⑮ | `warning` 渲染成错误块 | C27:必须是组内提示,`.soul-history-warning` 不得染成 `.soul-error` | | ⑯ | 顶部计数直接用固定 `200`,无视服务端带回的上限 | **初跑锚点未命中** —— 见下 | | ⑰ | 字段标签表漏掉一个审计字段 | `verify-config`:字段标签必须覆盖全部审计字段 | | ⑱ | 客户端自己再排一次序(翻成「最旧在前」) | `verify-config`:列表渲染块不得出现 `.reverse()` / `.sort()` | ▶ **这一批的三条漏网各自指向一种不同的失效,值得分开记。** - **③(时钟回拨)暴露的是样本覆盖死角。** 变异把 `if (diff < 60 * 1000)` 改成 `if (diff > 0 && diff < 60 * 1000)`,负的时间差于是落到 `Math.floor(diff / 60000)` 上,界面显示「-3 分钟前」。但 C24/C25 的样本时间戳 **全在过去**,负分支一次都没被执行过。补法:给 C24 加一条 `at` 在未来的记录, 断言它归入「刚刚」——**同一种输入形态(负数)必须有样本真的走到它**。 - **⑦(枚举白名单)暴露的是「条件存在但从未为假」。** 变异删掉 `HISTORY_ENUM_VALUES[key].includes(value)` 之后,非法取值也会去查词典, 渲染出裸键名 `trail.speed.turbo`。可样本里的枚举值**全是合法的**, 这个条件永远为真。补法:样本里加一个枚举外的取值(`trailSpeed: 'turbo'`), 断言它原样显示 —— 凡是「先校验再转换」的分支,样本里都要有一条**校验不通过**的。 - **⑯ 暴露的是一种更隐蔽的问题:期望值本身是实现的同义反复。** 这条初跑时 连锚点都没命中(探针脚本侧问题),但即便命中也会**照样通过** —— 因为路由桩 返回 `max: 200`、而断言也写着 `'200'`,那个 `200` 正好等于实现里的 `MAX_ENTRIES`。期望值等于实现的常量时,「写死常量」这条断言零判别力。 补法:桩改成返回 `max: 50`,断言跟着用 `50`。**变体强度不只指变体写法像不像真的, 也包括期望值不能是实现的同义反复。** ### 14.4 `0.8.1` 双入口迁移的 20 组变异(20/20 首跑全拦) 0.8.1 起主入口从设置页第 5 组迁到宿主插件详情页(`plugins.detail.section` 槽位, 组件 `SoulHistorySection`),设置页只留一行指引。14.3 的探针锚点全部失效 (缩进与组件归属都变了),于是按新代码位置重写一整批,并新增三条针对迁移本身的: | # | 变异 | 被哪条断言拦下 | | --- | --- | --- | | ❶ | subject 判据放宽成只看插件名(非 bundle 形态也渲染) | C24:`subject={"kind":"item",…}` 不得渲染变更记录区块 | | ❷ | 设置页把指引行换回完整列表(双入口退化成双份渲染) | C28:设置页不得渲染变更记录列表 | | ❸ | 详情页区块挂载时不拉取 | C24:挂载详情页区块应恰好拉取一次 | | ❹–⓲ | 14.3 的 ①②④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯ 平移到新组件代码位置(相对时间 / 时钟回拨 / 空值 / 只遍历 after / 枚举白名单 / 布尔 / 撤销与清空二次确认 / 20 条折叠 / 单行展开 / 失败静默 / 恢复入口 / warning 染红 / 计数写死) | 同 14.3 对应行;另新增 ⓲「按钮里套按钮」被 C25 的「每条记录应恰好一个可展开主体」拦下 | | ⓳ | 字段标签表漏掉一个审计字段 | `verify-config`:字段标签必须覆盖全部审计字段 | | ⓴ | 客户端自己再排一次序 | `verify-config`:列表渲染块不得出现 `.reverse()` / `.sort()` | 这一批首跑即 20/20,没有漏网 —— 14.3 补的三条断言(未来时间戳样本、枚举外取值 样本、`max: 50` 桩)在代码搬迁后依然守得住,说明那三种补法补的是**断言的判别力** 而不是某个具体实现的形状。 ### 14.5 `0.8.0` 打磨(保留条数可调 + 说明并入展开区)的 22 组变异(初跑 17/21,四条已定位) 本轮抓出的**真缺陷**是 `index.mjs` 的 `NON_PROMPT_FIELDS` 是**手工白名单**: `requireToolConfirmation` 与新增的 `historyMaxEntries` 都不在其中,于是改一次 「保留条数」会重新注册 system prompt section、并向所有活动会话注入一条 「[dsh-soul 个性化配置已更新]」。用户视角是「我只调了个容量,Agent 却被通知了一遍配置变了」。 修法:由 `DEFAULT_CONFIG` 减去 `PROMPT_INPUT_FIELDS` **推导**;推导的安全前提 (`compilePrompt` 只读 `PROMPT_INPUT_FIELDS` 内的字段)另配一条源文本契约钉住 —— 否则将来新增提示词字段却忘了登记,会静默变成「改了不生效」,那是本插件最坏的一类失败。 | # | 变异 | 被哪条断言拦下 | | --- | --- | --- | | ❶ | `NON_PROMPT_FIELDS` 退回手写清单 | `verify-config`:必须由 `Object.keys(DEFAULT_CONFIG)` 推导 | | ❷ | 推导式里再排除掉 `historyMaxEntries` | E10c:该字段保存后 section 与注入都不得增加 | | ❸ | 推导式里再排除掉 `requireToolConfirmation` | 同 ❷(同一个漏登记的另一半) | | ❹ | 推导方向写反(拿 `PROMPT_INPUT_FIELDS` 当非提示词集合) | E10:纯外观字段保存后不重新注册 section | | ❺ | 编译函数里新增一个未登记的 `config.X` 读取 | `verify-config`:编译源码读取的每个字段都必须已登记 | | ❻ | `PROMPT_INPUT_FIELDS` 里的字段被换成未登记形态 | 「预设范围只含人设,与「关于你」无交集」 | | ❼ | `clampHistoryMaxEntries` 退回「先 `Number()` 再判整数」 | clamp 逐值:`null` / 空串必须返回 `null` 而不是下限 50 | | ❽ | clamp 只判下界不判上界 | clamp 逐值:100000 必须夹成 500 | | ❾ | `DEFAULT_CONFIG.historyMaxEntries` 与 `MAX_ENTRIES` 脱钩 | `verify-config`:两模块常量必须相等 | | ❿ | `pruneEntries` 忽略调用方给的上限 | `verify-history`:必须真的按传进来的上限截 | | ⓫ | `pruneEntries` 保留头部而非尾部 | `verify-history`:切出的 id 序列必须是尾部 | | ⓬ | `toHistoryResponse` 的 `max` 写死模块常量 | `verify-history`:`max` 来自调用方 | | ⓭ | 下拉档位写进服务端区间之外的档(1000) | `verify-config`:档位必须落在合法区间内 | | ⓮ | 客户端默认档与后端默认值不一致 | 同上(必含默认档) | | ⓯ | 字段标签表漏掉 `historyMaxEntries` | `verify-config`:标签必须覆盖全部审计字段 | | ⓰ | label 的 `htmlFor` 不再关联 select | `verify-config`:`label` 必须关联到控件 | | ⓱ | 说明块拆回折叠标题旁 | C28:说明块应恰好一个 | | ⓲ | 说明块少一行(吞掉变更记录去向) | C28:说明块应有两行 | | ⓳ | 说明块两行顺序颠倒 | C28:第一行应是预设收录范围 | | ⓴ | 说明文案挪进折叠触发器 | C28:折叠态不该出现指引 | | ㉑ | 说明块挪进另一组手风琴 | C28:只有人设预设分组挂说明块 | | ㉒ | `.soul-accordion-head` 层复活 | C28:head 层应已删除 | **初跑 17/21 的四条,两条是探针自己的问题,两条是锚点没对上真实源码:** - ⓰ 首轮报「仍通过」,但**变异确实被拦** —— 它被挂在了渲染层脚本上,而 `label.htmlFor` 是**源文本契约**(归 `verify-config` 守)。单独复现一次即分清: 改挂 `CONFIG` 后当场变红。**没被拦时除了怀疑断言,还要怀疑「这条变异该由谁来拦」。** - ⓴ 首版的变异写法(在 `.soul-note` 之后追加一个 `SoulHint`)其实**改变了渲染树结构**, 但它挂在 `CLIENT` 上时被 C28 的「说明块之外不得有 hint」判据兜住了;重写为 「把 `history.entryHint` 写进 `summary` prop」后,判据落在折叠态文本上,更贴近 用户的真实观感(折叠时就看得见 → 正是要求改掉的那个形态)。 - ❽ / ⓫ / ⓰ 的锚点串初版对不上真实源码(`Math.min(Math.max(...))` 单行写法、 `list.slice(list.length - limit)`、缩进)。为此写了**锚点自检脚本** `D:\AliuProject\_p0822\anchors.mjs`:把每条探针的 `find` 串拿到真实源码里查一遍, 秒级完成。整批跑一轮要几分钟,锚点没命中却只在末尾报一句「变异未生效」—— 先过锚点,再跑整批。