@nightpainters/dsh-ling 1.5.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host/api.js CHANGED
@@ -17,8 +17,14 @@ import { applyImportItems } from './import-file.js';
17
17
  import { sealOk, KEY_MIN } from './seal.js';
18
18
  import { timeAnchorDate, timeAnchorLive } from './clock.js';
19
19
  import { TONE_ADVICE_SYS, TONE_SET, sampleToneRows, parseToneAdvice, appendStyleNote } from './tone-advice.js';
20
- import { logImport, listImportLog, formatLogLine } from './import-log.js';
20
+ import { logImport, listImportLog, formatLogLine, cleanTooBig, cleanTooBigNames, cleanFoldSkipped } from './import-log.js';
21
21
  import { checkRequest, lanAddresses, sanitizePersonaPatch } from './guard.js';
22
+ // 手术门(2026-09-30 深夜):本机判据 / 每启动票据 / 承诺句 / 哈希链留痕 —— 判据本体只在 surgery.js 一处。
23
+ // ⚠️ 上面那行 guard.js 的 import **不许动形态**:tests/guard.test.mjs:139 逐字匹配它。
24
+ import {
25
+ gateSurgery, isLocalSurgeryRequest, readSurgeryTicket, rotateSurgeryTicket,
26
+ listSurgeryEvents,
27
+ } from './surgery.js';
22
28
  import {
23
29
  mapBranchList, convExistsIn,
24
30
  opBranchCreate, opBranchDelete, opBranchReparent, opBranchRename, opBranchWeight,
@@ -41,6 +47,9 @@ import {
41
47
  import { TRUNK_ID } from './memory.js';
42
48
  import { bucketize, nameBucket } from './autotree.js';
43
49
  import { accessLogStatus } from '../audit/index.js';
50
+ // E2 可见化(2026-10-01):只取**键名常量** —— 那条留痕的键名与写法归 summarizer.js 一处所有,
51
+ // 本文件不另拼一份字符串(两处各写一份键名,将来必然漂成两个键)。
52
+ import { RAW_UNKNOWN_NS_KEY } from './summarizer.js';
44
53
 
45
54
  // (旧)只校验 cookie 名形状的守卫已废弃 —— 见 guard.js:来源栅栏 + 名字对撞(2026-09-16 加固 ①+②)
46
55
  // 网页端聊天记录库的默认路径:私有路径不写死在代码里 —— 由 UI 传入,或用 DSH_LING_DSWEB_DB 环境变量覆盖。
@@ -62,6 +71,111 @@ function sendJson(res, status, body) {
62
71
  * ② /memories/pin|delete 会拒收它 ⇒ 主人无法置顶或删除这类记忆。 */
63
72
  const MEM_SOURCES = ['dsh', 'dsweb', 'import', 'vein'];
64
73
 
74
+ /** E3(审计 H-4):会话内调用**没有原话**时,规矩的原话位写的如实标注。
75
+ * 它**不是原话**,是"没有原话"这件事本身 —— 仿 `rules.js` 自己的「(面板直接写入)」惯例。 */
76
+ export const SESSION_NO_QUOTE_NOTE = '(会话内调用,未附原话)';
77
+
78
+ /**
79
+ * E3(审计 H-4,2026-10-01 修):规矩落库的**归因判据** —— 全仓唯一一处。
80
+ *
81
+ * 旧写法是 `source: body.quote ? 'session' : 'panel'` —— 拿**客户端可控字段** `body.quote`
82
+ * 反推"这条规矩是谁提的"。而**真实面板本来就不发 quote**(`lib/client.js` 的表单发的正是
83
+ * `{ action:'add', rule }`)⇒ 面板确实靠"没有 quote"被认出来,但**同一个形状也把
84
+ * 「会话内、不带 quote 的 HTTP 调用」一起吞成了 `'panel'`**。后果不是文案问题:
85
+ * `rules.js` 对 `'panel'` 免掉原话要求,并把原话位填成「(面板直接写入)」——
86
+ * 于是一次**没有原话、也没有主人在场**的会话内调用,被记成"主人亲手写的那一条",
87
+ * 留痕从此不能用来回答"这条规矩是谁提的"(审计 §H.1 溯源格 🟡 的实锤之一)。
88
+ *
89
+ * 两种归因是两回事,这里分开判 —— 判据只看**请求形状**,不再拿 quote 有无去反推身份:
90
+ * · `'panel'` = 主人在**面板**里亲手敲的。面板的形状 = 既没有会话身份、也没有原话;
91
+ * 他自己敲的就是同意,故**不要求**另附原话(`rules.js` 既有语义,本轮未动);
92
+ * · `'session'` = **会话内**代写,且带了原话 ⇒ 「经同意」可核查;
93
+ * · `'session-no-quote'` = **会话内**调用但**没带原话** ⇒ 手里没有「经同意」的任何凭据。
94
+ * 如实记这个标记,原话位写 `SESSION_NO_QUOTE_NOTE` ⇒ 不冒充面板、也不冒充同意。
95
+ *
96
+ * ⚠️ 面板身份是**形状识别,不是签名**:本机调用方仍可照抄这个形状(面板那一侧今天没有签名可用)。
97
+ * 要变成不可冒充,得让面板带上只有它才有的凭据 —— 那要改 `lib/client.js`,不在本轮可写清单内,
98
+ * 已列入 1.6 的残余风险。
99
+ * @param {{sessionId?:unknown, quote?:unknown}} [body] 端点收到的 JSON body
100
+ * @returns {{source:'panel'|'session'|'session-no-quote', sessionId:string, quote:string, fromPanel:boolean}}
101
+ */
102
+ export function ruleWriteAttribution(body = {}) {
103
+ const sid = String(body?.sessionId ?? '').trim();
104
+ const quote = String(body?.quote ?? '').trim();
105
+ const fromPanel = !sid && !quote; // = client.js 面板表单的形状(它既不发 sessionId,也不发 quote)
106
+ const source = fromPanel ? 'panel' : (quote ? 'session' : 'session-no-quote');
107
+ return {
108
+ fromPanel,
109
+ source,
110
+ // 面板写入没有会话身份 ⇒ 留痕仍写 'panel'(可读的"这不是某个会话");会话内调用写真实 id。
111
+ sessionId: fromPanel ? 'panel' : sid,
112
+ // 原话位**只说真话**:面板传空串,由 rules.js 填它自己的「(面板直接写入)」(那一步未改);
113
+ // 会话内无原话时填如实标注 ⇒ 这次写入**照旧落库**(与改前一致:改前它也落库,只是被贴成
114
+ // 'panel'),但记录里写清了"没有原话"。若将来要**直接拒收**这一支,判据在 rules.js 的
115
+ // no-quote(它只认 src 是否为空)—— 那要动 rules.js,不在本轮可写清单内。
116
+ //
117
+ // ⚠️ **实测过的边界(别把它读成"没做")**:`ruleMeta.source` 落库时只有 `'panel'` / `'session'`
118
+ // 两个值 —— rules.js 组装 meta 的那一行是 `source: fromPanel ? 'panel' : 'session'`,
119
+ // **非 panel 一律折成 'session'** ⇒ 上面第三个值 `'session-no-quote'` 到不了库里的
120
+ // `source` 字段(它活在**判据与回执**里:/persona/rule 的响应 `source` / `unquoted`)。
121
+ // 库里"这次没有原话"由**原话位**承载(`SESSION_NO_QUOTE_NOTE`),面板看得见。
122
+ // 要把三态带进 `ruleMeta`,得改 rules.js 那一行 —— 不在本轮可写清单,已列入残余风险。
123
+ quote: fromPanel ? '' : (quote || SESSION_NO_QUOTE_NOTE),
124
+ };
125
+ }
126
+
127
+ /** 路径归一(只用于**白名单比对**,不碰文件系统):分隔符统一 / 尾斜杠去掉;
128
+ * Windows 再折大小写(盘符与文件名都不区分大小写)。== 只归一化,不解析 `.`/`..` ==:
129
+ * 归一化只会让"管理员写的路径"与"请求里的同一路径"对上,不会让白名单外的路径对上。 */
130
+ const normDbPath = (p) => {
131
+ const s = String(p ?? '').trim().replace(/\\/g, '/').replace(/\/+$/u, '');
132
+ return process.platform === 'win32' ? s.toLowerCase() : s;
133
+ };
134
+
135
+ /** B(审计 §四.4,2026-10-01 修):`/dsweb/summary/preview?db=` 的**准入白名单** —— 全仓唯一一处。
136
+ *
137
+ * 旧写法是 `dswebDbOf(q.get('db'))`(收任意路径)→ `openSource(db)` 只读打开 ⇒ 这是一个
138
+ * **任意本机路径的存在性预言机**:问一句就知道某个 `.db` 在不在、里面有多少条与记忆库对得上的会话。
139
+ * 审计把它列进"资源型"面时特别注明:它**今天靠 `readOnly:true` 才不是写原语** ——
140
+ * 也就是整个安全性挂在一个 flag 上(把这个 flag 去掉就是一条任意文件的写原语)。
141
+ *
142
+ * 判据:只接受**默认库**(环境变量 `DSH_LING_DSWEB_DB`)**或 settings 白名单**里的路径;
143
+ * 其余一律拒并给出 `reason`(**不静默**),让调用方能区分:
144
+ * · `no-db` = 你没给路径(且没有默认库)—— 文案与改前逐字一致;
145
+ * · `db-not-allowed` = 你给的路径不许(白名单外)。
146
+ *
147
+ * 白名单键(`settings.dsweb`):`dbPath` / `db`(单值)、`dbs[]` / `dbPaths[]`(数组)。
148
+ * 认这几个拼法是因为这套键**到今天还没有默认值**(`persona.js` 的 DEFAULT_SETTINGS 里没有
149
+ * `dsweb` 子树 ⇒ 不存在"既有约定"可循),所以这里不发明新配置面,只采最直白的写法。
150
+ * ⚠️ 面板(`client.js`)的 db 是**输入框手填**的 ⇒ 收紧后手填的路径会被拒 ——
151
+ * 故端点里的拒绝文案必须给出两条出路(env / settings),否则就是"静默关功能",
152
+ * 与审计"别静默"的要求正好相反。
153
+ *
154
+ * @param {unknown} value 请求里的 `?db=`
155
+ * @param {{get?:Function}|object} settings settings 服务或裸配置对象
156
+ * @param {Record<string,string|undefined>} [env] 环境变量来源(测试注入,不碰真机 env)
157
+ * @returns {{ok:boolean, db:string, allowed:string[], asked?:string, reason?:string}}
158
+ */
159
+ export function previewDbPolicy(value, settings, env = process.env) {
160
+ const asked = String(value ?? '').trim();
161
+ const cfg = (settings && typeof settings.get === 'function' ? settings.get() : settings) || {};
162
+ const d = cfg.dsweb || {};
163
+ const asList = (x) => (Array.isArray(x) ? x : []).map((s) => String(s || '').trim());
164
+ const envDb = String(env?.DSH_LING_DSWEB_DB || '').trim();
165
+ const allowed = [...new Set([
166
+ envDb,
167
+ String(d.dbPath || '').trim(),
168
+ String(d.db || '').trim(),
169
+ ...asList(d.dbs),
170
+ ...asList(d.dbPaths),
171
+ ].filter(Boolean))];
172
+ const hit = (p) => allowed.some((a) => normDbPath(a) === normDbPath(p));
173
+ // 没给 db:照旧用默认库(默认库为空时由调用方回既有的 no-db 文案,与改前逐字一致)
174
+ if (!asked) return { ok: true, db: envDb, allowed };
175
+ if (hit(asked)) return { ok: true, db: asked, allowed };
176
+ return { ok: false, db: '', allowed, asked, reason: 'db-not-allowed' };
177
+ }
178
+
65
179
  /** ⚠️ **写操作命中 0 行的"静默失败"**(审计 B#3,2026-09-22 修):
66
180
  * 底层 `renameBranch` / `setBranchWeight` / `reparentBranch` 用的都是 `UPDATE ... WHERE id=?`
67
181
  * —— id 不存在时 SQLite 命中 0 行、`changes=0`,可方法仍回 `{ok:true}`。端点据此写了
@@ -136,27 +250,141 @@ function batchLeave(kind) {
136
250
  /** 整批 deadline 判据:到点即**不再开始新的一条**。 */
137
251
  const batchTimeout = (kind, t0) => (Date.now() - t0) >= (BATCH_MS[kind] || 120000);
138
252
 
139
- function readBody(req, maxBytes = 4 * 1024 * 1024) {
253
+ /** ⚠️ **请求体上限的入口**(2026-09-29 修:一次「批失败(网关)」误报的根治点)。
254
+ * 旧实现把"超限"处理成一句话:`size > maxBytes` ⇒ `reject(...)` **同一拍** `req.destroy()`。
255
+ * 代价(同批实测里最主要的放大器):**那句现成的错误文案永远送不出去** —— 响应还没写连接就没了,
256
+ * 浏览器只看到 `ECONNRESET`,前端 `.catch` 把它显示成「批失败(网关)」(`client.js:3850-3854`),
257
+ * 把排查方向引向网络/网关/模型;而真因只是本机同一进程内的一道体量闸门。
258
+ *
259
+ * 改判版修法:超限后**不砍连接、不立刻 reject**,改为
260
+ * ① 立刻停止缓存(已缓存的那几 MiB 也丢掉,不抱着它等到 end);
261
+ * ② 继续消费 `data`、只累加计数(排空);
262
+ * ③ 等 `'end'` 到达、连接健康时再 reject 一个 `code='BODY_TOO_LARGE'` / `status=413` 的错误,
263
+ * 由下面的 `guard` 统一翻成 `sendJson(res, 413, { reason:'too-large' })` —— 文案因此真的有通路。
264
+ * 排空换来的两个新代价必须各有一道闸,就是下面这两个常量。 */
265
+ const BODY_MAX_BYTES = 4 * 1024 * 1024;
266
+ /** 排空态的**硬熔断**:真实字节数超过它 ⇒ 立刻 `destroy()` + reject。
267
+ * 取 64 MiB = 上限的 16 倍。理由:`drainMax` 不是"允许多大",而是**给一次真实超限请求留够
268
+ * 把 body 发完的余量** —— 现实里最大的一份导出 ≈4.9 MB(带原文的包实测 4.66 MB),
269
+ * 余量不够就会把"排空"本身变成新的失败:客户端拿到的仍是 reset 而不是 413,白改一场。
270
+ * 同时它把最坏代价钉死:单个请求最多让我们多读 64 MiB(**纯丢弃、不缓存**)、多占一个计数变量。
271
+ * 取更小会误伤大文件,取更大等于把"拒绝之后还能无限灌数据"当免费午餐。 */
272
+ const BODY_DRAIN_MAX_BYTES = 64 * 1024 * 1024;
273
+ /** 排空态的**静默超时**:连续这么久收不到任何 chunk ⇒ `destroy()` + reject。
274
+ * 这是**替代原 `req.destroy()` 的那条防 DoS 动机**(修法原先只说"先响应再断开",
275
+ * 没说清"对端发到一半就不发了怎么办"):只要还有数据在流,就说明对端在正常发,不算攻击;
276
+ * 一旦静默,就是僵尸/半开连接,必须收掉 —— 否则"发 4 MiB 然后挂着不动"的连接能把
277
+ * 请求、socket 和那段已丢缓存一起永久占住。
278
+ * 取 30 s:与浏览器/网关的"无进展容忍时间"同量级,且远大于一次 64 MiB 的正常传输耗时。
279
+ * 计时器**每收到一个 chunk 就重置**(滑动窗口,**不是**总时长上限)——
280
+ * 慢但持续的发送不会被误杀,只有真静默才会。 */
281
+ const BODY_DRAIN_IDLE_MS = 30000;
282
+
283
+ /** 读满一个请求体。**导出只为可测**(`tests/bodylimit.test.mjs` 直接驱动它);
284
+ * 49 处调用点一律只传 `req`,签名向后兼容:第二参默认值 = 上限常量,第三参是新增可选项。
285
+ *
286
+ * ⚠️ 不许省的三条纪律:
287
+ * · 计时器在**三条出口**都要 `clearTimeout`(`'end'` / `'error'` / 熔断),不留悬挂 timer;
288
+ * · reject **只能发生一次**(`settled` 标志)—— 熔断之后 socket 上的 `'error'`/`'end'` 还会陆续
289
+ * 到达,重复 reject 对 Promise 虽无害,但会让"谁才是真因"变得不可判;
290
+ * · **首字节之前也要有计时器**(2026-09-29 红队 E5):计时器原先只在 `data` 里武装
291
+ * ⇒「正确 cookie + `Content-Length: 4 MiB` + **实发 0 字节**」这条形状在应用层**没有任何计时器**
292
+ * (红队实测 20000 ms 零响应),兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。见下面的首字节闸。
293
+ */
294
+ export function readBody(req, maxBytes = BODY_MAX_BYTES, opts = {}) {
295
+ // 只认"正数"覆盖:0 / NaN / 负数一律回落默认值 —— 不发明"传 0 = 关掉闸门"这类隐藏开关,
296
+ // 免得将来有人以为能靠一个 0 把 DoS 口子拆掉。
297
+ const drainMax = (Number.isFinite(opts.drainMax) && opts.drainMax > 0) ? opts.drainMax : BODY_DRAIN_MAX_BYTES;
298
+ const drainIdleMs = (Number.isFinite(opts.drainIdleMs) && opts.drainIdleMs > 0) ? opts.drainIdleMs : BODY_DRAIN_IDLE_MS;
140
299
  return new Promise((resolve, reject) => {
141
300
  const chunks = [];
142
- let size = 0;
301
+ let size = 0; // 真实字节数:排空态里**继续增长**,`got` 报的就是它
302
+ let settled = false;
303
+ let draining = false; // 是否已进入排空态(超限第一拍置位,只置一次)
304
+ let firstByteSeen = false; // 首字节到没到 —— 首字节闸的解除条件(见下面 data 分支的第一行)
305
+ let timer = null;
306
+
307
+ const disarm = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
308
+ /** 唯一的 reject 出口:settled 一旦为真,后面所有 reject 都是 no-op。 */
309
+ const fail = (err) => {
310
+ if (settled) return;
311
+ settled = true;
312
+ disarm();
313
+ reject(err);
314
+ };
315
+ /** 413 错误对象:`message` **逐字沿用旧文案**(不换措辞),其余字段给 guard 用。 */
316
+ const tooLarge = () => {
317
+ const e = new Error('单次请求体过大(超过 ' + Math.round(maxBytes / 1024 / 1024) + 'MB 上限),请拆分后重试');
318
+ e.code = 'BODY_TOO_LARGE';
319
+ e.status = 413;
320
+ e.limit = maxBytes;
321
+ e.got = size;
322
+ return e;
323
+ };
324
+ /** **首字节闸**的错误对象(红队 E5,2026-09-29)。**故意不复用 `BODY_TOO_LARGE`**:
325
+ * 这一条不是"体量"问题 —— 一个字节都没发,连"多大"都无从谈起(`got` 恒为 0、**没有 `limit`**);
326
+ * 拿 413 去说它,等于对着一个空连接喊"超过 4MB 上限"。它说的是"对端发完请求头就停住了"
327
+ * (半开/僵尸连接),故另起一个 code、配 408 语义。
328
+ * 两种静默必须分得开,否则"到底谁没发数据"不可判:
329
+ * · 本函数 = **首字节之前**就静默(从没开始);
330
+ * · `tooLarge()` 那次(排空态) = **发到一半**停了(`size` 已 > `maxBytes`)。
331
+ * 文案里的毫秒数取自**同一个可注入参数**:真机 30 s,测试注入 20 ms 时也就不撒谎。 */
332
+ const noFirstByte = () => {
333
+ const e = new Error('已收到请求头,但 ' + drainIdleMs + ' ms 内没有收到任何请求体数据(对端只发了请求头、不发体),已断开连接');
334
+ e.code = 'BODY_IDLE_NO_FIRST_BYTE';
335
+ e.status = 408;
336
+ e.idleMs = drainIdleMs;
337
+ e.got = size; // 恒为 0:正因为它不是体量问题
338
+ return e;
339
+ };
340
+ /** **唯一**的计时器工厂:同一时刻只有一个计时器在跑 —— 首字节闸(只活在首字节之前)与
341
+ * 排空闸(只在超限之后)在时间上互斥,所以共用一个槽位、共用一个 `disarm()`。
342
+ * `onIdle` 决定"这次静默意味着什么"(见上面两个错误对象的区别),`req.destroy()` 是两条
343
+ * 静默路的共同动作(静默 = 僵尸/半开连接,必须收掉)。计时器**每收一个 chunk 就重置**
344
+ * (滑动窗口,**不是**总时长上限)⇒ 慢但持续的合法上传不会被误杀。 */
345
+ const arm = (onIdle) => {
346
+ disarm();
347
+ timer = setTimeout(() => { onIdle(); req.destroy(); }, drainIdleMs);
348
+ };
349
+
350
+ // ── 首字节闸:进函数就武装,且必须在任何 `req.on(` 注册**之前**(那时才有"还没开始"可言)──
351
+ // 旧形状的代价:`arm()` 只在 `data` 里武装 ⇒ "只发头、不发体"的请求让这个 Promise **永远挂着**,
352
+ // 连带请求与 socket 一起占住(请求体读取既没有超时,连接也没有)。修法就是这一句。
353
+ arm(() => fail(noFirstByte()));
354
+
143
355
  req.on('data', (c) => {
356
+ // 首字节到了 ⇒ 首字节闸的使命结束,**必须**在这里解除:留下来它会在**正常传输**里继续计时,
357
+ // 把"慢但持续的合法上传"误杀成超时(正常路径此后**不**武装任何计时器)。
358
+ if (!firstByteSeen) { firstByteSeen = true; disarm(); }
144
359
  size += c.length;
145
- if (size > maxBytes) {
146
- reject(new Error('单次请求体过大(超过 ' + Math.round(maxBytes / 1024 / 1024) + 'MB 上限),请拆分后重试'));
147
- req.destroy();
148
- return;
149
- }
150
- chunks.push(c);
360
+ // ── 正常路径(与旧实现行为逐字一致):只缓存没超限的 chunk ──────────────
361
+ if (size <= maxBytes) { chunks.push(c); return; }
362
+ // ── 超限第一拍:停缓存 **并丢弃已缓存的**(不抱着那几 MiB 等到 end)──────
363
+ if (!draining) { draining = true; chunks.length = 0; }
364
+ // ── 硬熔断:再灌下去就是真的流量攻击了 ─────────────────────────────────
365
+ // 先 fail 再 destroy:destroy() 若同步抛出 `'error'`(假 req、或将来 Node 改行为),
366
+ // 那个 error 也抢不走真因(`fail` 已被 settled 挡掉);旧代码同样是"先 reject 再 destroy"。
367
+ if (size > drainMax) { fail(tooLarge()); req.destroy(); return; }
368
+ arm(() => fail(tooLarge())); // 排空态的静默计时器(首字节闸已在上面的 data 分支解除)
151
369
  });
152
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
153
- req.on('error', reject);
370
+ req.on('end', () => {
371
+ disarm();
372
+ if (draining) { fail(tooLarge()); return; } // ← 连接健康:文案这次送得出去
373
+ resolve(Buffer.concat(chunks).toString('utf8'));
374
+ });
375
+ req.on('error', (e) => { disarm(); fail(e); });
154
376
  });
155
377
  }
156
378
 
157
379
  export function registerApi(ctx, deps, config) {
158
380
  const { gate, memory, settings } = deps;
159
381
 
382
+ // ── 手术门:票据**每启动轮换**(规格 §1.2)──────────────────────────────────────
383
+ // 这一句就是"每启动"的落点:插件随宿主启动装配一次 ⇒ 覆盖写一枚新的 32 字节随机票,
384
+ // 上一轮启动留下的票据**当场作废**(旧票仍留在文件里也没用:门比对的是本进程写下的那一枚)。
385
+ // 卸载/重装插件同样会重跑这里 ⇒ "轮换"跟着"装配"走,不需要额外生命周期钩子。
386
+ rotateSurgeryTicket();
387
+
160
388
  // ---- 获取 webServer:直取 → inject 子作用域 → 定时重试 ----
161
389
  let disposed = false;
162
390
  let disposeRoutes = null;
@@ -233,7 +461,33 @@ export function registerApi(ctx, deps, config) {
233
461
  '/deep/run-import', '/deep/run', '/deep/one',
234
462
  '/dsweb/summary/run',
235
463
  '/feedback/apply', '/feedback/dismiss', '/suggestions/apply', '/suggestions/dismiss',
464
+ // ── 1.5.3(2026-09-29 夜,用户批;依据作者的审计台账《1.5.3 端点写面 · 未进 POST_ONLY》,该台账不随包公开)──
465
+ // ⚠️ 版本号由主会话校正:本批**尚未发布**(`package.json` 仍是 `1.5.3-dev`)⇒ 归 **1.5.3**;
466
+ // 原先这里写的 `1.5.4` 是修复代理想当然的版本号,仓库里从无 1.5.4 的计划。
467
+ // 三条都是**形态上就不该由 GET 执行**的端点,补进同一处声明(仍守"一处维护、一眼看全"):
468
+ // · `/branch-log/pending`:`memory.branchLogPending()` 在"水位键缺失 + branch_log 非空"时会
469
+ // **一次性补种水位**(memory.js:1272 `kvSet('branch_log_wm.last', …)`)⇒ GET 能静默把
470
+ // "复盘守门"的欠账计数顶成 0(审计差集里**唯一一条真写**);
471
+ // · `/persona/draft`:handler 里 `await llmOnce(...)` ⇒ **GET 就能花一次全局模型额度**(不落盘);
472
+ // · `/persona/check`:handler 读 body(注释自称 POST),形态已像 POST 端点,防御性收口。
473
+ // 前端调用点**全部已核**(A:客户端本来就是 POST;C:本来就是 POST;B:三处已同步改 POST)。
474
+ '/branch-log/pending', '/persona/draft', '/persona/check',
475
+ // ── 1.5.3(2026-09-30 深夜,手术门)· 取票 = **发放凭据**的动作,形态上就不该由 GET 执行 ──
476
+ // `/surgery/ticket` 只对**本机直连**(无代理头 **且** 带 UA)发一枚 32 字节票;**不缓存**(no-store)—— 票据是每启动轮换的。
477
+ '/surgery/ticket',
236
478
  ]);
479
+ // ── 手术门下沉(2026-09-30 深夜;规格 §1.3「门下沉为一处」)──────────────────────────
480
+ // 判据本体**只在** lib/host/surgery.js 的 requireSurgery() 里;这里只是**唯一**的调用点。
481
+ // 覆盖面 = 全部 14 条能改人格/规矩/习惯的写面(逐条清单见 surgery.js 头部注释):挂在 guard(handler)
482
+ // 上就"一眼看全、零回归面",与 POST_ONLY / 413 出口同一个取舍 —— 而不是在 49 个 register 调用点各写一遍。
483
+ // 安全的位置:本调用在 POST_ONLY(405)与 guardVerdict(403)**之后** ⇒ 被挡下的请求绝不会走到 handler。
484
+ // ⚠️ 承诺句必须**惰性**取:多数写端点的载荷由 `await readBody(req)` 消费,而请求流**只能读一次** ——
485
+ // 门若在这里再读一遍,请求流就空了(那些端点会静默变成空 body)。
486
+ // ⇒ 这里**只**回一个"原文在哪"的函数;真正"从原文里抽承诺句"的规则在 surgery.js 的 rawUnlock()
487
+ // 一处(与判据同源,不在调用点各写一份正则)。
488
+ // `config.surgeryLog` = 留痕文件的**注入口**(测试用):默认落 $DSH_HOME/logs/surgery-YYYY-MM.jsonl,
489
+ // 测试可指向临时文件,免得"链是否完好"被上一段测试或真机留下的旧行干扰。
490
+ const surgeryLog = config?.surgeryLog;
237
491
  const guard = (handler) => async (req, res) => {
238
492
  // ── E4 覆盖缺口(C-02,1.5.1 红蓝对抗)──────────────────────────────────
239
493
  // 器灵的端点在宿主路由表里注册为 **exact** 路由,先于 core 的 `/api` **前缀**路由命中
@@ -271,10 +525,30 @@ export function registerApi(ctx, deps, config) {
271
525
  sendJson(res, 403, { ok: false, error: 'forbidden', reason: verdict.reason });
272
526
  return;
273
527
  }
528
+ // ── 手术门(2026-09-30 深夜)──────────────────────────────────────────────
529
+ // 判据本体只在 lib/host/surgery.js 的 requireSurgery() 一处;**各写端点不重判** ——
530
+ // 但调用点必须落在"载荷已经解析出来"的地方(见下),因为请求流只能读一次。
531
+ // 每个写端点的形状固定为:`const body = JSON.parse(…); if (!gateSurgery(req, res, sendJson, body, { settings, logFile: surgeryLog, endpoint: sub })) return;`
532
+ // · 这一句就是全部接线:判据(本机 / 每启动票据 / 承诺句)与拒绝回应都在 surgery.js;
533
+ // · 覆盖面 = 14 条写面,逐条清单与"改前是否过门"见 surgery.js 头部注释。
274
534
  try {
275
535
  await handler(req, res);
276
536
  } catch (e) {
277
- sendJson(res, 500, { ok: false, error: String(e?.message ?? e) });
537
+ // ── 413 出口:一处覆盖**全部**端点(2026-09-29)────────────────────────
538
+ // `readBody` 超限时 reject 的 `BODY_TOO_LARGE` 到这里落地。写在这里而不是 49 个端点里:
539
+ // 与 POST_ONLY 同一个取舍 —— 一处维护、一眼看全、零回归面。响应体形态
540
+ // 沿用既定约定(`reason:'too-large'`),`limit`/`got` 让人能自己算差多少。
541
+ if (e && e.code === 'BODY_TOO_LARGE' && !res.writableEnded && !res.destroyed) {
542
+ return sendJson(res, 413, { ok: false, reason: 'too-large', message: String(e.message || ''), limit: e.limit, got: e.got });
543
+ }
544
+ // ⚠️ `res.destroyed` 是**硬要求**,不是保险:熔断 / 静默超时那两条路上 socket 已经没了
545
+ // (`readBody` 里 `req.destroy()`),再 `res.end()` 就是往死 socket 写。这里放下这一句,
546
+ // 那两条路的错误就只能"静默丢弃"——故与下面的 500 分支合流,只多一句带原因的 debug。
547
+ if (!res.writableEnded && !res.destroyed) {
548
+ sendJson(res, 500, { ok: false, error: String(e?.message ?? e) });
549
+ return;
550
+ }
551
+ console.debug('[dsh-ling] response dropped (socket gone): %s', String(e?.message ?? e));
278
552
  }
279
553
  };
280
554
  // 供状态/自检使用:本机 LAN 地址(G1 之后**不再**自动受信,仅用于提示"要不要显式打开 allowLan")
@@ -286,6 +560,31 @@ export function registerApi(ctx, deps, config) {
286
560
  const register = (path, handler) =>
287
561
  server.register({ kind: 'exact', path: '/api/dsh-ling' + path, handler: guard(handler) });
288
562
 
563
+ // ── 手术门 · 每启动票据的**发放口**(规格 §1.2)────────────────────────────────────
564
+ // 「只对**本机直连**的请求下发」(§1.1 已加强为「无代理头 **且** 带 UA」,见 surgery.js 文件头):
565
+ // 带 x-forwarded-for / x-real-ip 的请求、以及**没有 UA**(远端中继重建请求头时会丢 UA)的请求
566
+ // 连票都拿不到 —— 判据用 surgery.js 的 isLocalSurgeryRequest(与手术门 §1.1 **同一个来源**,不另写一份)。
567
+ // 票据每启动轮换(见 registerApi 里的 rotateSurgeryTicket()),旧票即废;
568
+ // 故响应带 `no-store`,免得中间层/浏览器把票缓存成"过期也能用"。
569
+ // 这条路是**凭据发放**,所以进了 POST_ONLY(GET 不得执行)。
570
+ disposers.push(register('/surgery/ticket', async (req, res) => {
571
+ if (!isLocalSurgeryRequest(req.headers)) {
572
+ return sendJson(res, 403, { ok: false, reason: 'surgery-local-only', message: '本机通行票据只发给本机直连的请求。' });
573
+ }
574
+ res.setHeader('Cache-Control', 'no-store');
575
+ sendJson(res, 200, { ok: true, ticket: readSurgeryTicket(), local: true });
576
+ }));
577
+
578
+ // ── 手术门 · 只读留痕面(规格 §1.4)─────────────────────────────────────────────
579
+ // 供**主动感知层**将来消费(**本期只做读取,不做主动开口** —— 那是 Phase 2 的接口形状)。
580
+ // 过 guard(只有本机/受信 authority 能读);`limit` 默认 50、上限 500;
581
+ // 回执里带**链校验结果**(brokenAt:整链完好时为 null)⇒ 主人一眼能看出留痕有没有被动过。
582
+ disposers.push(register('/surgery/events', async (req, res) => {
583
+ const q = new URL(req.url, 'http://dsh.internal').searchParams;
584
+ const out = listSurgeryEvents(q.get('limit'), { file: surgeryLog });
585
+ sendJson(res, 200, { ok: true, ...out });
586
+ }));
587
+
289
588
  /** 批量失效所有已定稿快照(规矩/习惯写入后调用;运行中的会话只标记 stale,空闲边界再生效)。 */
290
589
  const invalidateAllSummaries = () =>
291
590
  allSnapshotSessionIds(gate).map((sid) => ({ sessionId: sid, result: invalidateSession(gate, memory, settings, sid) }));
@@ -300,7 +599,31 @@ export function registerApi(ctx, deps, config) {
300
599
  return anchor ? (base ? base + '\n\n' + anchor : anchor) : base;
301
600
  };
302
601
 
303
- const personaInfo = (s) => ({
602
+ /** §6.1(a) 读面**渠道分流**:承诺句只对本机直连回发。
603
+ * 判据 = `isLocalSurgeryRequest(headers)`(surgery.js)—— 它就是 §1.1 的**单一来源**,
604
+ * 与守卫⑤「伪造 loopback 必须被拒」那条、与手术门的 `surgery-local-only` **同源**。
605
+ * 本文件**不另写一份** IP / 代理头 / UA 判断(各写一份必然漂)。
606
+ * ⚠️ 2026-10-01 晚:判据已从「无代理头」加强为「**无代理头 且 带 UA**」——
607
+ * 远端面板的中继(remote-web-ui loopback-proxy)会重建请求头、把 UA 一并丢掉,
608
+ * 所以"没有 UA"正是那条路的指纹。本文件**一个字都不用改**:加强落在单一来源里,
609
+ * 三个读面(自动)跟着变严 —— 这正是"单一来源"的意义。
610
+ * ⚠️ 过滤只发生在**出站这一层**:已存档的历史条目、settings.json、库里的行**一个字节都不动**。 */
611
+ const readIsLocal = (req) => isLocalSurgeryRequest(req?.headers);
612
+ /** 非本机 ⇒ 从人格对象里剥掉承诺句、补 `hasSeal` 与 `phraseHidden`(**只读过滤,不改数据**)。
613
+ * ⚠️ 标记字段特意**不叫** `sealPhraseHidden`:名字里带 `sealPhrase` 子串,会让
614
+ * "远端响应体不含 `sealPhrase`"这条**子串**断言假红 —— 验收判据是"不含这个词",那就一个都不含。 */
615
+ const personaForRead = (p, local) => {
616
+ if (!p || typeof p !== 'object') return p ?? null;
617
+ if (local) return p;
618
+ const out = { ...p };
619
+ const had = !!out.sealPhrase;
620
+ delete out.sealPhrase;
621
+ out.hasSeal = had; // 剥掉内容,但"到底有没有这句"照样如实告诉远端(界面要用)
622
+ out.phraseHidden = had; // 客户端据此走"优雅退化"文案,而不是把"没这句"误当成"旧档案"
623
+ return out;
624
+ };
625
+
626
+ const personaInfo = (s, local = false) => ({
304
627
  enabled: s?.persona?.enabled !== false,
305
628
  userTitle: s?.persona?.userTitle ?? '',
306
629
  aiName: s?.persona?.aiName ?? '',
@@ -317,7 +640,11 @@ export function registerApi(ctx, deps, config) {
317
640
  bottomLines: Array.isArray(s?.persona?.bottomLines) ? s.persona.bottomLines : [],
318
641
  sealed: s?.persona?.sealed === true,
319
642
  hasSeal: !!s?.persona?.sealPhrase,
320
- sealPhrase: s?.persona?.sealPhrase ?? '', // 明文回显用(锁只造庄重,不保密)
643
+ // §6.1(a):承诺句**只对本机直连**回发(经代理的读面一个字节都不给);远端另给
644
+ // `phraseHidden` 让界面能说清"这句只在本机显示"(而不是把"没这句"当成"旧档案没有明文")。
645
+ ...(local
646
+ ? { sealPhrase: s?.persona?.sealPhrase ?? '' } // 明文回显用(锁只造庄重,不保密;本机①照旧)
647
+ : { phraseHidden: !!s?.persona?.sealPhrase }),
321
648
  extraLore: s?.persona?.extraLore ?? '',
322
649
  // 职责(1.5.2 I-5):白名单投影必须带出,否则人格中心恒空(与 habits 那次同族的"漏字段=界面静默错")
323
650
  duty: s?.persona?.duty ?? '',
@@ -399,6 +726,72 @@ export function registerApi(ctx, deps, config) {
399
726
  return tmp;
400
727
  };
401
728
 
729
+ // ---- 原文来源告警的读面(E2 可见化,2026-10-01)------------------------------------------
730
+ // 背景:E2 把「未知来源的原文」从**被误信**改成**被排除**,但那条留痕只落在 kv 键
731
+ // `dbg.raw_unknown_namespace` 里 —— **可查,但主人看不见**。本块把那句话搬到前台:
732
+ // 面板要能如实说出"有东西进来了,而我不知道它是什么"。
733
+ // ⚠️ 数据源**只有**那个 kv 键(键名 import 自 summarizer.js,见文件头;本文件不另拼一份)。
734
+ // `dsh_turns_raw` 将来会有 `source` 列 —— 本读面**不依赖它**,两条路并存也不冲突。
735
+ // ⚠️ **读面契约(前台照这个读,别改形状)**:
736
+ // · 字段名 `rawUnknownSources`,挂在**顶层**(与 `audit` 平级)。**不塞进 `audit` 块** ——
737
+ // 那块是访问日志的固定形状契约(tests/access-log.test.mjs:460「多一个少一个都算改契约」)。
738
+ // · **干净时该字段整个不出现**(调用点用条件展开)⇒ ①前台"没有告警就一个字符都不渲染",
739
+ // ②既有应答体逐字不变(负对照:V1)。绝不用 {} / null 之类空壳冒充"查过了,没事"。
740
+ // · 告警存在时形状(五个要素齐,顺序固定):
741
+ // { count, turns, names[], namespaces[{ns,turns,samples[],firstSeenAt,lastSeenAt}],
742
+ // firstSeenAt, lastSeenAt, at, text }
743
+ // ①count = 未知来源**个数**;②names/namespaces[].ns = 它们**叫什么**;
744
+ // ③namespaces[].turns = 各有多少轮原文;④firstSeenAt/lastSeenAt = 首次/最近见到;
745
+ // ⑤text = **唯一一处**拼出来的人话结论(前台原样显示,不再自己拼第二遍)。
746
+ // ⚠️ 唯一一个 helper 就是下面这个:`/health` 与 `/state` 两个读面都只经它取数。
747
+ // 将来要加第三个读面,也在这里加字段 —— **不许**在各处各写一份取数逻辑。
748
+ // ⚠️ 时间语义:kv 里的计数是**累积**的(summarizer 只加不减)⇒ 这是"**见过**"的留痕,不是
749
+ // "此刻表里还有"。所以 text 里写的是"首次/最近**见到**",不含糊成"现在有"。
750
+ const rawUnknownSourcesOf = () => {
751
+ try {
752
+ const raw = memory?.kvGet?.(RAW_UNKNOWN_NS_KEY);
753
+ if (!raw) return null; // 没写过这个键 ⇒ 干净(一个字节都没写过)
754
+ const parsed = JSON.parse(String(raw));
755
+ const src = parsed?.namespaces;
756
+ // 两种写法都吃:对象表(**当前** summarizer 的写法 —— 名字是**键**,值里没有 ns 字段;
757
+ // 第一版探针就在这里读错了:只认 r.ns ⇒ 一个来源都认不出、静默返回 null)与数组(将来若改形状,读面不跟着瞎)。
758
+ const rows = Array.isArray(src) ? src
759
+ : (src && typeof src === 'object' ? Object.entries(src).map(([ns, r]) => ({ ...(r && typeof r === 'object' ? r : {}), ns })) : []);
760
+ const byNs = new Map();
761
+ for (const r of rows) {
762
+ const ns = String((r && r.ns) || '').trim();
763
+ if (!ns) continue;
764
+ const cur = byNs.get(ns) || { ns, turns: 0, samples: [], firstSeenAt: null, lastSeenAt: null };
765
+ cur.turns += Number(r?.turns) || 0;
766
+ if (Array.isArray(r?.samples)) {
767
+ cur.samples = [...new Set([...cur.samples, ...r.samples.map(String)])].slice(0, 3);
768
+ }
769
+ const f = r?.firstSeenAt ? String(r.firstSeenAt) : null;
770
+ const l = r?.lastSeenAt ? String(r.lastSeenAt) : null;
771
+ if (f && (!cur.firstSeenAt || f < cur.firstSeenAt)) cur.firstSeenAt = f;
772
+ if (l && (!cur.lastSeenAt || l > cur.lastSeenAt)) cur.lastSeenAt = l;
773
+ byNs.set(ns, cur);
774
+ }
775
+ // 去重按命名空间(summarizer 那边已按 ns 归并,这里再收一次 —— 读面不假定写入侧一定整洁)
776
+ const namespaces = [...byNs.values()].sort((a, b) => (b.turns - a.turns) || a.ns.localeCompare(b.ns));
777
+ if (!namespaces.length) return null; // 有键没内容 = 空壳 ⇒ 按"没有告警"处理,不显示空行
778
+ const turns = namespaces.reduce((s, u) => s + u.turns, 0);
779
+ const stamps = (k) => namespaces.map((u) => u[k]).filter(Boolean).sort();
780
+ const firstSeenAt = stamps('firstSeenAt')[0] || null;
781
+ const lastSeenAt = stamps('lastSeenAt').slice(-1)[0] || null;
782
+ return {
783
+ count: namespaces.length,
784
+ turns,
785
+ names: namespaces.map((u) => u.ns),
786
+ namespaces,
787
+ firstSeenAt,
788
+ lastSeenAt,
789
+ at: parsed?.at ? String(parsed.at) : null,
790
+ text: rawUnknownSourcesText({ namespaces, turns, firstSeenAt, lastSeenAt }),
791
+ };
792
+ } catch { return null; } // 解析不出来 ⇒ 当"没有告警"(绝不拿半个读数冒充"一切正常")
793
+ };
794
+
402
795
  // ---- /state 分档(1.5.2,设计 §3) ------------------------------------------------------
403
796
  // 面板路径(client.js:890)恒带 `l0Preview=1`,轮询路径(client.js:604-608)从不带
404
797
  // ⇒ 用**已有**的这个开关当"面板档"标记:**不新增查询参数、前端零改动**。
@@ -406,11 +799,14 @@ export function registerApi(ctx, deps, config) {
406
799
  // 省掉它们不改变任何前端行为(而"按需省字段"是危险的,见设计 §4:前端每拍**无条件**覆写 store)。
407
800
  // ⚠️ `persona` 必须留在轮询档:client.js:612 每拍 `setPronounFrom(r.persona)`,省掉第三人称代词会卡住。
408
801
  // ⚠️ `audit` 必须留在轮询档:client.js:618 的 `!== undefined` 守卫 + 侧栏红点,唯一"不主动查也看得见"的日志故障位。
409
- const stateForGlobal = (q) => {
802
+ // ⚠️ `rawUnknownSources` 同样留在轮询档(2026-10-01):client.js 的 applyStateToStore 会读它
803
+ // ⇒ 面板不必重开就能看到"有未知来源进来了"。干净时**整个字段不出现** ⇒ 轮询应答逐字不变。
804
+ const stateForGlobal = (q, local = false) => {
410
805
  const base = settings.get();
411
806
  const tmp = previewOverrides(q) || base;
412
807
  const mode = base.mode?.lastMode === 'work' ? 'work' : 'life';
413
808
  const wantPreview = q && q.get('l0Preview') === '1'; // 面板档标记
809
+ const rawUnknown = rawUnknownSourcesOf(); // 干净 ⇒ null ⇒ 下面条件展开=字段不出现
414
810
  const light = {
415
811
  ok: true,
416
812
  plugin: 'dsh-ling',
@@ -420,9 +816,10 @@ export function registerApi(ctx, deps, config) {
420
816
  modeLabel: MODE_LABEL[mode],
421
817
  running: false,
422
818
  pending: 0,
423
- persona: personaInfo(base),
819
+ persona: personaInfo(base, local),
424
820
  lastMode: mode,
425
821
  audit: auditHealth(), // C-07:全局(无活动会话)视图也要看得到日志健康面
822
+ ...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}), // E2 可见化:新字段另起,老字段逐字不动
426
823
  };
427
824
  if (!wantPreview) return light; // 轮询档:到此为止(下面这些只有面板路径才付)
428
825
  return {
@@ -475,10 +872,11 @@ export function registerApi(ctx, deps, config) {
475
872
  } catch { return []; } // 映射本身出错也不许把 /state 带崩
476
873
  };
477
874
 
478
- const stateFor = (sessionId, { l0Preview = false, q } = {}) => {
875
+ const stateFor = (sessionId, { l0Preview = false, q, local = false } = {}) => {
479
876
  const base = settings.get();
480
877
  const tmp = q ? (previewOverrides(q) || base) : base;
481
878
  const mode = currentMode(memory, settings, sessionId);
879
+ const rawUnknown = rawUnknownSourcesOf(); // 同上:干净 ⇒ 字段整个不出现
482
880
  const light = {
483
881
  ok: true,
484
882
  plugin: 'dsh-ling',
@@ -487,9 +885,10 @@ export function registerApi(ctx, deps, config) {
487
885
  modeLabel: MODE_LABEL[mode] ?? mode,
488
886
  running: gate.isRunning(sessionId),
489
887
  pending: gate.pendingCount(sessionId),
490
- persona: personaInfo(base),
888
+ persona: personaInfo(base, local),
491
889
  lastMode: base.mode?.lastMode ?? 'life',
492
890
  audit: auditHealth(), // C-07:会话视图同样带上(旧客户端忽略未知字段,向后兼容)
891
+ ...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}), // E2 可见化:新字段另起,老字段逐字不动
493
892
  };
494
893
  if (!l0Preview) return light; // 轮询档(见上:分档说明)
495
894
  const snap = gate.snapshotOf(sessionId);
@@ -545,9 +944,13 @@ export function registerApi(ctx, deps, config) {
545
944
  disposers.push(register('/health', async (_req, res) => {
546
945
  // E0 只读探针:并进既有的 /health(**不开新路由**)—— 验收时不必离线解 zstd 会话日志,
547
946
  // 就能看到"追加了几次、跳过了几次、有没有重复"。`limit` 只留最近 8 个会话,免把健康面撑大。
947
+ // E2 可见化(2026-10-01):未知原文来源的留痕也挂在这里(**只在确有告警时**)——
948
+ // 既有 ok/plugin/version/audit/sessionLine 五个键的顺序与取值逐字未动,新键**排在最后**。
949
+ const rawUnknown = rawUnknownSourcesOf();
548
950
  sendJson(res, 200, {
549
951
  ok: true, plugin: 'dsh-ling', version: config.version, audit: auditHealth(),
550
952
  sessionLine: sessionLineProbe(memory, { limit: 8 }),
953
+ ...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}),
551
954
  });
552
955
  }));
553
956
 
@@ -727,9 +1130,12 @@ export function registerApi(ctx, deps, config) {
727
1130
  sendJson(res, 200, { ok: true, total: list.length, log: list });
728
1131
  }));
729
1132
 
730
- // GET /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
1133
+ // POST /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
731
1134
  // 返回"自器灵上次开口以来累积了多少笔结构改动还没交代";侧栏据此提示,
732
1135
  // 并在未同步时**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
1136
+ // ⚠️ 进 `POST_ONLY` 的原因:它不是纯读 —— `memory.branchLogPending()` 在"水位键缺失 +
1137
+ // branch_log 非空"时会**一次性把水位补种到当前最大 id**(memory.js:1272)⇒ 一个 GET
1138
+ // 就能静默把欠账计数顶成 0(审计差集 26 条里**唯一一条真写**)。前端三处调用点已同步改 POST。
733
1139
  disposers.push(register('/branch-log/pending', async (req, res) => {
734
1140
  let r = { watermark: 0, maxId: 0, pending: 0 };
735
1141
  try {
@@ -1659,12 +2065,15 @@ export function registerApi(ctx, deps, config) {
1659
2065
  }));
1660
2066
 
1661
2067
  // GET /state?sessionId=&scope=global&l0Preview=1&previewPersona=&previewStyles=
2068
+ // §6.1(a):`/state` 的 `personaInfo` **按渠道分流** —— 带代理头(经反代/远端)的请求不回承诺句,只回 hasSeal。
2069
+ // 判据在 `readIsLocal` 一处;两个调用点(stateForGlobal / stateFor)同一条路,不会有一条漏掉。
1662
2070
  disposers.push(register('/state', async (req, res) => {
1663
2071
  const q = new URL(req.url, 'http://dsh.internal').searchParams;
1664
- if (q.get('scope') === 'global') return sendJson(res, 200, stateForGlobal(q));
2072
+ const local = readIsLocal(req);
2073
+ if (q.get('scope') === 'global') return sendJson(res, 200, stateForGlobal(q, local));
1665
2074
  const sessionId = reqSessionId(req.url) || gate.recentSessionId();
1666
- if (!sessionId) return sendJson(res, 200, stateForGlobal(q));
1667
- sendJson(res, 200, stateFor(sessionId, { l0Preview: q.get('l0Preview') === '1', q }));
2075
+ if (!sessionId) return sendJson(res, 200, stateForGlobal(q, local));
2076
+ sendJson(res, 200, stateFor(sessionId, { l0Preview: q.get('l0Preview') === '1', q, local }));
1668
2077
  }));
1669
2078
 
1670
2079
  // POST /persona 字段补丁 → settings + 全局失效(按冻结门)
@@ -1672,6 +2081,11 @@ export function registerApi(ctx, deps, config) {
1672
2081
  // 上锁(sealed:true)需带新承诺句 → 覆盖哈希;撤锁清哈希;每次成功变更后存「结果档案」。
1673
2082
  disposers.push(register('/persona', async (req, res) => {
1674
2083
  const body = JSON.parse((await readBody(req)) || '{}');
2084
+ // ── 手术门(第 1/14 条写面)────────────────────────────────────────────────
2085
+ // 载荷必须**在这里**交上去:请求流只能读一次(见 guard 里的接线说明)。
2086
+ // 只交 `unlock` 一项:门只读它(§1.3 判据 3)。把整份 patch 交上去 = 让留痕的"字段指纹"
2087
+ // 记下一串与判据无关的内容(越少记越好;要留痕的是"这次手术",不是"他改成了什么")。
2088
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona' })) return;
1675
2089
  const raw = clampBottomLines(body.patch && typeof body.patch === 'object' ? body.patch : body);
1676
2090
  // 2026-09-17 加固④(G10):白名单收窄 —— 补丁只能落在 persona / styles 两棵子树内。
1677
2091
  // 此前 body 被原样深合并进 settings.json,`{"guard":{"enforce":false}}` 可持久化关停本守卫。
@@ -1679,19 +2093,19 @@ export function registerApi(ctx, deps, config) {
1679
2093
  if (dropped.length) console.warn('[dsh-ling] /persona 丢弃白名单外的字段: %s', dropped.join(', '));
1680
2094
  const cur = settings.get();
1681
2095
  const wantLock = patch.persona?.sealed === true;
1682
- const wasSealed = cur.persona?.sealed === true;
1683
2096
  const doUnseal = patch.persona?.sealed === false;
1684
2097
  if (touchesPersona(patch)) {
2098
+ // ── 2026-09-30 深夜(手术门):**删掉 `wasSealed` 绕过分支**(侦察 F9)──────────
2099
+ // 旧代码在这里是二选一:`wantLock` 直接采纳"新承诺句"、`wasSealed` 才验原句 ——
2100
+ // 而 `wantLock` 分支排在 `wasSealed` **之前**,于是"带着 sealed:true 的补丁"能跳过验句。
2101
+ // 现在:已定型时**所有**写入都由手术门(本机 + 每启动票据 + 原句)统一判过(见上面 gateSurgery),
2102
+ // 走到这一行只可能是"未定型"⇒ 这里只剩"首次上锁"这一种正当语义。
1685
2103
  if (wantLock) {
1686
2104
  const k = String(body.unlock || '').trim();
1687
2105
  if (k.length < KEY_MIN) return sendJson(res, 200, { ok: false, reason: 'sealed', message: '上锁需先亲手写下一句 ≥' + KEY_MIN + ' 字的承诺句(明文保存,仅供解锁界面回显提醒)。' });
1688
- patch.persona.sealPhrase = k; // 覆盖旧承诺
1689
- } else if (wasSealed) {
1690
- const g = sealOk(cur.persona, body.unlock);
1691
- if (!g.pass) return sendJson(res, 200, { ok: false, reason: 'sealed', message: MSG_KEY });
1692
- if (g.adoptKey) patch.persona.sealPhrase = g.adoptKey; // 旧档案无明文:采纳为承诺句
1693
- if (doUnseal) patch.persona.sealPhrase = ''; // 永久撤锁 → 清句
2106
+ patch.persona.sealPhrase = k; // 首次上锁:记下这句承诺
1694
2107
  }
2108
+ if (doUnseal) patch.persona.sealPhrase = ''; // 永久撤锁 → 清句
1695
2109
  }
1696
2110
  const s2 = await settings.update(patch);
1697
2111
  const histKey = touchesPersona(patch) ? await archivePersona(s2, 'result') : null;
@@ -1714,9 +2128,15 @@ export function registerApi(ctx, deps, config) {
1714
2128
  }));
1715
2129
 
1716
2130
  // POST /persona/check { unlock } 客户端「解锁修改」先验钥(只读,不落盘)
2131
+ // 进 `POST_ONLY` 是**防御性**:今天 GET 下 body 恒为 `{}` ⇒ 只回"承诺句不一致"(无写),
2132
+ // 但 handler 读 body、注释也自称 POST ⇒ 形态已经是 POST 端点,补进去零成本。
1717
2133
  disposers.push(register('/persona/check', async (req, res) => {
1718
2134
  const body = JSON.parse((await readBody(req)) || '{}');
1719
2135
  const cur = settings.get();
2136
+ // 手术门(第 2/14 条写面):**这一条本身不写** —— 它是"验句预言机"(在线猜句的入口)。
2137
+ // 门口径与别处**完全一致**:已定型 ⇒ 必须本机 + 带票 + 原句,否则连"这句对不对"都不告诉它
2138
+ // (改前它对任意调用者有求必应 ⇒ 猜句成功率只受网络延迟限制)。
2139
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/check' })) return;
1720
2140
  if (cur.persona?.sealed !== true) {
1721
2141
  return sendJson(res, 200, { ok: true, valid: true, sealed: false, message: '档案未定型' });
1722
2142
  }
@@ -1728,12 +2148,15 @@ export function registerApi(ctx, deps, config) {
1728
2148
  }));
1729
2149
 
1730
2150
  // GET /persona/history 人格档案存档(每次保存后的结果;回滚前另有 pre-rollback 档)
1731
- disposers.push(register('/persona/history', async (_req, res) => {
2151
+ // §6.1(a):**读时过滤** —— 存档内容一个字都不改写(那是人读的档、也是回滚的档),
2152
+ // 只在**出站这一层**按渠道剔除承诺句(经代理 ⇒ 剥掉并补 hasSeal / sealPhraseHidden)。
2153
+ disposers.push(register('/persona/history', async (req, res) => {
2154
+ const local = readIsLocal(req);
1732
2155
  const items = memory.kvList('persona.hist.').map((r) => {
1733
2156
  const k = String(r.key);
1734
2157
  let v = null;
1735
2158
  try { v = JSON.parse(String(r.value)); } catch {}
1736
- return { ts: Number(k.slice('persona.hist.'.length)) || null, persona: v?.persona ?? null, styles: v?.styles ?? null, kind: v?.kind || 'result' };
2159
+ return { ts: Number(k.slice('persona.hist.'.length)) || null, persona: personaForRead(v?.persona ?? null, local), styles: v?.styles ?? null, kind: v?.kind || 'result' };
1737
2160
  }).sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, 30);
1738
2161
  sendJson(res, 200, { ok: true, items });
1739
2162
  }));
@@ -1742,6 +2165,8 @@ export function registerApi(ctx, deps, config) {
1742
2165
  // 无论是否定型,只允许更新 persona.aiTitle(定位自述)单字段;其余字段仍需手术/承诺句。
1743
2166
  disposers.push(register('/persona/self-summary', async (req, res) => {
1744
2167
  const body = JSON.parse((await readBody(req)) || '{}');
2168
+ // 手术门(第 3/14 条写面):身份自述 aiTitle 属身份保护区。只交 unlock(见 /persona 处说明)。
2169
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/self-summary' })) return;
1745
2170
  const text = String(body.text ?? '').trim();
1746
2171
  if (!text) return sendJson(res, 200, { ok: false, reason: 'empty', message: '内容为空' });
1747
2172
  if (text.length > 500) return sendJson(res, 200, { ok: false, reason: 'long', message: '自述过长(≤500 字)' });
@@ -1764,6 +2189,8 @@ export function registerApi(ctx, deps, config) {
1764
2189
  // (习惯不能直达;这里的用户点击就是确认动作),重量前先归档。
1765
2190
  disposers.push(register('/persona/hint-adopt', async (req, res) => {
1766
2191
  const body = JSON.parse((await readBody(req)) || '{}');
2192
+ // 手术门(第 4/14 条写面):一次点击即 propose+confirm 落一条习惯。只交 unlock。
2193
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/hint-adopt' })) return;
1767
2194
  const text = String(body.text ?? '').trim();
1768
2195
  if (text.length < 4 || text.length > 400) {
1769
2196
  return sendJson(res, 200, { ok: false, reason: 'bad', message: '内容需在 4~400 字之间' });
@@ -1797,8 +2224,14 @@ export function registerApi(ctx, deps, config) {
1797
2224
 
1798
2225
  // POST /persona/rule { action:'add'|'remove', rule, quote? }
1799
2226
  // 规矩直达:add 必须带原话(quote);remove 只需正文。
2227
+ // ⚠️ E3(审计 H-4):"add 必须带原话"**只对会话内调用成立** —— 主人在**面板**里亲手敲的
2228
+ // (请求形状:无 sessionId、无 quote)本来就不需要另附原话(他自己敲的就是同意)。
2229
+ // "去掉 quote"**不再等于**"来自面板":归因只由 ruleWriteAttribution() 一处判(见文件头)。
1800
2230
  disposers.push(register('/persona/rule', async (req, res) => {
1801
2231
  const body = JSON.parse((await readBody(req)) || '{}');
2232
+ // 手术门(第 5+6/14 条写面:add 与 remove 同一端点,一次判过):
2233
+ // 规矩是他的指令(quote 是"经同意"的凭据),但**删规矩能整批删光** ⇒ 一律过门。
2234
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rule' })) return;
1802
2235
  const action = String(body.action || 'add');
1803
2236
  if (action === 'remove') {
1804
2237
  const r = await removeRule({ settings, rule: body.rule });
@@ -1807,9 +2240,13 @@ export function registerApi(ctx, deps, config) {
1807
2240
  : { ok: false, reason: r.reason, message: '没有这条规矩' });
1808
2241
  }
1809
2242
  if (action !== 'add') return sendJson(res, 200, { ok: false, reason: 'bad-action' });
2243
+ // ── E3(审计 H-4):归因判据只在 ruleWriteAttribution() 一处 ──────────────────────
2244
+ // 被删掉的旧写法:`source: body.quote ? 'session' : 'panel'` —— 用"有没有 quote"
2245
+ // 反推"谁写的",于是**会话内、不带 quote 的 HTTP 调用**被贴成 'panel'
2246
+ // (= 冒充主人亲手写的那一条:rules.js 据此免掉原话要求,原话位填成「(面板直接写入)」)。
2247
+ const attr = ruleWriteAttribution(body);
1810
2248
  const r = await addRule({
1811
- settings, rule: body.rule, quote: body.quote, sessionId: String(body.sessionId || 'panel'),
1812
- source: body.quote ? 'session' : 'panel', // 面板直接写入:用户自己敲的就是同意
2249
+ settings, rule: body.rule, quote: attr.quote, sessionId: attr.sessionId, source: attr.source,
1813
2250
  });
1814
2251
  const message = !r.ok
1815
2252
  ? {
@@ -1819,13 +2256,24 @@ export function registerApi(ctx, deps, config) {
1819
2256
  duplicate: '已有同义规矩,未重复写入。',
1820
2257
  }[r.reason] || ('未写入:' + r.reason)
1821
2258
  : undefined;
1822
- sendJson(res, 200, { ok: r.ok, ...r, message, affected: r.ok ? invalidateAllSummaries() : [] });
2259
+ sendJson(res, 200, {
2260
+ ok: r.ok, ...r, message, affected: r.ok ? invalidateAllSummaries() : [],
2261
+ // 归因如实回传:面板 = 主人亲手写 / 会话内带原话 / 会话内无原话 —— 调用方不必再猜
2262
+ // rules.js 的 'panel' 是什么意思。`unquoted` 只在"会话内且没有原话"时为真。
2263
+ source: attr.source,
2264
+ ...(attr.source === 'session-no-quote' && r.ok
2265
+ ? { unquoted: true, note: '这次写入来自会话内调用、且没有原话 —— 留痕按「会话内调用」记,不是面板写入,也不是"经同意"。' }
2266
+ : {}),
2267
+ });
1823
2268
  }));
1824
2269
 
1825
2270
  // POST /persona/habit { action:'propose'|'confirm'|'reject'|'remove', habit?, evidence?, id? }
1826
2271
  // 习惯不直达:propose(任一方可提)/ confirm(对方点头)/ reject / remove。
1827
2272
  disposers.push(register('/persona/habit', async (req, res) => {
1828
2273
  const body = JSON.parse((await readBody(req)) || '{}');
2274
+ // 手术门(第 7+8/14 条写面:propose/confirm/reject 与 remove 同一端点,一次判过)。
2275
+ // 改前只有 remove 过门 —— "加比删容易"正是侦察里指出的不对称,现在两条都过门。
2276
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habit' })) return;
1829
2277
  const action = String(body.action || 'propose');
1830
2278
  if (action === 'propose') {
1831
2279
  const r = await proposeHabit({
@@ -1845,15 +2293,8 @@ export function registerApi(ctx, deps, config) {
1845
2293
  return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
1846
2294
  }
1847
2295
  if (action === 'remove') {
1848
- // 习惯属于她:删改属"对她做手术",定型状态下需先解锁(承诺句);提议/认可/不认可仍免手术。
1849
- const cur = settings.get();
1850
- const gate = cur.persona?.sealed === true ? sealOk(cur.persona, body.unlock) : { pass: true, adoptKey: null };
1851
- if (!gate.pass) {
1852
- return sendJson(res, 200, {
1853
- ok: false, reason: 'sealed',
1854
- message: pronounize('习惯是她长出来的 —— 删改需走手术门:请在人格中心「🔓 对人格做手术…」解锁后再试。', pronounOf(settings.get())) + MSG_KEY,
1855
- });
1856
- }
2296
+ // 习惯属于她:删改属"对她做手术" —— 已定型时由上面的手术门统一判过(本机 + 每启动票据 + 原句),
2297
+ // 这里不再自己重判一遍(旧代码在此处另有一份 sealOk 判据:同一判据两份实现 = 迟早漂移)。
1857
2298
  const r = await removeHabit({ settings, habit: body.habit });
1858
2299
  return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
1859
2300
  }
@@ -2066,6 +2507,8 @@ export function registerApi(ctx, deps, config) {
2066
2507
  // 自性类成长通道:白名单字段(语气枚举 + 风格追加),锁定下亦可采纳;不可逆操作一律不做。
2067
2508
  disposers.push(register('/persona/grow', async (req, res) => {
2068
2509
  const body = JSON.parse((await readBody(req)) || '{}');
2510
+ // 手术门(第 9/14 条写面):改语气 / 追加风格注 —— 旧注释自陈"锁定下亦可采纳",那正是缺口。
2511
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/grow' })) return;
2069
2512
  const patch = {};
2070
2513
  const toneIn = body.tone && typeof body.tone === 'object' ? body.tone : {};
2071
2514
  if (toneIn.default && TONE_SET.includes(String(toneIn.default))) patch.tone = String(toneIn.default);
@@ -2100,6 +2543,9 @@ export function registerApi(ctx, deps, config) {
2100
2543
  // POST /persona/rollback { ts, unlock? } 回滚到某历史存档(同样受定型门约束)
2101
2544
  disposers.push(register('/persona/rollback', async (req, res) => {
2102
2545
  const body = JSON.parse((await readBody(req)) || '{}');
2546
+ // 手术门(第 10/14 条写面):整体回写 {persona,styles}(含 sealed/sealPhrase)——
2547
+ // 回滚到定型前快照 = **撤锁**,故判据必须在"看存档是否存在"之前:格式不对的请求也不该探到存档。
2548
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rollback' })) return;
2103
2549
  const ts = Number(body.ts);
2104
2550
  const raw = ts ? memory.kvGet('persona.hist.' + ts) : null;
2105
2551
  if (!raw) return sendJson(res, 200, { ok: false, reason: 'notfound', message: '存档不存在' });
@@ -2141,8 +2587,11 @@ export function registerApi(ctx, deps, config) {
2141
2587
  sendJson(res, 200, { ok: true, items, keep: 50 });
2142
2588
  }));
2143
2589
 
2144
- // GET /persona/draft 基于当前档案(惯例/底线/设定/风格)起草「定位自述」候选。
2145
- // 只读操作,不受定型门约束(不落盘;采用仍需常规保存流程)。
2590
+ // POST /persona/draft 基于当前档案(惯例/底线/设定/风格)起草「定位自述」候选。
2591
+ // 对**库**而言是只读:不写任何键、不改 settings,不受定型门约束(采用仍需常规保存流程);
2592
+ // 但对**模型额度**不是 —— 下面 `await llmOnce(...)` 每次请求都真发一次全局模型调用。
2593
+ // ⇒ 因此它在 `POST_ONLY` 里:GET 就能被诱导触发付费调用,是"成本型"而非"写型"的攻击面
2594
+ // (旧注释写"只读操作…不落盘",只说了库那一半,漏掉额度这一半;2026-09-29 夜改准)。
2146
2595
  disposers.push(register('/persona/draft', async (_req, res) => {
2147
2596
  const s = settings.get();
2148
2597
  const p = s.persona || {};
@@ -2217,7 +2666,15 @@ export function registerApi(ctx, deps, config) {
2217
2666
  }
2218
2667
  }));
2219
2668
 
2220
- // POST /import/file/batch { items, file?, runId?, at? } 通道 B:会话契约文件导入(每批 ≤500;客户端分页)
2669
+ // POST /import/file/batch { items, file?, runId?, at? } 通道 B:会话契约文件导入(上限口径见下;客户端分页)
2670
+ // ⚠️ **上限口径(2026-09-29 修正;原文写的「每批 ≤500」与实现自相矛盾,已作废)**:
2671
+ // 真正的闸门是**字节** —— 本文件 `readBody` 的 `BODY_MAX_BYTES = 4 MiB`(超限走下面的 413 出口);
2672
+ // 而 `items.slice(0, 500)` 只是**防呆的条数上限**(500 条要装进 4 MiB 需每条 ≤8 KB,而带正文的
2673
+ // 会话实测 ≈26 KB/条)。⇒ 客户端(`lib/client.js` 的 `takeChunk`)按**累计字节**切批:≤200 条 且 ≤3 MiB/批。
2674
+ // ⚠️ **两半是耦合的**:3 MiB(加数组封套与转义)必须留在 4 MiB 之下 —— 任何一方单独改上限,都会让
2675
+ // 两端口径重新错位,而**这正是本缺陷的成因**(186 条 4.89 MB 被按条数打成整批发 ⇒ 超限 ⇒ 连接被 reset)。
2676
+ // 超限时 `readBody` 会**排空请求体后回 `413 {ok:false, reason:'too-large', limit, got}`**(不再砍连接),
2677
+ // 客户端据此给人话。
2221
2678
  // file/runId/at 仅用于记账:同一次导入(同 runId)的多批累加进**一条**账,at = 导入开始时刻
2222
2679
  disposers.push(register('/import/file/batch', async (req, res) => {
2223
2680
  const body = JSON.parse((await readBody(req)) || '{}');
@@ -2229,23 +2686,46 @@ export function registerApi(ctx, deps, config) {
2229
2686
  kind: 'file', name: body.file, runId: body.runId, at: body.at,
2230
2687
  accepted: r.accepted, newRows: r.newRows, refreshed: r.refreshed,
2231
2688
  upgraded: r.upgraded, folded: r.folded, removedImport: r.removedImport,
2689
+ // A(2026-09-30):折叠时"因为目标已有原文而**没有替换**的轮数"也要进账(与 tooBig 同款,
2690
+ // 只有 >0 才真的落进账本;`= 0` 时账本逐字不变 —— `logImport` 里那道负对照管着)。
2691
+ foldSkippedRaw: cleanFoldSkipped(r.foldSkippedRaw),
2232
2692
  degraded: r.degraded, rejected: (r.rejected || []).length,
2233
2693
  });
2694
+ // 回执 = `{ ok, ...r }` **整份透传** ⇒ `foldSkippedRaw`(以及 folded/upgraded/…)天然在体里,
2695
+ // 不需要在这里另抄一行(抄了反而会与 applyImportItems 的返回漂移)。
2234
2696
  sendJson(res, 200, { ok: true, ...r });
2235
2697
  } catch (e) {
2236
2698
  sendJson(res, 200, { ok: false, reason: 'import', message: '导入失败:' + String(e?.message ?? e).slice(0, 200) });
2237
2699
  }
2238
2700
  }));
2239
2701
 
2240
- // POST /import/log { runId, at?, file?, errors? } 记账补充:客户端上报**失败批**(成功批由上面自己记账)
2702
+ // POST /import/log { runId, at?, file?, errors?, tooBig?, tooBigNames? }
2703
+ // 记账补充:客户端上报**失败批**(成功批由上面自己记账)。
2704
+ // #10(2026-09-29)新增第二条路:上报**被跳过的"单条过大"**。它与失败批是两码事 ——
2705
+ // 跳过是**已知且可解释**的(那一条自己就超过了单次上限,拆文件也拆不开它)。
2706
+ // ⚠️ **命门**:`errors` 的既有语义是 `Math.max(1, ...)`(**强制 ≥1**),所以"只报跳过"这条路
2707
+ // **绝不能**顺手带上 `errors` —— 否则账本会把"跳过"记成"失败批",正是要避免的混账;
2708
+ // 也正因为这条通路以前不存在,客户端当时只能**故意不报**,账本上看不到任何东西被跳过。
2709
+ // 输入护栏(账本要长期留着,不许原样吞任意长度用户输入):`tooBig` 夹 0..10000;
2710
+ // `tooBigNames` 只取前 5 条、每条截到 40 字、非字符串丢弃。两处口径的唯一实现在
2711
+ // `import-log.js` 的 `cleanTooBig` / `cleanTooBigNames`(这里显式过一遍,再交给 logImport
2712
+ // 自己防一道 —— 端点不是唯一入口,「三条入口共用」的那一份才是底线)。
2241
2713
  disposers.push(register('/import/log', async (req, res) => {
2242
2714
  const body = JSON.parse((await readBody(req)) || '{}');
2243
2715
  const runId = String(body.runId || '').slice(0, 64);
2244
2716
  if (!runId) return sendJson(res, 200, { ok: false, reason: 'runId', message: '缺少 runId' });
2245
- const key = logImport(memory, {
2246
- kind: 'file', name: body.file, runId, at: body.at,
2247
- errors: Math.max(1, Number(body.errors) || 1),
2248
- });
2717
+ const tooBig = cleanTooBig(body.tooBig);
2718
+ const patch = { kind: 'file', name: body.file, runId, at: body.at };
2719
+ // 既有通路(**逐字不变**):`errors` 一律 `max(1, ...)` —— 没传也记 1 批失败(旧行为)。
2720
+ // 只有"报跳过"这一路(tooBig > 0)才允许不带 errors;要同时报失败批,显式传 errors 即可。
2721
+ if (body.errors !== undefined || tooBig === 0) {
2722
+ patch.errors = Math.max(1, Number(body.errors) || 1);
2723
+ }
2724
+ if (tooBig > 0) {
2725
+ patch.tooBig = tooBig;
2726
+ patch.tooBigNames = cleanTooBigNames(body.tooBigNames);
2727
+ }
2728
+ const key = logImport(memory, patch);
2249
2729
  sendJson(res, 200, { ok: true, key });
2250
2730
  }));
2251
2731
 
@@ -2297,7 +2777,22 @@ export function registerApi(ctx, deps, config) {
2297
2777
  // GET /dsweb/summary/preview?db=&minTurns=&all=1&onlyHit=1&limit=
2298
2778
  disposers.push(register('/dsweb/summary/preview', async (req, res) => {
2299
2779
  const q = dswebQuery(req);
2300
- const db = dswebDbOf(q.get('db'));
2780
+ // ── B(审计 §四.4,2026-10-01 修):`db` **只认**默认库 / settings 白名单 ──────────────
2781
+ // 改前这里是 `dswebDbOf(q.get('db'))` —— 收任意路径再 `openSource()` 只读打开,
2782
+ // 等于一个"任意本机路径存在吗"的预言机;审计注明它靠 `readOnly:true` 才不是写原语。
2783
+ // 判据(全仓唯一一处)在模块级的 previewDbPolicy();这里只接线 + 如实回 reason。
2784
+ // ⚠️ **只收紧这一条**:同文件的 `/dsweb/scan` 与 `/dsweb/summary/run` 仍走 `dswebDbOf`
2785
+ // (它们是 POST_ONLY 的显式动作,不在本条审计范围内 —— 顺手混改会改掉另两个端点的语义)。
2786
+ const pol = previewDbPolicy(q.get('db'), settings);
2787
+ if (!pol.ok) {
2788
+ return sendJson(res, 200, {
2789
+ ok: false, reason: pol.reason,
2790
+ message: '这个库路径不在白名单里,没有打开它:' + pol.asked
2791
+ + '。要用它,先把它设成默认库或写进白名单 —— 环境变量 DSH_LING_DSWEB_DB,'
2792
+ + '或 settings 的 dsweb.dbPath / dsweb.dbs[]。',
2793
+ });
2794
+ }
2795
+ const db = pol.db;
2301
2796
  if (!db) {
2302
2797
  return sendJson(res, 200, {
2303
2798
  ok: false, reason: 'no-db',
@@ -2472,6 +2967,8 @@ export function registerApi(ctx, deps, config) {
2472
2967
  // POST /feedback/apply { id }
2473
2968
  disposers.push(register('/feedback/apply', async (req, res) => {
2474
2969
  const body = JSON.parse((await readBody(req)) || '{}');
2970
+ // 手术门(第 11/14 条写面):采纳建议 → 落习惯 / 改自称。改前只有身份两 kind 有门。
2971
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/feedback/apply' })) return;
2475
2972
  const r = await applySuggestionAsRule(memory, settings, body.id);
2476
2973
  if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
2477
2974
  await archivePersona(settings.get(), 'result'); // 惯例成长也留档
@@ -2512,6 +3009,10 @@ export function registerApi(ctx, deps, config) {
2512
3009
  // POST /suggestions/apply { id }
2513
3010
  disposers.push(register('/suggestions/apply', async (req, res) => {
2514
3011
  const body = JSON.parse((await readBody(req)) || '{}');
3012
+ // 手术门(第 13/14 条写面):采纳建议。改前**只有** kind∈{aiName,userTitle} 才验句,
3013
+ // 惯例类(hardrule 等)可自由追加 ⇒ 现在整条端点统一过门(下面那段身份分支因此成了一道冗余的深防线,
3014
+ // 保留不动:判据更严的一侧永远不会把请求放过去)。
3015
+ if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/suggestions/apply' })) return;
2515
3016
  const row = memory.getPersonaSuggestion(Number(body.id));
2516
3017
  const kind = row?.kind ?? '';
2517
3018
  // 定型门:自称/称呼属身份保护区,采纳需钥匙;惯例类建议(成长回路)可自由追加
@@ -2584,13 +3085,16 @@ export function registerApi(ctx, deps, config) {
2584
3085
  }));
2585
3086
 
2586
3087
  // GET /export
3088
+ // §6.1(a):导出包里的承诺句同样按渠道分流 —— **本机导出照旧带**(备份完整性:本机导出的包
3089
+ // 本来就是给本机恢复用的),经代理的导出剥掉(远端拿不到这句)。
2587
3090
  disposers.push(register('/export', async (req, res) => {
2588
3091
  const q = new URL(req.url, 'http://dsh.internal').searchParams;
3092
+ const local = readIsLocal(req);
2589
3093
  const bundle = memory.exportBundle({ includeRaw: q.get('includeRaw') === '1' });
2590
3094
  const s = settings.get();
2591
3095
  // 人格/风格/最后模式随包附载(默认不在合并时覆盖目标)
2592
3096
  bundle.persona = {
2593
- persona: s.persona || {},
3097
+ persona: personaForRead(s.persona || {}, local),
2594
3098
  styles: s.styles || {},
2595
3099
  lastMode: s.mode?.lastMode || 'life',
2596
3100
  };
@@ -2606,6 +3110,13 @@ export function registerApi(ctx, deps, config) {
2606
3110
  const q = new URL(req.url, 'http://dsh.internal').searchParams;
2607
3111
  const text = await readBody(req);
2608
3112
  const parsed = JSON.parse(text || '{}');
3113
+ // 手术门(第 14/14 条写面)⚠️ **前移到 importBundle 之前** —— 这是本批唯一一处"改前门在后面"的修正:
3114
+ // 改前:`memory.importBundle()`(下一行)先跑,门在它**之后** ⇒ 一个带着恶意 bundle 的请求
3115
+ // 即使人格回写被拒,**记忆也已经整体被换掉了**(DESIGN §3 该行备注"门拦不住记忆被换")。
3116
+ // 现在:已定型时,未过门(非本机 / 无票 / 原句不符)的请求**一个字节都不落库**。
3117
+ // 载荷交的是**原文**:bundle 可达 4 MiB,门用 rawUnlock() 只抽 `"unlock"` 那一段,
3118
+ // 不整包 parse、不进留痕字段(见 surgery.js)。未定型时 gated:false ⇒ 行为与现状逐字一致。
3119
+ if (!gateSurgery(req, res, sendJson, text, { settings, logFile: surgeryLog, endpoint: '/import' })) return;
2609
3120
  const bundle = parsed.bundle || parsed;
2610
3121
  const report = memory.importBundle(bundle, { overwrite: q.get('overwrite') === '1' });
2611
3122
  // 人格默认不覆盖;仅当显式 persona:true(如备份恢复)才合并(外来条目不污染本地人格)
@@ -2680,3 +3191,27 @@ function allSnapshotSessionIds(gate) {
2680
3191
  function rawTurnSessionCount(memory) {
2681
3192
  return memory.rawSessionCount();
2682
3193
  }
3194
+
3195
+ /** 人话结论(唯一的拼法;前台**原样**显示它,不再自己拼第二遍)。
3196
+ * 要素:①几个未知来源 ②它们叫什么 + ③各多少轮 ④首次/最近见到 ⑤一句结论(已经怎么处理了)。
3197
+ * ⚠️ 措辞纪律:只说**查得到的事**。
3198
+ * · "已按外部内容处理"的依据 = summarizer.js 的 dshSessionSql() 把它们排除在概述之外、
3199
+ * habit-gen.js 复用同一判据把它们排除在习惯扫描之外(2026-10-01 逐点核对过调用点);
3200
+ * · **不说**"没混进你的会话统计" —— `memory.rawSessionCount()` 数的是全表 DISTINCT session_id,
3201
+ * **含**外来命名空间 ⇒ 面板上那个"DSH 原始轮次会话 N 个"目前确实把它们算在内。
3202
+ * 那句话听着漂亮但是假的;这里如实说"原文表里的会话数仍含它们"(见报告「与描述不一致的事实」)。
3203
+ * · kv 里的计数是累积的 ⇒ 用"见到"而不是"现在有"。 */
3204
+ function rawUnknownSourcesText({ namespaces, turns, firstSeenAt, lastSeenAt }) {
3205
+ const list = namespaces.map((u) => u.ns + '(' + u.turns + ' 轮)').join(' · ');
3206
+ const when = (v) => {
3207
+ const d = new Date(String(v));
3208
+ if (isNaN(d.getTime())) return String(v); // 脏值原样印出,不做自作聪明的截取
3209
+ const p = (n) => (n < 10 ? '0' : '') + n;
3210
+ return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
3211
+ };
3212
+ const seen = (firstSeenAt && lastSeenAt)
3213
+ ? '首次见到 ' + when(firstSeenAt) + ',最近见到 ' + when(lastSeenAt) + '。'
3214
+ : (firstSeenAt ? '首次见到 ' + when(firstSeenAt) + '(未记最近时刻)。' : '时刻未记录。');
3215
+ return '有 ' + namespaces.length + ' 个我没见过的原文来源:' + list + ',共 ' + turns + ' 轮原文 —— '
3216
+ + '已按外部内容处理:没被概述、也没进你的习惯统计(原文表里那个"会话数"仍含它们)。' + seen;
3217
+ }