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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- 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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
+
})
|