@modusensus/dsh-mneme 0.9.0 → 0.9.1

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.
Files changed (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
package/lib/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { URL } from "node:url";
2
2
  import { isAbsolute } from "node:path";
3
+ import { existsSync, readdirSync } from "node:fs";
3
4
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
5
  import { FEATURE_FLAG_SPEC } from "./settings.js";
5
6
  import { TYPES } from "./store.js";
@@ -12,7 +13,10 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
12
13
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
13
14
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
14
15
  import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
15
- import { readPipelineDoc } from "./pipeline.js";
16
+ import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
17
+ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
+ import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
+ import { projectKey } from "./candidates.js";
16
20
  import { readCandidateDoc } from "./candidates.js";
17
21
 
18
22
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
@@ -37,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
37
41
  // defaults in config.js (externalApiPort / externalApiHost).
38
42
  const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
39
43
 
44
+ // 请求体上限(防御,不是功能):readBody 只累加字符串,不设上限时一个超大 body
45
+ // 就能把进程内存拉到 Content-Length 那个量级。4 MiB 远高于任何真实写入(记忆
46
+ // 正文、导入的 markdown、面板 patch 都远小于它)。
47
+ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
48
+ // 分页上界(防御):list/search 的参数直接进 SQL LIMIT,无上界时
49
+ // ?limit=100000 会让一次免鉴权 GET 把全库序列化出来(2026-10 审计:免鉴权读面
50
+ // + 浏览器简单请求 = 廉价 DoS)。1000/200 远高于面板与 CLI 的任何真实用法。
51
+ const MAX_PAGE_LIMIT = 1000;
52
+ const MAX_SEARCH_TOP_K = 200;
53
+
40
54
  /** Merge persisted external-api kv over the defaults for client display. */
41
55
  function fullExternalConfig(kv = {}) {
42
56
  return {
@@ -70,22 +84,82 @@ function isAuthorized(req, apiToken) {
70
84
  }
71
85
 
72
86
  /**
73
- * Reject a request with 401 when auth is enabled and the token is missing or
74
- * wrong. Returns true when the request may proceed.
87
+ * 路由鉴权策略(2026-10 审计的结构性修法)。三条:
88
+ * open 永不鉴权——「公开读面」,必须写进 SECURITY.md 的安全模型表
89
+ * token 配置了 apiToken 就整条要带(写路由 + 敏感读)
90
+ * token-write 只有非 GET/HEAD 才要 token(面板要读、只有写才有副作用)
91
+ *
92
+ * 少声明不是「默认开放」:register 直接抛错,插件起不来。这是本方案的要点——
93
+ * 「新增路由忘了加鉴权」从运行期静默漏洞变成装配期硬失败,不依赖谁记得写、也
94
+ * 不依赖测试有没有覆盖到(/export 就是这么漏的,见 2026-10 审计 P0)。
75
95
  */
76
- function requireAuth(req, res, apiToken) {
77
- if (isAuthorized(req, apiToken)) return true;
78
- sendJson(res, 401, { error: "unauthorized" });
79
- return false;
96
+ export const ROUTE_POLICIES = Object.freeze(["open", "token", "token-write"]);
97
+
98
+ /** True when this request must present the apiToken under the route's policy. */
99
+ function needsToken(req, policy, apiToken) {
100
+ if (policy === "open") return false;
101
+ const method = String(req.method || "GET").toUpperCase();
102
+ if (policy === "token-write" && (method === "GET" || method === "HEAD")) return false;
103
+ return !isAuthorized(req, apiToken);
80
104
  }
81
105
 
82
- /** Collect the request body as text (tolerant of empty/invalid bodies). */
83
- function readBody(req) {
106
+ /**
107
+ * 唯一的登记入口:校验 policy、拒绝重复路径、把 handler 包上鉴权。
108
+ * 返回的 count() 供 createApi 汇报真实路由数(此前的字面量 36 已经和现实脱节)。
109
+ */
110
+ export function createRouteRegistrar(webServer, apiToken, disposers) {
111
+ const seen = new Set();
112
+ const register = (route) => {
113
+ if (!ROUTE_POLICIES.includes(route.policy)) {
114
+ throw new Error(
115
+ `[dsh-mneme] 路由 ${route.path} 必须声明 policy(${ROUTE_POLICIES.join(" / ")}),` +
116
+ `实际是 ${JSON.stringify(route.policy)}。先想清楚它属于哪一类,再同步 SECURITY.md 的安全模型表。`
117
+ );
118
+ }
119
+ const key = `${route.kind}:${route.path}`;
120
+ if (seen.has(key)) throw new Error(`[dsh-mneme] 路由 ${key} 被重复登记`);
121
+ seen.add(key);
122
+ const inner = route.handler;
123
+ const handler = (req, res) => {
124
+ if (!needsToken(req, route.policy, apiToken)) return inner(req, res);
125
+ sendJson(res, 401, { error: "unauthorized" });
126
+ return undefined;
127
+ };
128
+ disposers.push(webServer.register({ ...route, handler }));
129
+ };
130
+ return { register, count: () => seen.size };
131
+ }
132
+
133
+
134
+ /**
135
+ * Collect the request body as text, or null when it exceeds the cap.
136
+ *
137
+ * 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
138
+ * /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
139
+ * 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
140
+ * 直接 413(2026-10 评审指出的数据损坏路径)。
141
+ * 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
142
+ */
143
+ function readBody(req, res) {
84
144
  return new Promise((resolve) => {
85
145
  let body = "";
86
- req.on("data", (chunk) => { body += chunk; });
87
- req.on("end", () => resolve(body));
88
- req.on("error", () => resolve(""));
146
+ let bytes = 0;
147
+ let overflow = false;
148
+ req.on("data", (chunk) => {
149
+ if (overflow) return;
150
+ bytes += chunk.length;
151
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
152
+ overflow = true;
153
+ body = "";
154
+ sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
155
+ return;
156
+ }
157
+ body += chunk;
158
+ });
159
+ req.on("end", () => resolve(overflow ? null : body));
160
+ // 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
161
+ // 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
162
+ req.on("error", () => resolve(overflow ? null : ""));
89
163
  });
90
164
  }
91
165
 
@@ -187,14 +261,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
187
261
  service.setEmbedder(embedder);
188
262
  }
189
263
 
190
- const register = (route) => {
191
- disposers.push(webServer.register(route));
192
- };
264
+ // 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
265
+ const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
193
266
 
194
267
  // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
195
268
  register({
196
269
  kind: "prefix",
197
270
  path: "/api/dsh-mneme",
271
+ policy: "open",
198
272
  handler(req, res) {
199
273
  sendJson(res, 404, { error: "not-found" });
200
274
  }
@@ -205,6 +279,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
205
279
  register({
206
280
  kind: "exact",
207
281
  path: "/api/dsh-mneme/info",
282
+ policy: "open",
208
283
  handler(req, res) {
209
284
  sendJson(res, 200, { version: PACKAGE_VERSION });
210
285
  }
@@ -217,6 +292,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
217
292
  register({
218
293
  kind: "exact",
219
294
  path: "/api/dsh-mneme/version-check",
295
+ policy: "open",
220
296
  handler(req, res) {
221
297
  if (req.method !== "GET") {
222
298
  sendJson(res, 404, { error: "not-found" });
@@ -236,11 +312,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
236
312
  register({
237
313
  kind: "exact",
238
314
  path: "/api/dsh-mneme/list",
315
+ policy: "open",
239
316
  handler(req, res) {
240
317
  try {
241
318
  const url = new URL(req.url, "http://localhost");
242
319
  const type = url.searchParams.get("type") ?? undefined;
243
- const limit = Number(url.searchParams.get("limit") ?? 50);
320
+ // 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
321
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
244
322
  const offset = Number(url.searchParams.get("offset") ?? 0);
245
323
  // order=chrono: pure newest-first for paged browsing; default keeps
246
324
  // the importance-ranked order other callers rely on.
@@ -292,6 +370,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
292
370
  register({
293
371
  kind: "exact",
294
372
  path: "/api/dsh-mneme/recall-stats",
373
+ policy: "open",
295
374
  handler(req, res) {
296
375
  if (req.method !== "GET") {
297
376
  sendJson(res, 404, { error: "not-found" });
@@ -311,11 +390,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
311
390
  register({
312
391
  kind: "exact",
313
392
  path: "/api/dsh-mneme/search",
393
+ policy: "open",
314
394
  handler(req, res) {
315
395
  try {
316
396
  const url = new URL(req.url, "http://localhost");
317
397
  const q = url.searchParams.get("q") ?? "";
318
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
398
+ // 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
399
+ const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
319
400
  // mode selects the recall strategy (defaults to auto):
320
401
  // auto (default) keyword first, vector fills remaining slots
321
402
  // hybrid vector first, keyword fills remaining slots; scores of
@@ -355,11 +436,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
355
436
  register({
356
437
  kind: "exact",
357
438
  path: "/api/dsh-mneme/profile",
439
+ policy: "token-write",
358
440
  handler(req, res) {
359
441
  try {
360
442
  if (req.method === "PUT" || req.method === "POST") {
361
- if (!requireAuth(req, res, apiToken)) return;
362
- return readBody(req).then((text) => {
443
+ return readBody(req, res).then((text) => {
444
+ if (text === null) return;
363
445
  const body = parseBody(text);
364
446
  settings.setProfile(typeof body.profile === "string" ? body.profile : "");
365
447
  sendJson(res, 200, { profile: settings.getProfile() });
@@ -372,6 +454,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
372
454
  }
373
455
  });
374
456
 
457
+ // --- agent persona(人格定义页,beta) -------------------------------------
458
+ // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
459
+ // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
460
+ // default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
461
+ // 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
462
+ register({
463
+ kind: "exact",
464
+ path: "/api/dsh-mneme/persona",
465
+ policy: "token-write",
466
+ handler(req, res) {
467
+ try {
468
+ if (req.method === "PUT" || req.method === "POST") {
469
+ return readBody(req, res).then((text) => {
470
+ if (text === null) return;
471
+ // parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
472
+ // 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
473
+ // 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
474
+ let body;
475
+ let jsonOk = true;
476
+ try {
477
+ body = JSON.parse(text || "{}");
478
+ } catch {
479
+ body = {};
480
+ jsonOk = false;
481
+ }
482
+ if (jsonOk && body && typeof body === "object") {
483
+ settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
484
+ }
485
+ sendJson(res, 200, { persona: settings.getPersona() });
486
+ });
487
+ }
488
+ sendJson(res, 200, { persona: settings.getPersona() });
489
+ } catch {
490
+ sendJson(res, 500, { error: "internal" });
491
+ }
492
+ }
493
+ });
494
+
375
495
  // --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
376
496
  // Connectivity probe for a dream/sleep model route: runs one minimal LLM call
377
497
  // through the same harness path the consolidation uses and reports success /
@@ -471,6 +591,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
471
591
  register({
472
592
  kind: "exact",
473
593
  path: "/api/dsh-mneme/llm-providers",
594
+ policy: "open",
474
595
  handler(req, res) {
475
596
  try {
476
597
  const llm = ctx.llm;
@@ -499,11 +620,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
499
620
  register({
500
621
  kind: "exact",
501
622
  path: "/api/dsh-mneme/test-model",
623
+ policy: "token",
502
624
  handler(req, res) {
503
625
  try {
504
626
  if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
505
- if (!requireAuth(req, res, apiToken)) return;
506
- return readBody(req).then(async (text) => {
627
+ return readBody(req, res).then(async (text) => {
628
+ if (text === null) return;
507
629
  const body = parseBody(text);
508
630
  let provider = typeof body.provider === "string" ? body.provider.trim() : "";
509
631
  let model = typeof body.model === "string" ? body.model.trim() : "";
@@ -534,14 +656,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
534
656
  register({
535
657
  kind: "exact",
536
658
  path: "/api/dsh-mneme/delete",
659
+ policy: "token",
537
660
  handler(req, res) {
538
661
  try {
539
662
  if (req.method !== "POST") {
540
663
  sendJson(res, 404, { error: "not-found" });
541
664
  return;
542
665
  }
543
- if (!requireAuth(req, res, apiToken)) return;
544
- return readBody(req).then((text) => {
666
+ return readBody(req, res).then((text) => {
667
+ if (text === null) return;
545
668
  const body = parseBody(text);
546
669
  const id = typeof body.id === "string" ? body.id.trim() : "";
547
670
  if (!id) {
@@ -571,14 +694,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
571
694
  register({
572
695
  kind: "exact",
573
696
  path: "/api/dsh-mneme/update",
697
+ policy: "token",
574
698
  handler(req, res) {
575
699
  try {
576
700
  if (req.method !== "POST") {
577
701
  sendJson(res, 404, { error: "not-found" });
578
702
  return;
579
703
  }
580
- if (!requireAuth(req, res, apiToken)) return;
581
- return readBody(req).then((text) => {
704
+ return readBody(req, res).then((text) => {
705
+ if (text === null) return;
582
706
  const body = parseBody(text);
583
707
  const id = typeof body.id === "string" ? body.id.trim() : "";
584
708
  if (!id) {
@@ -677,14 +801,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
677
801
  register({
678
802
  kind: "exact",
679
803
  path: "/api/dsh-mneme/create",
804
+ policy: "token",
680
805
  handler(req, res) {
681
806
  try {
682
807
  if (req.method !== "POST") {
683
808
  sendJson(res, 404, { error: "not-found" });
684
809
  return;
685
810
  }
686
- if (!requireAuth(req, res, apiToken)) return;
687
- return readBody(req).then((text) => {
811
+ return readBody(req, res).then((text) => {
812
+ if (text === null) return;
688
813
  const body = parseBody(text);
689
814
  if (!TYPES.has(body.type)) {
690
815
  sendJson(res, 400, { error: "invalid-type" });
@@ -759,10 +884,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
759
884
  register({
760
885
  kind: "exact",
761
886
  path: "/api/dsh-mneme/maintenance/reclaim",
887
+ policy: "token",
762
888
  handler(req, res) {
763
889
  try {
764
890
  if (req.method === "GET") {
765
- if (!requireAuth(req, res, apiToken)) return;
766
891
  if (!maintenance) {
767
892
  sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
768
893
  return;
@@ -785,8 +910,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
785
910
  sendJson(res, 404, { error: "not-found" });
786
911
  return;
787
912
  }
788
- if (!requireAuth(req, res, apiToken)) return;
789
- return readBody(req).then((text) => {
913
+ return readBody(req, res).then((text) => {
914
+ if (text === null) return;
790
915
  const parsed = parseReclaimJson(text);
791
916
  if (!parsed.ok) {
792
917
  sendJson(res, 400, { error: parsed.error });
@@ -821,11 +946,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
821
946
  register({
822
947
  kind: "exact",
823
948
  path: "/api/dsh-mneme/rules",
949
+ policy: "token-write",
824
950
  handler(req, res) {
825
951
  try {
826
952
  if (req.method === "PUT" || req.method === "POST") {
827
- if (!requireAuth(req, res, apiToken)) return;
828
- return readBody(req).then((text) => {
953
+ return readBody(req, res).then((text) => {
954
+ if (text === null) return;
829
955
  const body = parseBody(text);
830
956
  settings.setRules(Array.isArray(body.rules) ? body.rules : []);
831
957
  sendJson(res, 200, { rules: settings.getRules() });
@@ -842,12 +968,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
842
968
  register({
843
969
  kind: "exact",
844
970
  path: "/api/dsh-mneme/vector-config",
971
+ policy: "token",
845
972
  handler(req, res) {
846
973
  try {
847
974
  // Secret-bearing endpoint: fully protected when apiToken is set.
848
- if (!requireAuth(req, res, apiToken)) return;
849
975
  if (req.method === "PUT" || req.method === "POST") {
850
- return readBody(req).then((text) => {
976
+ return readBody(req, res).then((text) => {
977
+ if (text === null) return;
851
978
  const body = parseBody(text);
852
979
  // An empty apiKey, or one that already looks masked (round-trips
853
980
  // through maskApiKey unchanged), means "keep the existing key".
@@ -879,9 +1006,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
879
1006
  register({
880
1007
  kind: "exact",
881
1008
  path: "/api/dsh-mneme/vector-reindex",
1009
+ policy: "token",
882
1010
  handler(req, res) {
883
1011
  try {
884
- if (!requireAuth(req, res, apiToken)) return;
885
1012
  if (!embedder) {
886
1013
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
887
1014
  return;
@@ -911,6 +1038,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
911
1038
  register({
912
1039
  kind: "exact",
913
1040
  path: "/api/dsh-mneme/semantic",
1041
+ policy: "open",
914
1042
  handler(req, res) {
915
1043
  try {
916
1044
  const stats = semantic?.vectorIndex?.getStats?.() ?? null;
@@ -971,14 +1099,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
971
1099
  register({
972
1100
  kind: "exact",
973
1101
  path: "/api/dsh-mneme/runtime/provision",
1102
+ policy: "token",
974
1103
  handler(req, res) {
975
1104
  try {
976
1105
  if (req.method !== "POST") {
977
1106
  sendJson(res, 405, { error: "method-not-allowed" });
978
1107
  return;
979
1108
  }
980
- if (!requireAuth(req, res, apiToken)) return;
981
- return readBody(req).then(async (text) => {
1109
+ return readBody(req, res).then(async (text) => {
1110
+ if (text === null) return;
982
1111
  const body = parseBody(text);
983
1112
  const runtimeDir = config?.runtimeDir ?? "";
984
1113
  // 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
@@ -1008,6 +1137,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1008
1137
  register({
1009
1138
  kind: "exact",
1010
1139
  path: "/api/dsh-mneme/semantic/llm-audit",
1140
+ policy: "open",
1011
1141
  handler(req, res) {
1012
1142
  try {
1013
1143
  const url = new URL(req.url, "http://localhost");
@@ -1030,6 +1160,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1030
1160
  register({
1031
1161
  kind: "exact",
1032
1162
  path: "/api/dsh-mneme/semantic/llm-audit/stats",
1163
+ policy: "open",
1033
1164
  handler(req, res) {
1034
1165
  try {
1035
1166
  const url = new URL(req.url, "http://localhost");
@@ -1053,6 +1184,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1053
1184
  register({
1054
1185
  kind: "exact",
1055
1186
  path: "/api/dsh-mneme/semantic/graph/ego",
1187
+ policy: "open",
1056
1188
  handler(req, res) {
1057
1189
  try {
1058
1190
  const url = new URL(req.url, "http://localhost");
@@ -1130,6 +1262,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1130
1262
  register({
1131
1263
  kind: "exact",
1132
1264
  path: "/api/dsh-mneme/semantic/graph/entity-attrs",
1265
+ policy: "open",
1133
1266
  handler(req, res) {
1134
1267
  try {
1135
1268
  const url = new URL(req.url, "http://localhost");
@@ -1168,6 +1301,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1168
1301
  register({
1169
1302
  kind: "exact",
1170
1303
  path: "/api/dsh-mneme/entities",
1304
+ policy: "open",
1171
1305
  handler(req, res) {
1172
1306
  try {
1173
1307
  const url = new URL(req.url, "http://localhost");
@@ -1187,6 +1321,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1187
1321
  register({
1188
1322
  kind: "exact",
1189
1323
  path: "/api/dsh-mneme/memories/entities",
1324
+ policy: "open",
1190
1325
  handler(req, res) {
1191
1326
  try {
1192
1327
  const url = new URL(req.url, "http://localhost");
@@ -1217,8 +1352,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1217
1352
  register({
1218
1353
  kind: "exact",
1219
1354
  path: "/api/dsh-mneme/export",
1355
+ policy: "token",
1220
1356
  handler(req, res) {
1221
1357
  try {
1358
+ // 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
1359
+ // 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
1360
+ // apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
1222
1361
  const url = new URL(req.url, "http://localhost");
1223
1362
  const format = url.searchParams.get("format") ?? "json";
1224
1363
  if (format !== "json" && format !== "markdown") {
@@ -1267,14 +1406,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1267
1406
  register({
1268
1407
  kind: "exact",
1269
1408
  path: "/api/dsh-mneme/import",
1409
+ policy: "token",
1270
1410
  handler(req, res) {
1271
1411
  try {
1272
1412
  if (req.method !== "POST") {
1273
1413
  sendJson(res, 404, { error: "not-found" });
1274
1414
  return;
1275
1415
  }
1276
- if (!requireAuth(req, res, apiToken)) return;
1277
- return readBody(req).then((text) => {
1416
+ return readBody(req, res).then((text) => {
1417
+ if (text === null) return;
1278
1418
  const body = parseBody(text);
1279
1419
  if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
1280
1420
  sendJson(res, 400, { error: "invalid-type" });
@@ -1308,6 +1448,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1308
1448
  register({
1309
1449
  kind: "exact",
1310
1450
  path: "/api/dsh-mneme/dream-status",
1451
+ policy: "open",
1311
1452
  handler(req, res) {
1312
1453
  try {
1313
1454
  const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
@@ -1352,13 +1493,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1352
1493
  register({
1353
1494
  kind: "exact",
1354
1495
  path: "/api/dsh-mneme/conflicts",
1496
+ policy: "token",
1355
1497
  handler(req, res) {
1356
1498
  try {
1357
1499
  if (req.method !== "GET") {
1358
1500
  sendJson(res, 404, { error: "not-found" });
1359
1501
  return;
1360
1502
  }
1361
- if (!requireAuth(req, res, apiToken)) return;
1362
1503
  const url = new URL(req.url, "http://localhost");
1363
1504
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
1364
1505
  const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
@@ -1373,14 +1514,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1373
1514
  register({
1374
1515
  kind: "exact",
1375
1516
  path: "/api/dsh-mneme/conflicts/resolve",
1517
+ policy: "token",
1376
1518
  handler(req, res) {
1377
1519
  try {
1378
1520
  if (req.method !== "POST") {
1379
1521
  sendJson(res, 404, { error: "not-found" });
1380
1522
  return;
1381
1523
  }
1382
- if (!requireAuth(req, res, apiToken)) return;
1383
- return readBody(req).then((text) => {
1524
+ return readBody(req, res).then((text) => {
1525
+ if (text === null) return;
1384
1526
  let body = {};
1385
1527
  try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
1386
1528
  const id = typeof body.id === "string" ? body.id.trim() : "";
@@ -1410,8 +1552,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1410
1552
  register({
1411
1553
  kind: "exact",
1412
1554
  path: "/api/dsh-mneme/health",
1555
+ policy: "token",
1413
1556
  handler(req, res) {
1414
- if (!requireAuth(req, res, apiToken)) return;
1415
1557
  let state = null;
1416
1558
  try {
1417
1559
  state = service.getMirrorHealth?.() ?? null;
@@ -1459,11 +1601,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1459
1601
  register({
1460
1602
  kind: "exact",
1461
1603
  path: "/api/dsh-mneme/mode",
1604
+ policy: "token-write",
1462
1605
  handler(req, res) {
1463
1606
  try {
1464
1607
  if (req.method === "PUT" || req.method === "POST") {
1465
- if (!requireAuth(req, res, apiToken)) return;
1466
- return readBody(req).then((text) => {
1608
+ return readBody(req, res).then((text) => {
1609
+ if (text === null) return;
1467
1610
  const body = parseBody(text);
1468
1611
  if (body.mode !== "light" && body.mode !== "standard") {
1469
1612
  sendJson(res, 400, { error: "invalid-mode" });
@@ -1484,11 +1627,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1484
1627
  register({
1485
1628
  kind: "exact",
1486
1629
  path: "/api/dsh-mneme/external-api",
1630
+ policy: "token",
1487
1631
  handler(req, res) {
1488
1632
  try {
1489
1633
  if (req.method === "PUT" || req.method === "POST") {
1490
- if (!requireAuth(req, res, apiToken)) return;
1491
- return readBody(req).then((text) => {
1634
+ return readBody(req, res).then((text) => {
1635
+ if (text === null) return;
1492
1636
  const body = parseBody(text);
1493
1637
  const patch = {};
1494
1638
  if (body.enabled !== undefined) patch.enabled = body.enabled === true;
@@ -1511,9 +1655,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1511
1655
  sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
1512
1656
  });
1513
1657
  }
1514
- // GET: always hand back a token — the standalone server generates its
1515
- // own on first enabled boot, but the panel displays it before that,
1516
- // so materialize and persist one here.
1658
+ // GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
1659
+ // PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
1660
+ // 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
1661
+ // GET: hand back a token — the standalone server generates its own on
1662
+ // first enabled boot, but the panel displays it before that, so
1663
+ // materialize and persist one here. 只有通过上面那道门才会走到这里,
1664
+ // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
1517
1665
  const kv = settings.getExternalApi() ?? {};
1518
1666
  if (!kv.token) {
1519
1667
  kv.token = randomBytes(24).toString("base64url");
@@ -1535,11 +1683,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1535
1683
  register({
1536
1684
  kind: "exact",
1537
1685
  path: "/api/dsh-mneme/features",
1686
+ policy: "token-write",
1538
1687
  handler(req, res) {
1539
1688
  try {
1540
1689
  if (req.method === "PUT" || req.method === "POST") {
1541
- if (!requireAuth(req, res, apiToken)) return;
1542
- return readBody(req).then((text) => {
1690
+ return readBody(req, res).then((text) => {
1691
+ if (text === null) return;
1543
1692
  const body = parseBody(text);
1544
1693
  if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
1545
1694
  sendJson(res, 400, { error: "invalid-patch" });
@@ -1569,6 +1718,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1569
1718
  register({
1570
1719
  kind: "exact",
1571
1720
  path: "/api/dsh-mneme/inject-status",
1721
+ policy: "open",
1572
1722
  handler(req, res) {
1573
1723
  try {
1574
1724
  if (req.method !== "GET") {
@@ -1593,6 +1743,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1593
1743
  register({
1594
1744
  kind: "exact",
1595
1745
  path: "/api/dsh-mneme/inject-preview",
1746
+ policy: "open",
1596
1747
  handler(req, res) {
1597
1748
  try {
1598
1749
  if (req.method !== "GET") {
@@ -1610,11 +1761,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1610
1761
  register({
1611
1762
  kind: "exact",
1612
1763
  path: "/api/dsh-mneme/commands",
1764
+ policy: "token-write",
1613
1765
  handler(req, res) {
1614
1766
  try {
1615
1767
  if (req.method === "POST") {
1616
- if (!requireAuth(req, res, apiToken)) return;
1617
- return readBody(req).then((text) => {
1768
+ return readBody(req, res).then((text) => {
1769
+ if (text === null) return;
1618
1770
  const body = parseBody(text);
1619
1771
  try {
1620
1772
  const command = commands.add({
@@ -1629,7 +1781,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1629
1781
  });
1630
1782
  }
1631
1783
  if (req.method === "DELETE") {
1632
- if (!requireAuth(req, res, apiToken)) return;
1633
1784
  const url = new URL(req.url, "http://localhost");
1634
1785
  const id = url.searchParams.get("id");
1635
1786
  const removed = id ? commands.remove(id) : false;
@@ -1647,11 +1798,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1647
1798
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1648
1799
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1649
1800
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
1801
+ // 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
1802
+ // 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
1803
+ // runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
1650
1804
  // 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
1651
1805
  // 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
1652
1806
  register({
1653
1807
  kind: "exact",
1654
1808
  path: "/api/dsh-mneme/pipeline",
1809
+ policy: "open",
1655
1810
  handler(req, res) {
1656
1811
  try {
1657
1812
  const url = new URL(req.url, "http://localhost");
@@ -1665,7 +1820,76 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1665
1820
  sendJson(res, 400, { error: "project-not-absolute", detail: project });
1666
1821
  return;
1667
1822
  }
1668
- sendJson(res, 200, readPipelineDoc({ projectRoot: project, id }));
1823
+
1824
+ // 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
1825
+ // 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
1826
+ // runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
1827
+ // 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
1828
+ // ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
1829
+ // 复盘注释见下方旁挂块)。
1830
+ let runIds = [];
1831
+ let runsResult = { runsById: {}, errors: [], statesById: {} };
1832
+ if (project !== null) {
1833
+ const where = runsDir(project);
1834
+ runIds = existsSync(where)
1835
+ ? readdirSync(where)
1836
+ .filter((f) => f.endsWith(RUNS_SUFFIX))
1837
+ .sort()
1838
+ .map((f) => f.slice(0, -RUNS_SUFFIX.length))
1839
+ : [];
1840
+ if (runIds.length) {
1841
+ // step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
1842
+ // 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
1843
+ // 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
1844
+ let stepIdsById = null;
1845
+ try {
1846
+ stepIdsById = Object.create(null);
1847
+ for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
1848
+ stepIdsById[p.id] = p.steps.map((s) => s.id);
1849
+ }
1850
+ } catch {
1851
+ // 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
1852
+ // 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
1853
+ stepIdsById = null;
1854
+ }
1855
+ runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
1856
+ }
1857
+ }
1858
+ const { runsById, errors: runsErrors, statesById } = runsResult;
1859
+
1860
+ // P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
1861
+ // 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
1862
+ let observationsById = {};
1863
+ if (project !== null) {
1864
+ try {
1865
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
1866
+ ? config.memoryDir
1867
+ : "~/.dsh/memory";
1868
+ const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
1869
+ if (odir) {
1870
+ observationsById = loadObservationsById(odir, runIds.filter(Boolean));
1871
+ }
1872
+ } catch {
1873
+ // 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
1874
+ }
1875
+ }
1876
+
1877
+ const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
1878
+
1879
+ // 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
1880
+ // **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
1881
+ // pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
1882
+ // 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
1883
+ // 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
1884
+ // 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
1885
+ const chosenId = doc.pipeline ? doc.pipeline.id : null;
1886
+ const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
1887
+ const available =
1888
+ project === null
1889
+ ? doc.available
1890
+ : doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
1891
+
1892
+ sendJson(res, 200, { ...doc, available, runsState, runsErrors });
1669
1893
  } catch (error) {
1670
1894
  // 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
1671
1895
  // 而不是回一句 internal 让人去翻日志。
@@ -1690,6 +1914,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1690
1914
  register({
1691
1915
  kind: "exact",
1692
1916
  path: "/api/dsh-mneme/candidates",
1917
+ policy: "open",
1693
1918
  handler(req, res) {
1694
1919
  try {
1695
1920
  const url = new URL(req.url, "http://localhost");
@@ -1713,7 +1938,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1713
1938
  });
1714
1939
 
1715
1940
  return {
1716
- routes: 36,
1941
+ routes: countRoutes(),
1717
1942
  dispose: () => {
1718
1943
  for (const dispose of disposers) dispose();
1719
1944
  }