dsh-qq-digest 0.1.1 → 0.2.0

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/README.md CHANGED
@@ -49,6 +49,9 @@
49
49
 
50
50
  - `llm`(默认):OpenAI 兼容接口,结构化 JSON。密钥取 `QQ_DIGEST_LLM_API_KEY` 或 `~/.dsh/.credentials.yaml` 的 `DEEPSEEK_API_KEY`。
51
51
  失败**不静默降级**,直接抛 `LLM_*` 错误码。
52
+ ⚠️ **知情提示**:选 `llm` 意味着被摘要的群/私聊**消息正文、昵称,以及被 @ 的 QQ 号**会发送到
53
+ 模型服务商(默认 `api.deepseek.com`)。不能接受这一点就选 `heuristic`(纯本地规则,不出网)。
54
+ 摘要是对**不可信输入**的加工结果:群成员可以往群名/正文里塞指令,摘要只能当数据看,不能当指令执行。
52
55
  - `heuristic`:本地关键词规则,零网络零成本,质量有上限(会漏也会误抓)。
53
56
 
54
57
  ## 配置
@@ -69,7 +72,10 @@
69
72
 
70
73
  - 不写 `groups` = 所有群都可读。
71
74
  - `groups` 里只用于**关掉**某个群或给它起名。
72
- - 写出 `private` / `friend` / `send` / `scope` 一类键 → 整份配置作废 → **全关**(fail-closed)。
75
+ - `private` 是**受支持的一等键**:`{ "88880001": { "enabled": true } }` 逐条打开私聊;
76
+ 不允许一键全开(`private.defaultEnabled=true` 在策略解析阶段即判非法 → 全关)。
77
+ - 写出 `friend` / `send` / `reply` / `scope` / `write` 一类**危险键** → 整份配置作废 → **全关**(fail-closed);
78
+ 与安全无关的未知键被忽略。
73
79
  - 配置损坏同理:全关,不回退到「默认允许」。
74
80
 
75
81
  ## 落盘
@@ -82,9 +88,11 @@
82
88
  ## 验证
83
89
 
84
90
  ```bash
85
- npm run verify # core 纯净性检查 + 51 个用例
86
- node scripts/smoke.mjs # fixture 端到端(真实子进程走 MCP 协议)
87
- node scripts/smoke.mjs bridge # 真桥接只读冒烟
91
+ npm run verify # core 纯净性检查 + 全部用例(当前 116)
92
+ node scripts/smoke.mjs # fixture 端到端(真实子进程走 MCP 协议)
93
+ node scripts/smoke.mjs bridge # 真桥接只读冒烟
94
+ node scripts/host-smoke.mjs # 宿主半:假 ctx 跑 apply(),真 req/res 打路由(含跨站/伪造 Host 用例)
95
+ node scripts/final-check.mjs # 发布前自检(脱敏词从 QQ_DIGEST_SENSITIVE_PATTERNS 读,不写死在仓库里)
88
96
  ```
89
97
 
90
98
  core 纯净性检查锁定:`src/core/` 不许 import `node:fs` / 子进程 / 网络,不许出现 `fetch(`、`send_*`、`/api/send`、写文件符号。
@@ -207,9 +215,18 @@ node scripts/verify-dsh.mjs # 宿主半在线?客户端半上次启动有
207
215
 
208
216
  ## 已知限制(不美化)
209
217
 
210
- - **设置页的开关不是安全边界**:`/qq-digest-api` 只允许回环地址访问,但本机任何进程
211
- (包括模型通过 bash 起一个 `curl`)都能 POST 改配置。开关管的是「默认不读」,
212
- 不是「别人改不了」。真要防,得给 API 加一次性的会话令牌,本期没做。
218
+ - **设置页的开关不是安全边界**:`/qq-digest-api` 允许**本机进程**(包括模型通过 bash 起一个 `curl`)直接 POST 改配置。
219
+ 它挡住的是**浏览器与网络**,不是本机同一用户下的进程。
220
+ - 管理面已做的校验(`lib/index.js` 的 `denyUntrustedRequest`):
221
+ ① 来源必须是回环(`remoteAddress`);② `Host` 必须是回环字面量(挡 DNS rebinding);
222
+ ③ `Origin` 必须是回环来源;④ `Sec-Fetch-Site: cross-site` 直接拒;
223
+ ⑤ 写操作必须是 `application/json` —— 这个类型跨源必预检,而本服务不返回任何 CORS 头,
224
+ 于是 `<form>` / `text/plain` 的 CSRF 简单请求进不来。
225
+ - 反代 / 公网隧道仍会让 `remoteAddress` 看起来是回环:**不要把本插件挂在公网隧道后面**。
226
+ - 句柄密钥不再有源码默认值:`QQ_DIGEST_SECRET` → `<数据目录>/handle-secret`(首次启动随机生成并落盘)。
227
+ 写不进去就拒绝启动,而不是退回弱密钥。`/state` 的 `secretSource` 会如实告诉你当前用哪一种。
228
+ - 读取摘要会**按当前策略现算授权**:关掉某个群或关掉总开关之后,该群历史摘要立即读不到(本轮修复)。
229
+ 拒绝列表里返回的是 `auditRef`(独立 HMAC 域),它**不能**用来读任何摘要。
213
230
  - 私聊只在 `bridge` 数据源下可用;`snowluma` 适配器只调群历史,没有私聊取数路径。
214
231
  - `bridge` 源的群名是空的(桥接不存群名);可在 `config.json` 的 `groups.<id>.name` 里人工命名。
215
232
  - `bridge` 源每轮最多回看 **100 条**(桥接 `/api/messages` 的硬上限)。两次运行之间新增超过 100 条时,
@@ -218,6 +235,11 @@ node scripts/verify-dsh.mjs # 宿主半在线?客户端半上次启动有
218
235
  - `snowluma` 源是 `stateless`:每轮全量重算固定窗口(默认 60 条)。原因是 OneBot 的 `message_id`
219
236
  不保证单调,拿它当水位线会静默漏读新消息。
220
237
  - 句子级启发式摘要对「谁负责」判断保守:猜不准就回退成发消息的人,不做过度推断。
221
- - `config.json` 里的 `schedule` 字段目前只是**描述性配置**:插件本身不注册定时任务。
222
- 要每天自动跑,需要另外用 DSH 的 automation 建一条定时任务去调用 `qq_digest_run_summary`。
238
+ - `config.json` 的 `schedule` 由**宿主半自己**执行:`lib/index.js` 每 30s tick 一次,到点跑一轮
239
+ (启动后 5s 还会补跑一次,避免刚好在到点后才启动而整天漏跑)。
240
+ 跨进程互斥用 `run.lock`(带心跳续租,长轮次不会被判成陈旧锁抢占)。
241
+ - 手动「立即总结」**不会**吞掉当天的定时:去重只认 `lastTrigger === "scheduled"` 的那一次。
223
242
  - 未评估 QQ 侧 ToS 与账号风险;本插件只读本地桥接/网关接口,不额外登录。
243
+ - **`/state` 会明文回显群号与私聊 QQ 号**:设置页要靠它们做开关和提交,不做掩码是刻意选择
244
+ (入口已经用来源校验收窄到回环同源)。因此别在截图、录屏、远程协助里展示设置页。
245
+ - 未做速率限制:`/run` 与 `run_summary` 可以被反复触发,会放大 LLM 调用费用。需要限流请在外层加。
@@ -6,7 +6,10 @@
6
6
  config:
7
7
  serverName: qq-digest
8
8
  transport: stdio
9
- command: 'D:\Program Files\nodejs\node.exe'
9
+ # __NODE_EXE__ 由 install.mjs 用 process.execPath 替换。
10
+ # 模板里绝不能出现作者机器上的绝对路径:换台机器就会 spawn ENOENT,
11
+ # 表现为「MCP 工具全部消失但插件看起来装上了」。
12
+ command: '__NODE_EXE__'
10
13
  args:
11
14
  - '__PACKAGE_ROOT__/src/run-mcp.mjs'
12
15
  toolCallTimeoutMs: 180000
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@ import { join } from 'node:path';
18
18
  import { canonicalize, KIND } from '../src/core/canonical.js';
19
19
  import { roomHandle } from '../src/core/gate.js';
20
20
  import { configPath, defaultConfig, loadConfig, saveConfig } from '../src/config.js';
21
- import { resolveDataDir, resolveSecret, createRuntime } from '../src/runtime.js';
21
+ import { resolveDataDir, createRuntime } from '../src/runtime.js';
22
22
  import { withRunLock } from '../src/run-lock.js';
23
23
 
24
24
  export const name = 'dsh-qq-digest';
@@ -34,11 +34,12 @@ export function apply(ctx) {
34
34
  mkdirSync(dataDir, { recursive: true });
35
35
 
36
36
  const stateFile = join(dataDir, 'schedule-state.json');
37
- const secret = resolveSecret();
38
37
 
39
38
  let runtime = null;
40
39
  const getRuntime = () => {
41
- if (runtime === null) runtime = createRuntime({ dataDir, secret });
40
+ // createRuntime 自己解析密钥(env → <dataDir>/handle-secret → 首次生成),
41
+ // 与 MCP 半走同一份实现,保证两边算出的句柄一致。
42
+ if (runtime === null) runtime = createRuntime({ dataDir });
42
43
  return runtime;
43
44
  };
44
45
 
@@ -52,6 +53,7 @@ export function apply(ctx) {
52
53
  // ── 状态 ────────────────────────────────────────────────────────────────
53
54
 
54
55
  async function buildState() {
56
+ const secret = getRuntime().secret;
55
57
  const loaded = loadConfig(configPath(dataDir));
56
58
  const config = loaded.config;
57
59
  const scheduleState = readScheduleState(stateFile);
@@ -113,6 +115,8 @@ export function apply(ctx) {
113
115
  privateRooms,
114
116
  configError: loaded.error,
115
117
  policyDegraded: Boolean(loaded.policy?.degraded),
118
+ // 密钥来源如实上报(env / generated / file / explicit)。值本身永不下发。
119
+ secretSource: getRuntime().secretSource,
116
120
  summarizer: config.summarizer,
117
121
  schedule: config.schedule,
118
122
  scheduleState,
@@ -129,7 +133,11 @@ export function apply(ctx) {
129
133
  // ── 配置写入 ─────────────────────────────────────────────────────────────
130
134
 
131
135
  function saveFromUi(body) {
132
- const current = loadConfig(configPath(dataDir)).config;
136
+ const loaded = loadConfig(configPath(dataDir));
137
+ // 配置文件本身坏了时 loadConfig 会回退成默认值;拿这个默认值当 current 再保存,
138
+ // 会把用户原有的 groups 静默清空。宁可明确拒绝,让人先修好文件。
139
+ if (loaded.error) throw badRequest(`CONFIG_UNREADABLE:${loaded.error}`);
140
+ const current = loaded.config;
133
141
 
134
142
  const groups = {};
135
143
  const inputGroups = body?.groups ?? {};
@@ -205,7 +213,7 @@ export function apply(ctx) {
205
213
  // ── 路由 ────────────────────────────────────────────────────────────────
206
214
 
207
215
  function handler(req, res) {
208
- if (denyNonLoopback(req, res)) return;
216
+ if (denyUntrustedRequest(req, res)) return;
209
217
 
210
218
  let url;
211
219
  try {
@@ -249,9 +257,19 @@ export function apply(ctx) {
249
257
  if (!canonical.ok || canonical.kind !== KIND.GROUP) {
250
258
  return sendJson(res, 400, { ok: false, error: 'ONLY_GROUPS' });
251
259
  }
252
- const handle = roomHandle(secret, canonical.canonicalId);
253
- const entry = getRuntime().store.latest(handle);
254
- return sendJson(res, 200, { ok: true, groupId: canonical.groupId, digest: entry?.digest ?? null, createdAt: entry?.createdAt ?? null });
260
+ // 与 MCP 工具走同一条读取路径:读取也要按当前策略复核,
261
+ // 否则「关掉这个群」只影响下一轮取数,历史摘要照样读得到。
262
+ const handle = roomHandle(getRuntime().secret, canonical.canonicalId);
263
+ const out = await getRuntime().service.getDigest(handle);
264
+ const entry = out.status === 'OK' ? out.entry : null;
265
+ return sendJson(res, 200, {
266
+ ok: true,
267
+ groupId: canonical.groupId,
268
+ status: out.status,
269
+ ...(out.reason ? { reason: out.reason } : {}),
270
+ digest: entry?.digest ?? null,
271
+ createdAt: entry?.createdAt ?? null,
272
+ });
255
273
  }
256
274
 
257
275
  return sendJson(res, 404, { ok: false, error: 'NOT_FOUND' });
@@ -279,10 +297,7 @@ export function apply(ctx) {
279
297
  if (!isDue(schedule, now)) return;
280
298
 
281
299
  const state = readScheduleState(stateFile);
282
- if (state.lastRunAt) {
283
- const last = nowIn(schedule.timeZone, new Date(state.lastRunAt));
284
- if (last.dateKey === now.dateKey) return; // 今天已经跑过
285
- }
300
+ if (alreadyRanToday(state, schedule, now)) return;
286
301
 
287
302
  const result = await runOnce('scheduled');
288
303
  ctx.logger?.info?.(`[qq-digest] 定时总结 ${now.dateKey} ${now.hhmm} → ${result.status}`);
@@ -381,7 +396,8 @@ function nowIn(timeZone, at = new Date()) {
381
396
  return {
382
397
  dateKey: `${year}-${month}-${day}`,
383
398
  hhmm: `${hour}:${minute}`,
384
- weekday: index >= 0 ? WEEKDAY_CODES[index] : 'MO',
399
+ // 解析不出来就是 null,不再默默当成周一:fail-open 会让「每周一」的任务在错误的星期跑。
400
+ weekday: index >= 0 ? WEEKDAY_CODES[index] : null,
385
401
  };
386
402
  }
387
403
 
@@ -401,9 +417,22 @@ function normalizeSchedule(raw) {
401
417
  };
402
418
  }
403
419
 
420
+ /**
421
+ * 今天是否已经「由定时任务」跑过。
422
+ *
423
+ * 只认 lastTrigger === 'scheduled':手动跑也会写 lastRunAt,若把它当成定时已完成,
424
+ * 用户在 08:59 手点一次「立即总结」,09:00 的定时就会被静默吞掉 —— 而且没有任何提示。
425
+ */
426
+ function alreadyRanToday(state, schedule, now) {
427
+ if (state?.lastTrigger !== 'scheduled' || !state.lastRunAt) return false;
428
+ const last = nowIn(schedule.timeZone, new Date(state.lastRunAt));
429
+ return last.dateKey === now.dateKey;
430
+ }
431
+
404
432
  function isDue(schedule, now) {
405
433
  if (now.hhmm !== schedule.time) return false;
406
434
  if (schedule.kind === 'daily') return true;
435
+ if (!now.weekday) return false; // 周几不可判定 → 不跑,而不是猜一个
407
436
  return Array.isArray(schedule.weekdays) && schedule.weekdays.includes(now.weekday);
408
437
  }
409
438
 
@@ -412,10 +441,11 @@ function readScheduleState(file) {
412
441
  const parsed = JSON.parse(readFileSync(file, 'utf8'));
413
442
  return {
414
443
  lastRunAt: typeof parsed?.lastRunAt === 'string' ? parsed.lastRunAt : null,
444
+ lastTrigger: typeof parsed?.lastTrigger === 'string' ? parsed.lastTrigger : null,
415
445
  lastStatus: typeof parsed?.lastStatus === 'string' ? parsed.lastStatus : null,
416
446
  };
417
447
  } catch {
418
- return { lastRunAt: null, lastStatus: null };
448
+ return { lastRunAt: null, lastTrigger: null, lastStatus: null };
419
449
  }
420
450
  }
421
451
 
@@ -429,14 +459,64 @@ function writeScheduleState(file, { lastRunAt, trigger, status }) {
429
459
  }
430
460
  }
431
461
 
432
- function denyNonLoopback(req, res) {
462
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
463
+
464
+ /**
465
+ * 管理面入口校验。
466
+ *
467
+ * 只判 remoteAddress 是不够的,有三种真实场景会绕过去:
468
+ * 1) DNS rebinding:攻击者的域名解析到 127.0.0.1,浏览器会把请求发到本机,remoteAddress 就是回环;
469
+ * 2) 任意网页的跨站请求:浏览器发出的简单请求(form / text-plain)不需要预检,服务端不校验就照做;
470
+ * 3) 反向代理 / 公网隧道:转进来的请求 remoteAddress 通常也是 127.0.0.1。
471
+ *
472
+ * 因此这里同时校验 Host、Origin、Sec-Fetch-Site,并要求写操作必须是 application/json
473
+ * (该类型跨源必预检,而本服务不返回任何 CORS 头,预检不可能通过)。
474
+ */
475
+ function denyUntrustedRequest(req, res) {
433
476
  const address = String(req.socket?.remoteAddress ?? '');
434
477
  const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
435
- if (loopback) return false;
436
- sendJson(res, 403, { ok: false, error: 'LOCAL_ONLY' });
478
+ if (!loopback) return reject(res, 403, 'LOCAL_ONLY');
479
+
480
+ if (!hostIsLoopback(req.headers?.host)) return reject(res, 403, 'BAD_HOST');
481
+ if (!originIsLoopback(req.headers?.origin)) return reject(res, 403, 'BAD_ORIGIN');
482
+
483
+ const site = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
484
+ if (site === 'cross-site') return reject(res, 403, 'CROSS_SITE');
485
+
486
+ const method = String(req.method ?? 'GET').toUpperCase();
487
+ if (method === 'POST') {
488
+ const type = String(req.headers?.['content-type'] ?? '').toLowerCase();
489
+ if (!type.startsWith('application/json')) return reject(res, 415, 'JSON_REQUIRED');
490
+ }
491
+
492
+ return false;
493
+ }
494
+
495
+ function reject(res, status, error) {
496
+ sendJson(res, status, { ok: false, error });
437
497
  return true;
438
498
  }
439
499
 
500
+ function hostIsLoopback(header) {
501
+ if (typeof header !== 'string' || header.trim() === '') return false;
502
+ let value = header.trim().toLowerCase();
503
+ // IPv6 形如 [::1]:1875,先剥掉端口
504
+ if (value.startsWith('[')) value = value.slice(0, value.indexOf(']') + 1);
505
+ else value = value.split(':')[0];
506
+ return LOOPBACK_HOSTS.has(value);
507
+ }
508
+
509
+ function originIsLoopback(header) {
510
+ // 没有 Origin 说明不是跨源浏览器请求(同源导航、curl、脚本),交给 Host 校验兜住。
511
+ if (typeof header !== 'string' || header.trim() === '') return true;
512
+ try {
513
+ const url = new URL(header.trim());
514
+ return LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
515
+ } catch {
516
+ return false;
517
+ }
518
+ }
519
+
440
520
  function readJson(req) {
441
521
  return new Promise((resolve, reject) => {
442
522
  let size = 0;
@@ -475,5 +555,13 @@ function sendJson(res, status, payload) {
475
555
  res.end(body);
476
556
  }
477
557
 
478
- /** 仅供单元测试:时区换算与到期判定是定时逻辑里最容易错的部分。 */
479
- export const __test = { nowIn, isDue, normalizeSchedule };
558
+ /** 仅供单元测试:时区换算、到期判定与管理面来源校验是最容易出错的三块。 */
559
+ export const __test = {
560
+ nowIn,
561
+ isDue,
562
+ alreadyRanToday,
563
+ normalizeSchedule,
564
+ readScheduleState,
565
+ writeScheduleState,
566
+ denyUntrustedRequest,
567
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qq-digest",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "只读的 DSH QQ 摘要插件:群聊默认全开、私聊默认全关且只能逐条打开;MCP 四个只读工具 + 设置页(选群/起名/定时/立即总结),零发送能力",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,8 @@
26
26
  "mcp"
27
27
  ],
28
28
  "engines": {
29
- "node": ">=20"
29
+ "node": ">=20",
30
+ "dsh": ">=0.2.0-0 <0.3.0"
30
31
  },
31
32
  "main": "lib/index.js",
32
33
  "exports": {
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 发布前/后的全量自检:仓库、脱敏、测试、npm、GitHub、DSH 运行态,一次跑完。
4
+ *
5
+ * 用法:node scripts/final-check.mjs
6
+ */
7
+
8
+ import { execFileSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
15
+ let failures = 0;
16
+ const ok = (label, detail) => console.log(`PASS ${label}${detail ? ` ${detail}` : ''}`);
17
+ const bad = (label, detail) => { console.log(`FAIL ${label}${detail ? ` ${detail}` : ''}`); failures += 1; };
18
+ const info = (label, detail) => console.log(`INFO ${label}${detail ? ` ${detail}` : ''}`);
19
+
20
+ function run(cmd, args, options = {}) {
21
+ try {
22
+ return {
23
+ ok: true,
24
+ out: execFileSync(cmd, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim(),
25
+ };
26
+ } catch (err) {
27
+ return { ok: false, out: String(err.stdout ?? '').trim(), err: String(err.stderr ?? err.message).trim() };
28
+ }
29
+ }
30
+
31
+ // ── 1. 仓库 ──────────────────────────────────────────────────────────────
32
+ const log = run('git', ['log', '--oneline']);
33
+ if (log.ok && log.out) {
34
+ const commits = log.out.split('\n');
35
+ ok('git 提交', `${commits.length} 个:${commits[0]}`);
36
+ } else bad('git 提交读不到');
37
+
38
+ const status = run('git', ['status', '--porcelain']);
39
+ if (status.ok) {
40
+ if (status.out === '') ok('工作区干净');
41
+ else bad('工作区有未提交改动', status.out.split('\n').slice(0, 3).join(' | '));
42
+ }
43
+
44
+ const tracked = run('git', ['ls-files']);
45
+ const fileCount = tracked.ok ? tracked.out.split('\n').filter(Boolean).length : 0;
46
+ info('跟踪文件', `${fileCount} 个`);
47
+
48
+ const remote = run('git', ['remote', '-v']);
49
+ info('remote', remote.ok && remote.out ? remote.out.split('\n')[0] : '(未配置)');
50
+
51
+ // ── 2. 脱敏 ──────────────────────────────────────────────────────────────
52
+ // 敏感词**不能写在这个文件里**:本文件随 npm 包一起发布(package.json files 含 scripts),
53
+ // 把真实 QQ 号/群号写进来,等于用「检查泄漏的脚本」本身泄漏一遍,而且它还会命中自己,
54
+ // 于是永远报 4 项 FAIL。现在改为外部注入:
55
+ // QQ_DIGEST_SENSITIVE_PATTERNS="123456,88888888" node scripts/final-check.mjs
56
+ const PATTERNS = String(process.env.QQ_DIGEST_SENSITIVE_PATTERNS ?? '')
57
+ .split(',')
58
+ .map((value) => value.trim())
59
+ .filter((value) => value !== '');
60
+
61
+ // 排除检查器自身:否则只要配了词,就必然命中这个文件。
62
+ const GREP_EXCLUDE = [':(exclude)scripts/final-check.mjs'];
63
+
64
+ let leak = 0;
65
+ if (PATTERNS.length === 0) {
66
+ info('脱敏', '未设置 QQ_DIGEST_SENSITIVE_PATTERNS,已跳过(仓库内不得残留个人标识)');
67
+ } else {
68
+ for (const pattern of PATTERNS) {
69
+ const hit = run('git', ['grep', '-n', pattern, '--', '.', ...GREP_EXCLUDE]);
70
+ if (hit.ok && hit.out !== '') {
71
+ bad(`仓库里仍有 ${pattern}`, hit.out.split('\n').slice(0, 2).join(' | '));
72
+ leak += 1;
73
+ }
74
+ }
75
+ if (leak === 0) ok('仓库内无个人标识(QQ/群号/用户名)');
76
+ }
77
+
78
+ const bridgePath = join(root, 'src', 'adapters', 'bridge.js');
79
+ const bridgeSrc = run('node', ['-e', `process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(bridgePath)},'utf8'))`]);
80
+ if (bridgeSrc.ok && /resolveBridgePaths/.test(bridgeSrc.out)) ok('桥接路径可解析(无硬编码目录)');
81
+ else bad('bridge.js 仍在硬编码路径');
82
+
83
+ // ── 3. 测试 ──────────────────────────────────────────────────────────────
84
+ const tests = run('node', ['--test']);
85
+ const summary = (tests.out.match(/# tests (\d+)/) ?? tests.out.match(/ℹ tests (\d+)/) ?? [])[1];
86
+ const failed = (tests.out.match(/# fail (\d+)/) ?? tests.out.match(/ℹ fail (\d+)/) ?? [])[1];
87
+ if (tests.ok && failed === '0') ok('单元/集成测试', `${summary} 用例全过`);
88
+ else bad('测试失败', `fail=${failed ?? '?'}`);
89
+
90
+ const purity = run('node', ['scripts/check-core-purity.mjs']);
91
+ if (purity.ok) ok('core 纯净性', purity.out.split('\n')[0].slice(0, 60));
92
+ else bad('core 纯净性检查失败', purity.out);
93
+
94
+ const smoke = run('node', ['scripts/host-smoke.mjs']);
95
+ const smokeLine = (smoke.out.match(/(\d+)\/(\d+) 通过/) ?? []).slice(1).join('/');
96
+ if (smoke.ok && smokeLine && smokeLine.split('/')[0] === smokeLine.split('/')[1]) ok('宿主冒烟', `${smokeLine} 通过`);
97
+ else bad('宿主冒烟失败', smokeLine || smoke.out.slice(-200));
98
+
99
+ // ── 4. npm ──────────────────────────────────────────────────────────────
100
+ const whoami = run('npm', ['whoami'], { shell: true });
101
+ info('npm 登录', whoami.ok ? whoami.out : '(未登录)');
102
+
103
+ try {
104
+ const res = await fetch('https://registry.npmjs.org/dsh-qq-digest', { signal: AbortSignal.timeout(15000) });
105
+ if (res.status === 404) {
106
+ info('npm 包', '尚未创建');
107
+ } else {
108
+ const meta = await res.json();
109
+ const versions = Object.keys(meta.versions ?? {});
110
+ const latest = meta['dist-tags']?.latest;
111
+ info('npm 版本', versions.join(', ') || '(无)');
112
+ info('npm latest', String(latest));
113
+ info('npm maintainer', (meta.maintainers ?? []).map((m) => `${m.name}<${m.email}>`).join(', ') || '(无)');
114
+
115
+ if (versions.some((v) => v !== '0.0.0-stage')) {
116
+ ok('npm 已有正式版本', versions.filter((v) => v !== '0.0.0-stage').join(', '));
117
+ } else {
118
+ bad('npm 只有占位版 0.0.0-stage —— 暂存版本仍未批准', String(latest));
119
+ }
120
+ }
121
+ } catch (err) {
122
+ bad('npm registry 查询失败', String(err.message));
123
+ }
124
+
125
+ // ── 5. GitHub ───────────────────────────────────────────────────────────
126
+ const lsRemote = run('git', ['ls-remote', 'origin'], {
127
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
128
+ timeout: 20000,
129
+ });
130
+ if (lsRemote.ok && lsRemote.out !== '') {
131
+ const heads = lsRemote.out.split('\n').filter((line) => line.includes('refs/heads/'));
132
+ ok('GitHub 仓库可达', heads.length ? heads.map((h) => h.split('refs/heads/')[1]).join(',') : '(空仓库)');
133
+ } else if (lsRemote.ok) {
134
+ info('GitHub 仓库', '存在但为空,可以直接 push');
135
+ } else {
136
+ bad('GitHub 仓库不可达', lsRemote.err.split('\n').slice(0, 2).join(' '));
137
+ }
138
+
139
+ /**
140
+ * 猜不出端口就等于误报。这里从 DSH 自己留下的痕迹里取:
141
+ * 1) QQ_DIGEST_PORT(显式)
142
+ * 2) ~/.dsh/dsh-process.json 里记录的端口
143
+ * 3) 几个历史默认端口兜底
144
+ */
145
+ function detectDshPorts() {
146
+ const ports = new Set([8784, 1402, 14802]);
147
+ try {
148
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
149
+ ? process.env.DSH_HOME.trim()
150
+ : join(homedir(), '.dsh');
151
+ const parsed = JSON.parse(readFileSync(join(home, 'dsh-process.json'), 'utf8'));
152
+ for (const value of [parsed?.port, parsed?.webPort, parsed?.web?.port]) {
153
+ if (Number.isInteger(value) && value > 0) ports.add(value);
154
+ }
155
+ } catch {
156
+ // 没有就只试兜底端口
157
+ }
158
+ return [...ports];
159
+ }
160
+
161
+ // ── 6. DSH 运行态 ───────────────────────────────────────────────────────
162
+ // DSH 的 Web 端口每次重启都可能变(同一台机器实测过 4845 / 1875 / 4260),
163
+ // 因此优先用显式指定,其次才试历史端口,最后直接问 DSH 的进程信息。
164
+ const explicitPort = process.env.QQ_DIGEST_PORT
165
+ || (process.argv.find((arg) => arg.startsWith('--port=')) ?? '').split('=')[1]
166
+ || '';
167
+ const candidatePorts = [
168
+ ...(explicitPort === '' ? [] : [Number(explicitPort)]),
169
+ ...detectDshPorts(),
170
+ ];
171
+ let base = null;
172
+ for (const port of candidatePorts) {
173
+ try {
174
+ const res = await fetch(`http://127.0.0.1:${port}/qq-digest-api/state`, {
175
+ headers: { accept: 'application/json' },
176
+ signal: AbortSignal.timeout(5000),
177
+ });
178
+ if (res.status === 200) { base = `http://127.0.0.1:${port}`; break; }
179
+ } catch { /* 下一个端口 */ }
180
+ }
181
+
182
+ if (base === null) {
183
+ bad('DSH 里插件没在跑', `试过端口:${candidatePorts.join(', ') || '(无)'};可用 QQ_DIGEST_PORT=<端口> 指定`);
184
+ } else {
185
+ const state = await (await fetch(`${base}/qq-digest-api/state`, { headers: { accept: 'application/json' } })).json();
186
+ ok('宿主半在线', base);
187
+ if (typeof state.groupsEnabled === 'boolean') ok('群聊总开关在位', String(state.groupsEnabled));
188
+ else bad('宿主半是旧版本(缺 groupsEnabled)—— 需要重启 DSH');
189
+ if (Array.isArray(state.privateRooms)) {
190
+ ok('私聊列表中', `${state.privateRooms.length} 条,打开 ${state.privateRooms.filter((r) => r.enabled).length} 条`);
191
+ } else bad('宿主半是旧版本(缺 privateRooms)');
192
+ info('群数', String((state.rooms ?? []).length));
193
+ info('客户端心跳', state.clientBoot ? (state.clientBoot.mountedAt ? '已注册设置页' : `未注册(${state.clientBoot.lastError ?? state.clientBoot.lastPhase})`) : '(旧版宿主无此字段)');
194
+ }
195
+
196
+ console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
197
+ process.exit(failures === 0 ? 0 : 1);
@@ -47,12 +47,19 @@ if (captured.kind !== 'prefix' || captured.path !== '/qq-digest-api') {
47
47
  process.exit(1);
48
48
  }
49
49
 
50
- function request(handler, { method = 'GET', url, body }) {
50
+ function request(handler, { method = 'GET', url, body, headers = {}, remoteAddress = '127.0.0.1' }) {
51
51
  return new Promise((resolve, reject) => {
52
52
  const req = new Readable({ read() {} });
53
53
  req.method = method;
54
54
  req.url = url;
55
- req.socket = { remoteAddress: '127.0.0.1' };
55
+ // 真实请求一定带 Host;写操作还带 content-type。
56
+ // 宿主半现在会校验 Host / Origin / Sec-Fetch-Site,所以这里要把「正常浏览器」的形状补齐。
57
+ req.headers = {
58
+ host: '127.0.0.1:4845',
59
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
60
+ ...headers,
61
+ };
62
+ req.socket = { remoteAddress };
56
63
  if (body !== undefined) req.push(JSON.stringify(body));
57
64
  req.push(null);
58
65
 
@@ -175,6 +182,48 @@ const uniqueSave = await request(captured.handler, {
175
182
  });
176
183
  check('名字互不相同 → 200', uniqueSave.status === 200, uniqueSave.status);
177
184
 
185
+
186
+ // ── 管理面来源校验(跨站 / rebinding / 隧道 / CSRF)──────────────────────
187
+ // 这些用例锁住的是「只判 remoteAddress 不够」这件事本身:
188
+ // 每一条的攻击前提都能让 remoteAddress 看起来是回环。
189
+ const evilOrigin = await request(captured.handler, {
190
+ method: 'POST',
191
+ url: '/qq-digest-api/config',
192
+ body: { groupsEnabled: false },
193
+ headers: { origin: 'https://evil.example' },
194
+ });
195
+ check('跨站 Origin → 403', evilOrigin.status === 403 && evilOrigin.json.error === 'BAD_ORIGIN', `${evilOrigin.status} ${JSON.stringify(evilOrigin.json)}`);
196
+
197
+ const evilHost = await request(captured.handler, { url: '/qq-digest-api/state', headers: { host: 'evil.example:4845' } });
198
+ check('伪造 Host(DNS rebinding)→ 403', evilHost.status === 403 && evilHost.json.error === 'BAD_HOST', `${evilHost.status} ${JSON.stringify(evilHost.json)}`);
199
+
200
+ const crossSite = await request(captured.handler, {
201
+ method: 'POST',
202
+ url: '/qq-digest-api/run',
203
+ body: {},
204
+ headers: { 'sec-fetch-site': 'cross-site' },
205
+ });
206
+ check('Sec-Fetch-Site: cross-site → 403', crossSite.status === 403 && crossSite.json.error === 'CROSS_SITE', `${crossSite.status} ${JSON.stringify(crossSite.json)}`);
207
+
208
+ const textPlain = await request(captured.handler, {
209
+ method: 'POST',
210
+ url: '/qq-digest-api/config',
211
+ body: { groupsEnabled: true },
212
+ headers: { 'content-type': 'text/plain', origin: 'https://evil.example' },
213
+ });
214
+ check('text/plain 简单请求(CSRF)→ 被来源校验挡住', textPlain.status === 403 || textPlain.status === 415, `${textPlain.status} ${JSON.stringify(textPlain.json)}`);
215
+
216
+ const sameOriginNoBody = await request(captured.handler, {
217
+ method: 'POST',
218
+ url: '/qq-digest-api/client-boot',
219
+ body: { phase: 'factory' },
220
+ headers: { origin: 'http://127.0.0.1:4845' },
221
+ });
222
+ check('同源 Origin + application/json → 放行', sameOriginNoBody.status === 200, `${sameOriginNoBody.status} ${JSON.stringify(sameOriginNoBody.json)}`);
223
+
224
+ const nonLoopback = await request(captured.handler, { url: '/qq-digest-api/state', remoteAddress: '192.168.1.10' });
225
+ check('非回环来源 → 403 LOCAL_ONLY', nonLoopback.status === 403 && nonLoopback.json.error === 'LOCAL_ONLY', `${nonLoopback.status} ${JSON.stringify(nonLoopback.json)}`);
226
+
178
227
  for (const result of results) {
179
228
  console.log(`${result.ok ? 'PASS' : 'FAIL'} ${result.name}${result.detail === undefined ? '' : ` (${result.detail})`}`);
180
229
  }
@@ -68,7 +68,12 @@ if (!existsSync(patchFile)) {
68
68
  }
69
69
 
70
70
  const fragment = readFileSync(join(root, 'install', 'cordis.patch.fragment.yml'), 'utf8');
71
- const block = fragment.replace(/__PACKAGE_ROOT__/g, root.replace(/\\/g, '/')).trimEnd();
71
+ const block = fragment
72
+ .replace(/__PACKAGE_ROOT__/g, root.replace(/\\/g, '/'))
73
+ // node 可执行文件必须取当前这台机器上的真实路径:写死任何一台机器的路径,
74
+ // 换机安装都会 spawn ENOENT,而症状是「工具全部消失、插件却像是装好了」。
75
+ .replace(/__NODE_EXE__/g, process.execPath.replace(/\\/g, '/'))
76
+ .trimEnd();
72
77
 
73
78
  const current = readFileSync(patchFile, 'utf8');
74
79
 
package/src/core/gate.js CHANGED
@@ -26,10 +26,23 @@ export const REASON = Object.freeze({
26
26
  GROUP_DISABLED: 'GROUP_DISABLED',
27
27
  });
28
28
 
29
+ /** 读取句柄:拿到它意味着「当前策略放行这个房间」。 */
29
30
  export function roomHandle(secret, canonicalId) {
30
31
  return createHmac('sha256', secret).update(`room:${canonicalId}`).digest('hex').slice(0, 16);
31
32
  }
32
33
 
34
+ /**
35
+ * 审计引用:与读取句柄**必须不同域**。
36
+ *
37
+ * 拒绝分支也要能回一个稳定的、可对账但不泄漏标识的引用;但如果直接复用 roomHandle,
38
+ * 拒绝就等于把「读取凭据」发给了被拒绝的一方 —— 模型可以从 denied[] 里取到被拒绝房间的
39
+ * 句柄,再去读该房间的历史摘要,撤销语义直接失效。
40
+ * 因此审计引用走 `audit:` 域,它**不能**用来读任何东西。
41
+ */
42
+ export function auditRefFor(secret, canonicalId) {
43
+ return createHmac('sha256', secret).update(`audit:${canonicalId}`).digest('hex').slice(0, 16);
44
+ }
45
+
33
46
  export function createGate({ secret }) {
34
47
  if (typeof secret !== 'string' || secret.length < 16) {
35
48
  throw new Error('GATE_SECRET_TOO_SHORT');
@@ -47,7 +60,8 @@ export function createGate({ secret }) {
47
60
  }
48
61
 
49
62
  const { canonicalId, kind } = canon;
50
- const auditRef = roomHandle(secret, canonicalId);
63
+ // 审计引用与读取句柄分属两个 HMAC 域:拒绝时只发放 auditRef,它读不了任何东西。
64
+ const auditRef = auditRefFor(secret, canonicalId);
51
65
 
52
66
  // 交叉校验:声明形态必须与数据源给出的权威类型一致,不一致即拒。
53
67
  if (authoritativeKind !== undefined && authoritativeKind !== null && authoritativeKind !== kind) {
@@ -68,7 +82,7 @@ export function createGate({ secret }) {
68
82
  return Object.freeze({
69
83
  verdict: VERDICT.ALLOW,
70
84
  kind,
71
- roomHandle: auditRef,
85
+ roomHandle: roomHandle(secret, canonicalId),
72
86
  auditRef,
73
87
  });
74
88
  };
package/src/mcp-server.js CHANGED
@@ -17,15 +17,28 @@
17
17
  */
18
18
 
19
19
  import { createInterface } from 'node:readline';
20
+ import { readFileSync } from 'node:fs';
20
21
 
21
22
  import { createRuntime } from './runtime.js';
22
23
  import { withRunLock } from './run-lock.js';
23
24
  import { createToolHandlers } from './tools.js';
24
25
  import { TOOLS } from './core/toolSurface.js';
25
26
 
27
+ /** 版本只有一个真相源:package.json。硬编码会漂(serverInfo 与包版本打对不上)。 */
28
+ function readPackageVersion() {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
31
+ return typeof parsed?.version === 'string' ? parsed.version : '0.0.0';
32
+ } catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+
37
+ const VERSION = readPackageVersion();
38
+
26
39
  const runtime = createRuntime();
27
40
  process.stderr.write(
28
- `[qq-digest] source=${runtime.source} data=${runtime.dataDir} llmKey=${runtime.llm.apiKeySource}\n`,
41
+ `[qq-digest] source=${runtime.source} data=${runtime.dataDir} llmKey=${runtime.llm.apiKeySource} secretSource=${runtime.secretSource}\n`,
29
42
  );
30
43
 
31
44
  // run_summary 走跨进程锁;拿不到锁如实返回 ALREADY_RUNNING,而不是重复跑一轮。
@@ -63,7 +76,7 @@ rl.on('line', async (line) => {
63
76
  result: {
64
77
  protocolVersion: '2024-11-05',
65
78
  capabilities: { tools: {} },
66
- serverInfo: { name: 'qq-digest', version: '0.2.0' },
79
+ serverInfo: { name: 'qq-digest', version: VERSION },
67
80
  },
68
81
  });
69
82
  return;
package/src/run-lock.js CHANGED
@@ -8,10 +8,17 @@
8
8
  * 超过 lease 时限视为陈旧锁并抢占,避免进程崩溃后永久卡死。
9
9
  */
10
10
 
11
- import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
 
14
14
  const DEFAULT_LEASE_MS = 10 * 60_000;
15
+ /**
16
+ * 心跳间隔。租期只有 10 分钟,而一轮最坏可能跑几个小时
17
+ * (200 个房间上限 × LLM 单次 60s 超时 + 桥接 15s),所以必须续租:
18
+ * 否则「还在跑」的锁会被下一个触发者判成陈旧锁抢占,两轮并发写水位线,
19
+ * 表现为重复摘要与水位线互相覆盖 —— 也就是锁存在的唯一理由失效。
20
+ */
21
+ const DEFAULT_HEARTBEAT_MS = 60_000;
15
22
 
16
23
  export function lockPath(dir) {
17
24
  return join(dir, 'run.lock');
@@ -22,18 +29,43 @@ export function lockPath(dir) {
22
29
  * @param {{ leaseMs?: number, now?: () => number }} [options]
23
30
  */
24
31
  export function acquireRunLock(dir, options = {}) {
25
- const { leaseMs = DEFAULT_LEASE_MS, now = () => Date.now() } = options;
32
+ const {
33
+ leaseMs = DEFAULT_LEASE_MS,
34
+ heartbeatMs = DEFAULT_HEARTBEAT_MS,
35
+ now = () => Date.now(),
36
+ } = options;
26
37
  const path = lockPath(dir);
27
38
  const owner = { pid: process.pid, at: now() };
28
39
 
29
40
  for (let attempt = 0; attempt < 2; attempt += 1) {
30
41
  try {
31
42
  mkdirSync(path);
32
- writeFileSync(join(path, 'owner.json'), `${JSON.stringify(owner)}\n`, 'utf8');
43
+ const ownerFile = join(path, 'owner.json');
44
+ writeFileSync(ownerFile, `${JSON.stringify(owner)}\n`, 'utf8');
45
+
46
+ // 续租:age 判定看的是**目录** mtime,所以心跳除了刷新 owner.json 还要 touch 目录本身。
47
+ let timer = null;
48
+ if (heartbeatMs > 0) {
49
+ const period = Math.max(1_000, Math.min(heartbeatMs, Math.floor(leaseMs / 2)));
50
+ timer = setInterval(() => {
51
+ try {
52
+ const beat = { pid: process.pid, at: now() };
53
+ writeFileSync(ownerFile, `${JSON.stringify(beat)}\n`, 'utf8');
54
+ const at = new Date();
55
+ utimesSync(path, at, at);
56
+ } catch {
57
+ // 心跳失败不该让整轮失败:下一轮最多被判成陈旧锁,行为与没有心跳时相同。
58
+ }
59
+ }, period);
60
+ timer.unref?.();
61
+ }
62
+
33
63
  return {
34
64
  ok: true,
35
65
  path,
66
+ owner,
36
67
  release() {
68
+ if (timer) clearInterval(timer);
37
69
  try {
38
70
  rmSync(path, { recursive: true, force: true });
39
71
  } catch {
package/src/runtime.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * DSH_HOME 空白视为未设置,绝不退回 cwd(否则换个目录启动就多一份空数据)。
9
9
  */
10
10
 
11
- import { readFileSync } from 'node:fs';
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { randomBytes } from 'node:crypto';
12
13
  import { homedir } from 'node:os';
13
14
  import { join, resolve } from 'node:path';
14
15
 
@@ -20,11 +21,11 @@ import { configPath, loadConfig } from './config.js';
20
21
  import { createSummarizerFromConfig } from './summarizer-factory.js';
21
22
  import { createService } from './service.js';
22
23
  import { resolveLlmConfig } from './llm-config.mjs';
23
-
24
- export const DEFAULT_SECRET = 'qq-digest-local-dev-secret-0123456789';
25
-
26
24
  import { resolveBridgePaths } from './bridge-paths.js';
27
25
 
26
+ export const HANDLE_SECRET_FILE = 'handle-secret';
27
+ export const MIN_SECRET_LENGTH = 16;
28
+
28
29
  export function resolveDshHome(env = process.env) {
29
30
  const fromEnv = env.DSH_HOME;
30
31
  if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
@@ -37,9 +38,48 @@ export function resolveDataDir(env = process.env) {
37
38
  return join(resolveDshHome(env), 'qq-digest');
38
39
  }
39
40
 
40
- export function resolveSecret(env = process.env) {
41
- const value = env.QQ_DIGEST_SECRET;
42
- return typeof value === 'string' && value.length >= 16 ? value : DEFAULT_SECRET;
41
+ export function secretPath(dataDir) {
42
+ return join(dataDir, HANDLE_SECRET_FILE);
43
+ }
44
+
45
+ /**
46
+ * 解析句柄 HMAC 密钥,并如实说明来源。
47
+ *
48
+ * 为什么不再有「默认常量」:常量写死在源码里,等于句柄可离线计算 ——
49
+ * 知道群号/QQ 号的人就能算出 handle 去定向读摘要,不透明句柄这层保护归零。
50
+ *
51
+ * 优先级:QQ_DIGEST_SECRET(>=16 字符)→ <dataDir>/handle-secret → 首次启动随机生成并落盘。
52
+ * 必须落盘:只存内存会让每次重启都换密钥,水位线(按句柄作键)全部作废,等于每次重启全量重跑。
53
+ */
54
+ export function resolveSecretInfo(env = process.env, dataDir = resolveDataDir(env)) {
55
+ const fromEnv = env.QQ_DIGEST_SECRET;
56
+ if (typeof fromEnv === 'string' && fromEnv.trim().length >= MIN_SECRET_LENGTH) {
57
+ return { secret: fromEnv.trim(), source: 'env' };
58
+ }
59
+
60
+ const file = secretPath(dataDir);
61
+ try {
62
+ const existing = readFileSync(file, 'utf8').trim();
63
+ if (existing.length >= MIN_SECRET_LENGTH) return { secret: existing, source: 'file' };
64
+ } catch {
65
+ // 文件不存在 → 下面生成
66
+ }
67
+
68
+ const generated = randomBytes(32).toString('hex');
69
+ try {
70
+ mkdirSync(dataDir, { recursive: true });
71
+ writeFileSync(file, `${generated}\n`, { encoding: 'utf8', mode: 0o600 });
72
+ } catch (err) {
73
+ // 写不进去就拒绝启动:宁可工具不可用,也不要用一个「一看就知道」的密钥继续跑。
74
+ const failure = new Error(`HANDLE_SECRET_UNWRITABLE:${file}:${err?.message ?? err}`);
75
+ failure.code = 'HANDLE_SECRET_UNWRITABLE';
76
+ throw failure;
77
+ }
78
+ return { secret: generated, source: 'generated' };
79
+ }
80
+
81
+ export function resolveSecret(env = process.env, dataDir = resolveDataDir(env)) {
82
+ return resolveSecretInfo(env, dataDir).secret;
43
83
  }
44
84
 
45
85
  export function readBridgeToken(tokenFile) {
@@ -93,10 +133,14 @@ export function createRuntime(options = {}) {
93
133
 
94
134
  const store = options.store ?? createFileStore({ dir: dataDir });
95
135
  const watermark = options.watermark ?? createFileWatermark({ dir: dataDir });
136
+ const secretInfo = options.secret
137
+ ? { secret: options.secret, source: 'explicit' }
138
+ : resolveSecretInfo(env, dataDir);
96
139
 
97
140
  const service = createService({
98
141
  adapter,
99
- secret: options.secret ?? resolveSecret(env),
142
+ secret: secretInfo.secret,
143
+ secretSource: secretInfo.source,
100
144
  store,
101
145
  watermark,
102
146
  getPolicy: () => readConfig().policy,
@@ -112,5 +156,17 @@ export function createRuntime(options = {}) {
112
156
  summarize: (input) => createSummarizerFromConfig(readConfig().config, { llm })(input),
113
157
  });
114
158
 
115
- return { dataDir, source, adapter, llm, store, watermark, readConfig, configPath: configPath(dataDir), service };
159
+ return {
160
+ dataDir,
161
+ source,
162
+ adapter,
163
+ llm,
164
+ store,
165
+ watermark,
166
+ readConfig,
167
+ configPath: configPath(dataDir),
168
+ service,
169
+ secret: secretInfo.secret,
170
+ secretSource: secretInfo.source,
171
+ };
116
172
  }
package/src/service.js CHANGED
@@ -40,6 +40,8 @@ export function createService(options = {}) {
40
40
  getPolicy: externalPolicyProvider,
41
41
  // 桥接不提供群名,允许外部按 rawId 补一个显示名(只用于展示,不参与裁决)。
42
42
  titleProvider = null,
43
+ // 密钥来源:env | generated | file | unknown。只用于如实上报,绝不外传值。
44
+ secretSource = 'unknown',
43
45
  } = options;
44
46
 
45
47
  if (!adapter) throw new Error('SERVICE_MISSING_ADAPTER');
@@ -142,16 +144,62 @@ export function createService(options = {}) {
142
144
  }
143
145
 
144
146
  function getDigest(handle) {
145
- if (handle !== undefined && handle !== null && handle !== '') {
146
- if (typeof handle !== 'string' || !/^[0-9a-f]{16}$/.test(handle)) {
147
- return { status: 'REJECTED', reason: 'INVALID_HANDLE' };
148
- }
147
+ return readDigest(handle);
148
+ }
149
+
150
+ /**
151
+ * 读取已落盘摘要。
152
+ *
153
+ * 关键约束:**读取也必须过闸门**。
154
+ * 句柄是不透明令牌,但它只证明「签发时策略放行过这个房间」,不证明「现在仍然放行」。
155
+ * 因此每次读取都先按当前策略对数据源现算一遍允许集,再与存储求交 ——
156
+ * 关掉某个群 / 关掉总开关之后,历史摘要立即读不到(撤销语义成立)。
157
+ *
158
+ * 数据源不可用时不允许「先读再说」:无法确认当前允许谁,就一律拒绝。
159
+ */
160
+ async function readDigest(handle) {
161
+ const hasHandle = handle !== undefined && handle !== null && handle !== '';
162
+ if (hasHandle && (typeof handle !== 'string' || !/^[0-9a-f]{16}$/.test(handle))) {
163
+ return { status: 'REJECTED', reason: 'INVALID_HANDLE' };
164
+ }
165
+
166
+ const allowed = await currentAllowedHandles();
167
+ if (!allowed.ok) {
168
+ return {
169
+ status: 'DENIED',
170
+ reason: 'SOURCE_UNAVAILABLE',
171
+ detail: allowed.error?.code ?? null,
172
+ };
173
+ }
174
+
175
+ if (hasHandle) {
176
+ // 不在允许集里,与「没有摘要」返回同一个结果:不告诉调用方它是否存在。
177
+ if (!allowed.handles.has(handle)) return { status: 'NOT_FOUND' };
149
178
  const entry = store.latest(handle);
150
179
  return entry ? { status: 'OK', entry } : { status: 'NOT_FOUND' };
151
180
  }
152
181
 
153
182
  const rows = typeof store.latestPerRoom === 'function' ? store.latestPerRoom() : dedupeLatest(store.all());
154
- return { status: 'OK', count: rows.length, items: rows };
183
+ const visible = rows.filter((row) => allowed.handles.has(row.roomHandle));
184
+ return { status: 'OK', count: visible.length, items: visible };
185
+ }
186
+
187
+ /** 按当前策略现算「此刻允许读」的句柄集合。数据源失败时返回 ok:false。 */
188
+ async function currentAllowedHandles() {
189
+ const policy = resolvePolicy();
190
+ let rooms;
191
+ try {
192
+ rooms = await adapter.listRooms();
193
+ } catch (err) {
194
+ return { ok: false, error: { code: err?.code ?? 'ADAPTER_ERROR', message: String(err?.message ?? err) } };
195
+ }
196
+
197
+ const handles = new Set();
198
+ for (const room of rooms) {
199
+ const decision = gate({ policy, rawId: room.rawId, authoritativeKind: room.authoritativeKind });
200
+ if (decision.verdict === 'ALLOW') handles.add(decision.roomHandle);
201
+ }
202
+ return { ok: true, handles, policy };
155
203
  }
156
204
 
157
205
  async function status() {
@@ -173,8 +221,10 @@ export function createService(options = {}) {
173
221
  adapterError,
174
222
  digestCount: store.all().length,
175
223
  lastRun,
176
- // 句柄密钥只报「是否设置」,绝不报值。
177
- secretConfigured: typeof secret === 'string' && secret.length >= 16,
224
+ // 句柄密钥只报「是否设置」与「来源」,绝不报值。
225
+ // 注意:源码里不再有默认常量,因此 secretConfigured 不会再出现「恒真」的假保证。
226
+ secretConfigured: typeof secret === 'string' && secret.length >= 16 && secretSource !== 'default',
227
+ secretSource,
178
228
  };
179
229
  }
180
230
 
package/src/store-file.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 损坏行启动时跳过并计数,不因为一行坏掉就丢整份历史。
8
8
  */
9
9
 
10
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
 
13
13
  import { RECORD_FIELDS, fail } from './core/store.js';
@@ -23,26 +23,87 @@ export function createFileStore({ dir, now = () => new Date().toISOString() } =
23
23
  const entries = [];
24
24
  let skippedLines = 0;
25
25
  let allowed = new Set();
26
+ let stamp = null;
26
27
 
27
- if (existsSync(file)) {
28
- const text = readFileSync(file, 'utf8');
29
- for (const line of text.split('\n')) {
30
- const trimmed = line.trim();
31
- if (trimmed === '') continue;
32
- try {
33
- const parsed = JSON.parse(trimmed);
34
- if (parsed && typeof parsed.roomHandle === 'string') entries.push(parsed);
35
- else skippedLines += 1;
36
- } catch {
37
- skippedLines += 1;
28
+ function currentStamp() {
29
+ try {
30
+ const stat = statSync(file);
31
+ return `${stat.mtimeMs}:${stat.size}`;
32
+ } catch {
33
+ return 'missing';
34
+ }
35
+ }
36
+
37
+ /** 载入时按落盘白名单重建:文件可能被别的进程改过,原样塞进内存等于绕过白名单。 */
38
+ function rebuildRecord(raw) {
39
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
40
+ if (typeof raw.roomHandle !== 'string' || !/^[0-9a-f]{16}$/.test(raw.roomHandle)) return null;
41
+
42
+ const p = raw.provenance;
43
+ if (!p || typeof p.seqFrom !== 'number' || typeof p.seqTo !== 'number') return null;
44
+
45
+ const draft = {
46
+ roomHandle: raw.roomHandle,
47
+ roomTitle: typeof raw.roomTitle === 'string' ? raw.roomTitle.slice(0, 60) : null,
48
+ digest: sanitizeDigest(raw.digest),
49
+ provenance: {
50
+ roomHandle: raw.roomHandle,
51
+ seqFrom: p.seqFrom,
52
+ seqTo: p.seqTo,
53
+ ...(typeof p.messageCount === 'number' ? { messageCount: p.messageCount } : {}),
54
+ },
55
+ trigger: raw.trigger === 'scheduled' ? 'scheduled' : 'manual',
56
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : now(),
57
+ };
58
+
59
+ return Object.freeze(
60
+ Object.fromEntries(RECORD_FIELDS.filter((key) => key in draft).map((key) => [key, draft[key]])),
61
+ );
62
+ }
63
+
64
+ function loadFromDisk() {
65
+ entries.length = 0;
66
+ skippedLines = 0;
67
+ try {
68
+ const text = readFileSync(file, 'utf8');
69
+ for (const line of text.split('\n')) {
70
+ const trimmed = line.trim();
71
+ if (trimmed === '') continue;
72
+ try {
73
+ const rebuilt = rebuildRecord(JSON.parse(trimmed));
74
+ if (rebuilt) entries.push(rebuilt);
75
+ else skippedLines += 1;
76
+ } catch {
77
+ skippedLines += 1;
78
+ }
38
79
  }
80
+ } catch {
81
+ // 文件不存在 = 空表
39
82
  }
83
+ stamp = currentStamp();
84
+ }
85
+
86
+ /**
87
+ * 跨进程可见性:MCP 子进程写入的摘要,宿主进程(设置页)也必须看得到。
88
+ * 用 mtime+size 判断有没有变化,变了就整体重载 —— 比 tail 增量简单,
89
+ * 也不会因为读到半行而把一条合法记录判成损坏。
90
+ */
91
+ function refresh() {
92
+ if (currentStamp() !== stamp) loadFromDisk();
40
93
  }
41
94
 
95
+ loadFromDisk();
96
+
42
97
  return {
43
98
  dir,
44
99
  file,
45
- skippedLines,
100
+
101
+ get skippedLines() {
102
+ refresh();
103
+ return skippedLines;
104
+ },
105
+
106
+ refresh,
46
107
 
47
108
  bindAllowed(handles) {
48
109
  allowed = new Set(Array.isArray(handles) ? handles : []);
@@ -78,14 +139,18 @@ export function createFileStore({ dir, now = () => new Date().toISOString() } =
78
139
 
79
140
  appendFileSync(file, `${JSON.stringify(stored)}\n`, 'utf8');
80
141
  entries.push(stored);
142
+ // 自己刚写过:记下新的 stamp,避免下一次读取又整体重载一遍。
143
+ stamp = currentStamp();
81
144
  return stored;
82
145
  },
83
146
 
84
147
  all() {
148
+ refresh();
85
149
  return entries.slice();
86
150
  },
87
151
 
88
152
  latest(roomHandle) {
153
+ refresh();
89
154
  for (let index = entries.length - 1; index >= 0; index -= 1) {
90
155
  if (entries[index].roomHandle === roomHandle) return entries[index];
91
156
  }
@@ -93,12 +158,14 @@ export function createFileStore({ dir, now = () => new Date().toISOString() } =
93
158
  },
94
159
 
95
160
  latestPerRoom() {
161
+ refresh();
96
162
  const map = new Map();
97
163
  for (const entry of entries) map.set(entry.roomHandle, entry);
98
164
  return [...map.values()];
99
165
  },
100
166
 
101
167
  dump() {
168
+ refresh();
102
169
  return JSON.stringify(entries);
103
170
  },
104
171
  };