dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -61,7 +61,9 @@ function createFixture() {
61
61
  async listLibraries() {
62
62
  return [{ id: 'lib_a' }, { id: 'lib_b' }]
63
63
  },
64
- async loadIndex(libraryId) {
64
+ // 同步读:membership() 是同步接口(getSettingsState 心跳不能起 Promise 链),
65
+ // 与 state.js 的真实 loadIndex 保持同一约定。
66
+ loadIndex(libraryId) {
65
67
  return indexes[libraryId ?? 'global'] ?? { version: 1, entries: {} }
66
68
  },
67
69
  async commit({ db, libraryId, item }) {
@@ -303,6 +305,61 @@ test('membership reports which ids are actually inside the active generation', a
303
305
  assert.ok(Array.isArray(membership.lib_b), 'every scope in the generation gets a bucket')
304
306
  })
305
307
 
308
+ test('membership grows with post-migration writes and shrinks with permanent deletes', async () => {
309
+ resetAll()
310
+ const fx = createFixture()
311
+ fx.setIndex('lib_a', { m1: { text: '迁移时就有的', status: 'active' } })
312
+ const migrator = fx.migrator()
313
+ const { generation } = await migrator.switchProfile({ profile: BGE })
314
+ assert.deepEqual(fx.migrator().membership().lib_a, ['m1'], 'the count starts at the migration ledger')
315
+
316
+ // 切换之后聊天写入的记忆:索引条目带当前代际的戳(privateRemember 落盘),账本不知道。
317
+ // 迁移账本永远停在 8/8 那一刻——这就是"当前已入向量库"只显示迁移数的根因。
318
+ fx.indexes.lib_a.entries['mem-new'] = { text: '切换后写入', status: 'active', vector: { route: 'managed-local', generation } }
319
+ let membership = fx.migrator().membership()
320
+ assert.deepEqual((membership.lib_a || []).sort(), ['m1', 'mem-new'], 'a stamped post-migration write is counted')
321
+
322
+ // 永久删除:引擎行和索引行都删了,但账本还挂着 m1 —— 必须从计数里扣掉。
323
+ delete fx.indexes.lib_a.entries.m1
324
+ membership = fx.migrator().membership()
325
+ assert.deepEqual(membership.lib_a, ['mem-new'], 'a deleted migration item is not counted anymore')
326
+ })
327
+
328
+ test('membership ignores stamps that belong to another generation', async () => {
329
+ resetAll()
330
+ const fx = createFixture()
331
+ fx.setIndex('lib_a', { m1: { text: '第一代写入', status: 'active' } })
332
+ const migrator = fx.migrator()
333
+ const first = await migrator.switchProfile({ profile: BGE })
334
+ fx.indexes.lib_a.entries['mem-g1'] = { text: '第一代新写入', status: 'active', vector: { route: 'managed-local', generation: first.generation } }
335
+
336
+ // 切到第二个模型:新代际把 m1 / mem-g1 全部重写进新空间(账本接管),新写入带新代际戳。
337
+ const second = await migrator.switchProfile({ profile: ALT })
338
+ assert.notEqual(second.generation, first.generation)
339
+ fx.indexes.lib_a.entries['mem-g2'] = { text: '第二代写入', status: 'active', vector: { route: 'managed-local', generation: second.generation } }
340
+ let membership = fx.migrator().membership()
341
+ assert.deepEqual((membership.lib_a || []).sort(), ['m1', 'mem-g1', 'mem-g2'], 'the new space holds everything migrated into it plus new writes')
342
+
343
+ // 第二代写入的条目随后被归档:回滚只重放可准入的条目,它留在旧空间里。
344
+ // 回滚后的代际是全新编号,它身上的旧代际戳不得把它带进新计数。
345
+ fx.indexes.lib_a.entries['mem-g2'].status = 'archived'
346
+ await migrator.rollbackProfile()
347
+ membership = fx.migrator().membership()
348
+ assert.deepEqual((membership.lib_a || []).sort(), ['m1', 'mem-g1'], 'a held second-generation write stays out of the rolled-back count')
349
+ })
350
+
351
+ test('membership counts only managed-local stamps, not remote-api ones', async () => {
352
+ resetAll()
353
+ const fx = createFixture()
354
+ fx.setIndex('global', { g1: { text: '全局偏好', status: 'active' } })
355
+ const migrator = fx.migrator()
356
+ const { generation } = await migrator.switchProfile({ profile: BGE })
357
+ // remote-api 的戳描述的是远端空间;本地代际的计数不能把它算进来。
358
+ fx.indexes.global.entries['mem-remote'] = { text: '远端写入', status: 'active', vector: { route: 'remote-api', generation } }
359
+ const membership = fx.migrator().membership()
360
+ assert.deepEqual(membership.global, ['g1'])
361
+ })
362
+
306
363
  test('a legacy requested={"status":"active"} marker is not a pending switch', async () => {
307
364
  resetAll()
308
365
  const fx = createFixture()
@@ -25,12 +25,25 @@ const clientJs = standaloneAsset('/dsh-memu-memory/ui-assets/client.js')
25
25
  const iconPng = standaloneAsset('/dsh-memu-memory/ui-assets/icon.png')
26
26
 
27
27
  const routeSpecs = []
28
- const auth = { decision: undefined, calls: 0 }
28
+ // callbackMode: 'present' | 'throw' | 'absent' — 'absent' models a host that
29
+ // does not implement authorizeLibrary at all (typeof check must fail).
30
+ const auth = { decision: undefined, calls: 0, libraryDecision: true, libraryCalls: 0, libraryRequests: [], callbackMode: 'present' }
29
31
  registerRpc({
30
32
  inject: (_deps, build) => build({
31
33
  effect: (fn) => { const dispose = fn(); return typeof dispose === 'function' ? dispose : () => {} },
32
34
  webServer: { register: (spec) => { routeSpecs.push(spec); return () => {} } },
33
- connection: { requestRejection: () => { auth.calls += 1; return auth.decision } },
35
+ connection: {
36
+ requestRejection: () => { auth.calls += 1; return auth.decision },
37
+ get authorizeLibrary() {
38
+ if (auth.callbackMode === 'absent') return undefined
39
+ return async (req, details) => {
40
+ auth.libraryCalls += 1
41
+ auth.libraryRequests.push({ req, ...details })
42
+ if (auth.callbackMode === 'throw') throw new Error('host authorization unavailable')
43
+ return auth.libraryDecision
44
+ }
45
+ },
46
+ },
34
47
  logger: {},
35
48
  }),
36
49
  })
@@ -243,6 +256,53 @@ test('GET-side RPC reads: 401/403 return before the private index is read', asyn
243
256
  }
244
257
  })
245
258
 
259
+ test('library authorization: session A cannot address library B, and standalone callback fails closed', async () => {
260
+ const other = state.createLibrary({ name: 'auth-other', cwd: path.join(home, 'auth-other') })
261
+ state.setSessionBinding('auth-session-other', { libraryId: other.id, useLibrary: true, scopeKey: other.scopeKey })
262
+ for (const prefix of prefixes) {
263
+ auth.callbackMode = 'present'
264
+ auth.libraryDecision = true
265
+ auth.libraryCalls = 0
266
+ auth.libraryRequests.length = 0
267
+ const cross = await send(prefix.route, prefix.base + '/getMemory', {
268
+ method: 'POST',
269
+ body: envelope('getMemory', { libraryId: other.id, sessionId, id: 'mem-auth-sentinel' }),
270
+ })
271
+ assert.equal(cross.res.status, 403)
272
+ assert.equal(cross.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
273
+ assert.equal(cross.pulls.length, 1, 'JSON body may be parsed before route authorization')
274
+ assert.equal(auth.libraryCalls, 0, 'bound sessions must not fall back to host callback')
275
+ assert.deepEqual(cross.reads, [])
276
+ assert.deepEqual(cross.writes, [])
277
+
278
+ auth.libraryDecision = false
279
+ auth.libraryCalls = 0
280
+ const denied = await send(prefix.route, prefix.base + '/getMemory', {
281
+ method: 'POST',
282
+ body: envelope('getMemory', { libraryId: other.id }),
283
+ })
284
+ assert.equal(denied.res.status, 403)
285
+ assert.equal(auth.libraryCalls, 1)
286
+ assert.equal(denied.reads.length, 0)
287
+ assert.equal(denied.writes.length, 0)
288
+
289
+ auth.libraryDecision = true
290
+ auth.callbackMode = 'throw'
291
+ auth.libraryCalls = 0
292
+ const thrown = await send(prefix.route, prefix.base + '/getMemory', {
293
+ method: 'POST', poison: false,
294
+ body: envelope('getMemory', { libraryId: other.id }),
295
+ })
296
+ assert.equal(thrown.res.status, 403)
297
+ assert.equal(auth.libraryCalls, 1)
298
+ assert.equal(thrown.reads.length, 0)
299
+ assert.equal(thrown.writes.length, 0)
300
+
301
+ auth.callbackMode = 'present'
302
+ auth.libraryDecision = true
303
+ }
304
+ })
305
+
246
306
  test('attachment thumbnails: 401/403 precede authorization and attachment-pool reads', async () => {
247
307
  const attachmentLibrary = state.createLibrary({ name: 'auth-attachment', cwd: path.join(home, 'auth-attachment') })
248
308
  const attachmentSession = 'auth-attachment-session'
@@ -452,3 +512,289 @@ test('unsupported methods still authenticate first: 401/403 before 405', async (
452
512
  assert.equal((await send(prefix.route, prefix.base + '/listLibraries', { method: 'GET' })).res.status, 405)
453
513
  }
454
514
  })
515
+
516
+ // §10 P0: one representative per audited operation category. The table drives
517
+ // every variant, so a new endpoint added to LIBRARY_ROUTE_OPERATIONS with the
518
+ // wrong operation (or none) fails here instead of shipping unverified.
519
+ const MATRIX_SHA = 'a'.repeat(64)
520
+ const matrixRows = [
521
+ { endpoint: 'getMemory', operation: 'read', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
522
+ { endpoint: 'browseWall', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
523
+ { endpoint: 'getStats', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
524
+ { endpoint: 'addMemory', operation: 'write', payload: (lib) => ({ libraryId: lib.id, content: 'matrix write body' }) },
525
+ { endpoint: 'updateMemory', operation: 'write', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch', patch: { title: 'matrix retitled' } }) },
526
+ { endpoint: 'archiveMemory', operation: 'archive', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
527
+ { endpoint: 'deleteMemory', operation: 'delete', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
528
+ { endpoint: 'renameLibrary', operation: 'rename', payload: (lib) => ({ libraryId: lib.id, name: 'matrix rename' }) },
529
+ { endpoint: 'restoreLibrary', operation: 'restore', payload: (lib) => ({ libraryId: lib.id }) },
530
+ { endpoint: 'importMemories', operation: 'import', payload: (lib) => ({ libraryId: lib.id, memories: [] }) },
531
+ { endpoint: 'exportLibrary', operation: 'export', payload: (lib) => ({ libraryId: lib.id }) },
532
+ { endpoint: 'getAttachment', operation: 'attachment.read', payload: (lib) => ({ libraryId: lib.id, sha256: MATRIX_SHA }) },
533
+ { endpoint: 'listReviews', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
534
+ { endpoint: 'resolveReview', operation: 'write', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch', action: 'reject' }) },
535
+ { endpoint: 'lexiconList', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
536
+ { endpoint: 'lexiconAdd', operation: 'write', payload: (lib) => ({ libraryId: lib.id, term: 'matrix term', kind: 'term', meaning: 'matrix meaning' }) },
537
+ // §2.3:lexiconReview 删除,lifecycle 变成 archive / restore 两个普通 write;
538
+ // 授权模型不动(会话绑定优先、standalone 走 host callback)。
539
+ { endpoint: 'lexiconArchive', operation: 'write', payload: (lib) => ({ libraryId: lib.id, termId: 'lex-matrix-scratch' }) },
540
+ { endpoint: 'lexiconRestore', operation: 'write', payload: (lib) => ({ libraryId: lib.id, termId: 'lex-matrix-scratch' }) },
541
+ ]
542
+
543
+ function seedMatrixScratch() {
544
+ state.updatePrivateIndex(library.id, (index) => {
545
+ index.entries['mem-matrix-scratch'] = {
546
+ title: 'matrix scratch', content: 'matrix scratch body', status: 'active', layer: 'long_term',
547
+ visibility: 'shared', disclosure: 'can_tell', source: 'user_said',
548
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
549
+ }
550
+ })
551
+ state.flushState()
552
+ }
553
+
554
+ test('operation matrix: every library route denies A→B, allows A→A, and gates standalone on the host callback', async () => {
555
+ const libB = state.createLibrary({ name: 'auth-matrix-b', cwd: path.join(home, 'auth-matrix-b') })
556
+ for (const prefix of prefixes) {
557
+ seedMatrixScratch()
558
+ for (const row of matrixRows) {
559
+ const post = (payload) => send(prefix.route, prefix.base + '/' + row.endpoint, { method: 'POST', body: envelope(row.endpoint, payload) })
560
+
561
+ // Session A → library B: 403, the host callback is never consulted, and
562
+ // the body (already parsed) is the only thing touched before the denial.
563
+ auth.callbackMode = 'present'
564
+ auth.libraryDecision = true
565
+ auth.libraryCalls = 0
566
+ const cross = await post({ ...row.payload(libB), sessionId })
567
+ assert.equal(cross.res.status, 403, `${prefix.name} ${row.endpoint}: session A must not address library B`)
568
+ assert.equal(cross.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
569
+ assert.equal(cross.pulls.length, 1, 'route authorization happens after the envelope parse')
570
+ assert.equal(auth.libraryCalls, 0, `${row.endpoint}: a bound session must not fall back to the host callback`)
571
+
572
+ // Session A → library A: past the route gate. Business errors, when the
573
+ // payload is not worth making valid, still come back inside a 200.
574
+ const own = await post({ ...row.payload(library), sessionId })
575
+ assert.equal(own.res.status, 200, `${prefix.name} ${row.endpoint}: session A on its own library must pass the route gate`)
576
+ assert.equal(auth.libraryCalls, 0)
577
+
578
+ // Standalone (no session): exactly one authorizeLibrary call that carries
579
+ // the audited operation and the target library; only strict true passes.
580
+ auth.libraryRequests.length = 0
581
+ auth.libraryDecision = true
582
+ auth.libraryCalls = 0
583
+ const granted = await post(row.payload(libB))
584
+ assert.equal(granted.res.status, 200, `${prefix.name} ${row.endpoint}: host callback true must grant the standalone request`)
585
+ assert.equal(auth.libraryCalls, 1)
586
+ const request = auth.libraryRequests.at(-1)
587
+ assert.equal(request.libraryId, libB.id, `${row.endpoint}: callback must see the target library`)
588
+ assert.equal(request.operation, row.operation, `${row.endpoint}: callback must see the audited operation`)
589
+
590
+ auth.libraryDecision = false
591
+ auth.libraryCalls = 0
592
+ const denied = await post(row.payload(libB))
593
+ assert.equal(denied.res.status, 403, `${prefix.name} ${row.endpoint}: callback false must deny`)
594
+ assert.equal(auth.libraryCalls, 1)
595
+
596
+ auth.callbackMode = 'throw'
597
+ auth.libraryDecision = true
598
+ auth.libraryCalls = 0
599
+ const thrown = await post(row.payload(libB))
600
+ assert.equal(thrown.res.status, 403, `${prefix.name} ${row.endpoint}: a thrown callback must deny`)
601
+
602
+ auth.callbackMode = 'absent'
603
+ auth.libraryCalls = 0
604
+ const absent = await post(row.payload(libB))
605
+ assert.equal(absent.res.status, 200, `${prefix.name} ${row.endpoint}: a host without the callback falls back to the HTTP boundary (2026-11-01 flip)`)
606
+ assert.equal(auth.libraryCalls, 0)
607
+ auth.callbackMode = 'present'
608
+ }
609
+ }
610
+ })
611
+
612
+ test('removed lexiconReview is no longer a library route and does not dispatch', async () => {
613
+ for (const prefix of prefixes) {
614
+ auth.callbackMode = 'present'
615
+ auth.libraryDecision = true
616
+ auth.libraryCalls = 0
617
+ const res = await send(prefix.route, prefix.base + '/lexiconReview', {
618
+ method: 'POST',
619
+ body: envelope('lexiconReview', { libraryId: library.id, termId: 'lex-matrix-scratch', action: 'approve' }),
620
+ })
621
+ assert.equal(res.res.status, 200, 'an unknown method is answered as a business error, not routed')
622
+ assert.equal(auth.libraryCalls, 0, 'lexiconReview must not be authorized as a library route anymore')
623
+ const json = JSON.parse(res.res.text)
624
+ assert.equal(json.result.ok, false)
625
+ assert.match(json.result.error.message, /unknown endpoint/)
626
+ }
627
+ // 401/403 仍然先于方法存在性:被拒的 review 请求连 dispatch 都到不了。
628
+ for (const prefix of prefixes) {
629
+ for (const code of [401, 403]) {
630
+ assertRejected(await send(prefix.route, prefix.base + '/lexiconReview', {
631
+ method: 'POST', poison: true, rejection: code,
632
+ body: envelope('lexiconReview', { libraryId: library.id, termId: 'lex-matrix-scratch', action: 'approve' }),
633
+ }), code)
634
+ }
635
+ }
636
+ })
637
+
638
+ test('session-bound resolution: write/archive without an explicit libraryId resolve from the persisted binding', async () => {
639
+ const rows = [
640
+ { endpoint: 'updateMemory', payload: (session) => ({ sessionId: session, id: 'mem-matrix-scratch', patch: { title: 'fallback retitled' } }) },
641
+ { endpoint: 'archiveMemory', payload: (session) => ({ sessionId: session, id: 'mem-matrix-scratch' }) },
642
+ ]
643
+ for (const prefix of prefixes) {
644
+ seedMatrixScratch()
645
+ for (const row of rows) {
646
+ const post = (payload) => send(prefix.route, prefix.base + '/' + row.endpoint, { method: 'POST', body: envelope(row.endpoint, payload) })
647
+ auth.callbackMode = 'present'
648
+ auth.libraryDecision = true
649
+ auth.libraryCalls = 0
650
+ const own = await post(row.payload(sessionId))
651
+ assert.equal(own.res.status, 200, `${prefix.name} ${row.endpoint}: the bound session resolves its own library`)
652
+ assert.equal(auth.libraryCalls, 0)
653
+ for (const bad of ['', 'ghost-session', 42]) {
654
+ const denied = await post(row.payload(bad))
655
+ assert.equal(denied.res.status, 403, `${prefix.name} ${row.endpoint}: sessionId=${JSON.stringify(bad)} must not resolve to a target`)
656
+ assert.equal(denied.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
657
+ }
658
+ }
659
+ }
660
+ })
661
+
662
+ test('global review queue: embedded sessions may read, writes need a live binding, standalone needs review.manage', async () => {
663
+ const seedGlobalReview = () => {
664
+ state.updateGlobalIndex((index) => {
665
+ index.entries['mem-global-review'] = {
666
+ title: 'global review', content: 'global pending body', status: 'pending_review', layer: 'long_term',
667
+ domain: 'general', visibility: 'shared', disclosure: 'can_tell', source: 'inferred',
668
+ reviewReason: 'matrix conflict', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
669
+ }
670
+ })
671
+ state.flushState()
672
+ }
673
+ const post = (prefix, endpoint, payload) => send(prefix.route, prefix.base + '/' + endpoint, { method: 'POST', body: envelope(endpoint, payload) })
674
+
675
+ for (const prefix of prefixes) {
676
+ seedGlobalReview()
677
+ auth.callbackMode = 'present'
678
+ auth.libraryDecision = true
679
+ auth.libraryCalls = 0
680
+
681
+ // Embedded read: a well-formed session reads the global queue without any
682
+ // host callback — the queue is conversation-visible by design.
683
+ const read = await post(prefix, 'listReviews', { sessionId })
684
+ assert.equal(read.res.status, 200)
685
+ const readJson = JSON.parse(read.res.text)
686
+ assert.equal(readJson.result.ok, true)
687
+ assert.equal(readJson.result.value.scope, 'global')
688
+ assert.ok(readJson.result.value.items.some((item) => item.id === 'mem-global-review'))
689
+ assert.equal(auth.libraryCalls, 0, 'embedded reads never consult the host callback')
690
+
691
+ // Embedded write: only a session whose binding still exists may resolve.
692
+ const resolved = await post(prefix, 'resolveReview', { sessionId, id: 'mem-global-review', action: 'reject', resolution: 'replace' })
693
+ assert.equal(resolved.res.status, 200)
694
+ assert.equal(JSON.parse(resolved.res.text).result.ok, true)
695
+ assert.equal(state.loadGlobalIndex().entries['mem-global-review'].status, 'archived')
696
+
697
+ for (const bad of ['', 'ghost-session']) {
698
+ seedGlobalReview()
699
+ auth.libraryCalls = 0
700
+ const denied = await post(prefix, 'resolveReview', { sessionId: bad, id: 'mem-global-review', action: 'reject' })
701
+ assert.equal(denied.res.status, 403, `${prefix.name} resolveReview with sessionId=${JSON.stringify(bad)} must fail closed`)
702
+ assert.equal(state.loadGlobalIndex().entries['mem-global-review'].status, 'pending_review', 'a denied review write must not touch the queue')
703
+ const readDenied = await post(prefix, 'listReviews', { sessionId: bad })
704
+ assert.equal(readDenied.res.status, 403, `${prefix.name} listReviews with sessionId=${JSON.stringify(bad)} must fail closed`)
705
+ }
706
+
707
+ // Standalone management plane: the global queue authorizes with
708
+ // libraryId null and the dedicated review.manage operation, and a host
709
+ // that does not implement it fails closed.
710
+ const standalone = async (endpoint, payload) => {
711
+ seedGlobalReview()
712
+ auth.callbackMode = 'present'
713
+ auth.libraryDecision = true
714
+ auth.libraryCalls = 0
715
+ auth.libraryRequests.length = 0
716
+ const granted = await post(prefix, endpoint, payload)
717
+ assert.equal(granted.res.status, 200, `${prefix.name} ${endpoint}: review.manage true must grant the standalone request`)
718
+ assert.equal(auth.libraryCalls, 1)
719
+ const request = auth.libraryRequests.at(-1)
720
+ assert.equal(request.libraryId, null, 'the global queue authorizes with libraryId null')
721
+ assert.equal(request.operation, 'review.manage')
722
+
723
+ auth.libraryDecision = false
724
+ auth.libraryCalls = 0
725
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 403, `${prefix.name} ${endpoint}: review.manage false must deny`)
726
+ assert.equal(auth.libraryCalls, 1)
727
+
728
+ auth.callbackMode = 'throw'
729
+ auth.libraryDecision = true
730
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 403, `${prefix.name} ${endpoint}: a thrown callback must deny`)
731
+
732
+ auth.callbackMode = 'absent'
733
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 200, `${prefix.name} ${endpoint}: a host without the callback falls back to the HTTP boundary (2026-11-01 flip)`)
734
+ auth.callbackMode = 'present'
735
+ }
736
+ await standalone('listReviews', {})
737
+ await standalone('resolveReview', { id: 'mem-global-review', action: 'reject' })
738
+
739
+ // The private branch keeps its library checks: a bound session cannot
740
+ // address another library's queue, and a granted standalone private read is
741
+ // a plain library read, not review.manage.
742
+ const privateLib = state.createLibrary({ name: 'auth-review-private', cwd: path.join(home, 'auth-review-private') })
743
+ auth.libraryDecision = true
744
+ assert.equal((await post(prefix, 'listReviews', { libraryId: privateLib.id, sessionId })).res.status, 403)
745
+ assert.equal((await post(prefix, 'resolveReview', { libraryId: privateLib.id, sessionId, id: 'mem-global-review', action: 'reject' })).res.status, 403)
746
+ auth.libraryRequests.length = 0
747
+ auth.libraryCalls = 0
748
+ const standalonePrivate = await post(prefix, 'listReviews', { libraryId: privateLib.id })
749
+ assert.equal(standalonePrivate.res.status, 200)
750
+ assert.equal(auth.libraryRequests.at(-1).operation, 'read', 'a private-queue read is a plain library read')
751
+ }
752
+ })
753
+
754
+
755
+ // ---- 2026-11-01 现场(Unexpected token "forbidden" / Invalid RPC response: 403)----
756
+ // 无 libraryId 的 browseWall("全部记忆库"视图)曾被硬拒 403:capability 缺席的宿主
757
+ // 上整个记忆墙挂掉。契约:无 libraryId 走同一 capability 模型——缺席回退放行、实现
758
+ // 了回调的宿主由 callback 决定;403 响应必须是 JSON 信封(standalone fetch 会
759
+ // response.json(),裸文本抛 SyntaxError)。
760
+
761
+ test('browseWall all-libraries view: absent capability allows, present callback decides, envelope on 403', async () => {
762
+ state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
763
+ const lib = state.createLibrary({ name: 'auth-wall-all', cwd: path.join(home, 'auth-wall-all') })
764
+ state.updatePrivateIndex(lib.id, (index) => {
765
+ index.entries['mem-wall-all'] = { title: 'wall all body', content: 'wall all body', status: 'active', layer: 'long_term', visibility: 'shared', disclosure: 'can_tell', source: 'user_said', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
766
+ })
767
+ state.flushState()
768
+
769
+ for (const prefix of prefixes) {
770
+ // capability 缺席(当前版本宿主):跨库视图放行——记忆墙的核心视图不能 403。
771
+ auth.libraryCalls = 0
772
+ const allowed = await send(prefix.route, prefix.base + '/browseWall', { method: 'POST', body: envelope('browseWall', { libraryId: '' }) })
773
+ assert.equal(allowed.res.status, 200, `${prefix.name} browseWall(''): absent capability falls back to the HTTP boundary`)
774
+ const payload = JSON.parse(allowed.res.text)
775
+ assert.ok(payload.result.value.items.length >= 1, 'the wall returns entries across libraries')
776
+
777
+ // 实现了 capability 的宿主:多库枚举由回调决定(deny → 403 JSON 信封)。
778
+ auth.libraryDecision = false
779
+ auth.libraryCalls = 0
780
+ const denied = await send(prefix.route, prefix.base + '/browseWall', { method: 'POST', body: envelope('browseWall', { libraryId: '' }) })
781
+ assert.equal(denied.res.status, 403, 'a host that implements the capability may deny the multi-library read')
782
+ assert.equal(auth.libraryCalls, 1, 'present capability is consulted for the multi-library read')
783
+
784
+ // 403 响应体必须是 JSON 信封:standalone fetch 客户端 response.json() 之后再读
785
+ // error.message——裸 "forbidden" 在客户端抛 SyntaxError(现场事故)。
786
+ assert.equal(denied.res.headers['content-type'] || denied.res.headers['Content-Type'], 'application/json')
787
+ const parsed = JSON.parse(denied.res.text)
788
+ assert.equal(parsed.ok, false)
789
+ assert.equal(parsed.error.code, 'dsh-memu-memory/forbidden')
790
+ assert.equal(typeof parsed.error.message, 'string')
791
+ auth.libraryDecision = true
792
+ }
793
+ })
794
+
795
+ // standalone fetch 包装器:403 信封透传 error.message,而不是只报状态码
796
+ // (现场:Unexpected token "forbidden" → Invalid RPC response: 403,都看不到为什么)。
797
+ test('standalone rpc wrapper surfaces the server error message instead of the status code', () => {
798
+ const client = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
799
+ assert.match(client, /envelope\.error\?\.message \|\| \('Invalid RPC response: ' \+ response\.status\)/)
800
+ })