@jjh32456/dsh-longlive 1.6.7 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
package/test/bus.test.mjs DELETED
@@ -1,1572 +0,0 @@
1
- // Bus prototype tests — dependency-free runner.
2
- //
3
- // Run with the bundled Node runtime:
4
- // node --test test/bus.test.mjs
5
- //
6
- // Every temp directory is created under <repo>/.tmp-test and removed afterwards.
7
-
8
- import assert from 'node:assert/strict';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { spawn } from 'node:child_process';
12
- import { fileURLToPath, pathToFileURL } from 'node:url';
13
-
14
- import {
15
- createBus,
16
- DEFAULT_WRITER_TTL_MS,
17
- MAX_TTL_MS,
18
- DEFAULT_MAX_BODY_CHARS,
19
- mergePendingAckIds,
20
- shouldAckOnTurnEnd,
21
- } from '../src/bus/bus.mjs';
22
-
23
- const HERE = path.dirname(fileURLToPath(import.meta.url));
24
- const REPO = path.resolve(HERE, '..');
25
- const TMP_BASE = path.join(REPO, '.tmp-test');
26
- const BUS_URL = pathToFileURL(path.join(REPO, 'src', 'bus', 'bus.mjs')).href;
27
- const HOST = 'testhost';
28
-
29
- fs.mkdirSync(TMP_BASE, { recursive: true });
30
-
31
- const results = [];
32
-
33
- async function test(name, fn) {
34
- try {
35
- await fn();
36
- results.push({ name, ok: true });
37
- console.log(`ok ${results.length} - ${name}`);
38
- } catch (error) {
39
- results.push({ name, ok: false, error });
40
- console.log(`not ok ${results.length} - ${name}`);
41
- console.log(String(error && error.stack ? error.stack : error).split('\n').slice(0, 8).join('\n'));
42
- }
43
- }
44
-
45
- function setup(tag) {
46
- const memoryRoot = fs.mkdtempSync(path.join(TMP_BASE, `${tag}-`));
47
- const root = path.join(memoryRoot, '.longlive', 'bus');
48
- const clock = { value: Date.now() };
49
- const make = (sessionId, extra = {}) => createBus({
50
- root,
51
- sessionId,
52
- host: HOST,
53
- memoryRoot,
54
- now: () => clock.value,
55
- ...extra,
56
- });
57
- return {
58
- memoryRoot,
59
- root,
60
- clock,
61
- make,
62
- alice: make('session-alice'),
63
- bob: make('session-bob'),
64
- cleanup: () => fs.rmSync(memoryRoot, { recursive: true, force: true }),
65
- };
66
- }
67
-
68
- function writeLiveRow(memoryRoot, sessionId, options = {}) {
69
- const dir = path.join(memoryRoot, '.longlive', 'live');
70
- fs.mkdirSync(dir, { recursive: true });
71
- const now = options.now ?? Date.now();
72
- const heartbeatAt = new Date(now - (options.heartbeatAgeMs ?? 0)).toISOString();
73
- const row = {
74
- schemaVersion: 1,
75
- sessionId,
76
- pid: options.pid ?? process.pid,
77
- host: options.host ?? HOST,
78
- startedAt: heartbeatAt,
79
- updatedAt: heartbeatAt,
80
- heartbeatAt,
81
- ttlMs: options.ttlMs ?? 60000,
82
- state: options.state ?? 'running',
83
- turn: 1,
84
- goal: '',
85
- blocked_on: '',
86
- queued: [],
87
- last_action: { at: heartbeatAt, kind: 'turn', detail: '' },
88
- };
89
- fs.writeFileSync(path.join(dir, `${sessionId}.json`), JSON.stringify(row, null, 2));
90
- }
91
-
92
- function runNode(code, env) {
93
- return new Promise((resolve, reject) => {
94
- const child = spawn(process.execPath, ['--input-type=module', '-e', code], {
95
- env: { ...process.env, ...env },
96
- stdio: ['ignore', 'pipe', 'pipe'],
97
- });
98
- let stderr = '';
99
- child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
100
- child.on('error', reject);
101
- child.on('exit', (exitCode) => {
102
- if (exitCode === 0) resolve();
103
- else reject(new Error(`child exited ${exitCode}: ${stderr.slice(0, 500)}`));
104
- });
105
- });
106
- }
107
-
108
- // 1. Basic round trip.
109
- await test('send / poll / ack round trip', async () => {
110
- const s = setup('basic');
111
- try {
112
- const sent = s.alice.send({ to: 'session-bob', body: 'hello', kind: 'note' });
113
- assert.equal(sent.ok, true);
114
- assert.match(sent.id, /^m-/);
115
- const first = s.bob.poll({ since: 0 });
116
- assert.equal(first.messages.length, 1);
117
- assert.equal(first.messages[0].body, 'hello');
118
- assert.equal(first.messages[0].from, 'session-alice');
119
- assert.equal(first.messages[0].to, 'session-bob');
120
- assert.equal(first.messages[0].host, HOST);
121
- const acked = s.bob.ack(sent.id);
122
- assert.equal(acked.ok, true);
123
- assert.equal(acked.already, false);
124
- const second = s.bob.poll({ since: 0 });
125
- assert.equal(second.messages.length, 0);
126
- } finally {
127
- s.cleanup();
128
- }
129
- });
130
-
131
- // 2. poll is non-destructive until ack.
132
- await test('poll is at-least-once (repeat poll before ack)', async () => {
133
- const s = setup('at-least-once');
134
- try {
135
- const sent = s.alice.send({ to: 'session-bob', body: 'retry me' });
136
- const a = s.bob.poll({ since: 0 });
137
- const b = s.bob.poll({ since: 0 });
138
- assert.equal(a.messages.length, 1);
139
- assert.equal(b.messages.length, 1);
140
- assert.equal(a.messages[0].id, sent.id);
141
- assert.equal(b.messages[0].id, sent.id);
142
- s.bob.ack(sent.id);
143
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 0);
144
- } finally {
145
- s.cleanup();
146
- }
147
- });
148
-
149
- // 3. Duplicate ack is idempotent; unknown id is E_NOT_FOUND.
150
- await test('duplicate ack is idempotent', async () => {
151
- const s = setup('dup-ack');
152
- try {
153
- const sent = s.alice.send({ to: 'session-bob', body: 'ack twice' });
154
- const first = s.bob.ack(sent.id);
155
- const second = s.bob.ack(sent.id);
156
- assert.equal(first.ok, true);
157
- assert.equal(first.already, false);
158
- assert.equal(second.ok, true);
159
- assert.equal(second.already, true);
160
- const missing = s.bob.ack('m-000000000-0001-000001-deadbeef');
161
- assert.equal(missing.ok, false);
162
- assert.equal(missing.code, 'E_NOT_FOUND');
163
- } finally {
164
- s.cleanup();
165
- }
166
- });
167
-
168
- // 4. dedupKey makes send idempotent; a conflicting reuse is rejected.
169
- await test('dedupKey send idempotency and conflict', async () => {
170
- const s = setup('idempotent');
171
- try {
172
- const first = s.alice.send({ to: 'session-bob', body: 'same', dedupKey: 'job-1' });
173
- const second = s.alice.send({ to: 'session-bob', body: 'same', dedupKey: 'job-1' });
174
- assert.equal(first.id, second.id);
175
- assert.equal(second.results[0].deduped, true);
176
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 1);
177
- await assert.rejects(
178
- async () => s.alice.send({ to: 'session-bob', body: 'different', dedupKey: 'job-1' }),
179
- (error) => error.code === 'E_DEDUP_CONFLICT',
180
- );
181
- } finally {
182
- s.cleanup();
183
- }
184
- });
185
-
186
- // 5. TTL expiry is evaluated by the receiver and removes the file.
187
- await test('TTL expiry discards the message', async () => {
188
- const s = setup('expiry');
189
- try {
190
- s.clock.value = 1_000_000;
191
- s.alice.send({ to: 'session-bob', body: 'soon gone', ttl: 1000 });
192
- s.clock.value += 1001;
193
- const polled = s.bob.poll({ since: 0 });
194
- assert.equal(polled.messages.length, 0);
195
- assert.equal(polled.expired, 1);
196
- assert.equal(polled.scanned, 1);
197
- const inbox = path.join(s.root, 'inbox', 'session-bob');
198
- assert.equal(fs.readdirSync(inbox).filter((name) => name.endsWith('.json')).length, 0);
199
- } finally {
200
- s.cleanup();
201
- }
202
- });
203
-
204
- // 6. Dead peer is refused by default, allowed explicitly; roles skip dead members.
205
- await test('dead session refusal and allowDead / role skip', async () => {
206
- const s = setup('dead');
207
- try {
208
- writeLiveRow(s.memoryRoot, 'session-dead', { heartbeatAgeMs: 10 * 60 * 1000, ttlMs: 60000, pid: 999999 });
209
- writeLiveRow(s.memoryRoot, 'session-alive', { heartbeatAgeMs: 0, ttlMs: 60000 });
210
- await assert.rejects(
211
- async () => s.alice.send({ to: 'session-dead', body: 'anyone?' }),
212
- (error) => error.code === 'E_PEER_DEAD',
213
- );
214
- const forced = s.alice.send({ to: 'session-dead', body: 'stored', allowDead: true });
215
- assert.equal(forced.ok, true);
216
- const deadBus = s.make('session-dead');
217
- assert.equal(deadBus.poll({ since: 0 }).messages.length, 1);
218
-
219
- fs.writeFileSync(path.join(s.root, 'roles.json'), JSON.stringify({ team: ['session-alive', 'session-dead'] }));
220
- const roleSend = s.alice.send({ to: '#team', body: 'role hello' });
221
- assert.equal(roleSend.results.length, 1);
222
- assert.equal(roleSend.results[0].to, 'session-alive');
223
- assert.equal(roleSend.partial, true);
224
- assert.ok(roleSend.skipped.some((item) => item.to === 'session-dead' && item.reason === 'dead'));
225
- const aliveBus = s.make('session-alive');
226
- assert.equal(aliveBus.poll({ since: 0 }).messages.length, 1);
227
- } finally {
228
- s.cleanup();
229
- }
230
- });
231
-
232
- // 7. Path traversal, reserved names, and bad poll ids are rejected.
233
- await test('path escape and bad session ids', async () => {
234
- const s = setup('path');
235
- try {
236
- const badTargets = ['../evil', '..\\evil', 'C:\\evil', 'a/b', '..', '.', 'con', 'nul', 'session-x/../y', 'session-x\u0000y'];
237
- for (const bad of badTargets) {
238
- await assert.rejects(
239
- async () => s.alice.send({ to: bad, body: 'x' }),
240
- (error) => error.code === 'E_PATH_OUTSIDE_ROOT' || error.code === 'E_BAD_SESSION_ID',
241
- `expected rejection for ${JSON.stringify(bad)}`,
242
- );
243
- }
244
- await assert.rejects(
245
- async () => s.bob.poll({ sessionId: '../x' }),
246
- (error) => error.code === 'E_PATH_OUTSIDE_ROOT',
247
- );
248
- assert.equal(fs.existsSync(path.join(s.root, '..', 'evil.json')), false);
249
- } finally {
250
- s.cleanup();
251
- }
252
- });
253
-
254
- // 8. Backlog cap blocks new sends until a slot is freed.
255
- await test('backlog cap', async () => {
256
- const s = setup('backlog');
257
- try {
258
- const alice = s.make('session-alice', { maxPending: 3 });
259
- const sent = [];
260
- for (let i = 0; i < 3; i += 1) sent.push(alice.send({ to: 'session-bob', body: `m${i}` }));
261
- await assert.rejects(
262
- async () => alice.send({ to: 'session-bob', body: 'overflow' }),
263
- (error) => error.code === 'E_BACKLOG_FULL',
264
- );
265
- s.bob.ack(sent[0].id);
266
- const after = alice.send({ to: 'session-bob', body: 'fits now' });
267
- assert.equal(after.ok, true);
268
- } finally {
269
- s.cleanup();
270
- }
271
- });
272
-
273
- // 9. A fresh handle sees the same on-disk queue.
274
- await test('restart recovery with a new handle', async () => {
275
- const s = setup('restart');
276
- try {
277
- const sent = s.alice.send({ to: 'session-bob', body: 'persisted' });
278
- const restarted = s.make('session-bob');
279
- const polled = restarted.poll({ since: 0 });
280
- assert.equal(polled.messages.length, 1);
281
- assert.equal(polled.messages[0].id, sent.id);
282
- restarted.ack(sent.id);
283
- const third = s.make('session-bob');
284
- assert.equal(third.poll({ since: 0 }).messages.length, 0);
285
- } finally {
286
- s.cleanup();
287
- }
288
- });
289
-
290
- // 10. Three processes deliver 15 messages each without loss or duplication.
291
- await test('concurrent delivery from three processes', async () => {
292
- const s = setup('concurrent');
293
- try {
294
- const code = `
295
- import { createBus } from ${JSON.stringify(BUS_URL)};
296
- const bus = createBus({
297
- root: process.env.BUS_ROOT,
298
- sessionId: 'session-alice',
299
- host: ${JSON.stringify(HOST)},
300
- });
301
- for (let i = 0; i < 15; i += 1) {
302
- bus.send({
303
- to: 'session-bob',
304
- body: 'worker-' + process.env.WORKER + '-' + i,
305
- dedupKey: 'worker-' + process.env.WORKER + '-' + i,
306
- });
307
- }
308
- `;
309
- await Promise.all([0, 1, 2].map((worker) => runNode(code, {
310
- BUS_ROOT: s.root,
311
- WORKER: String(worker),
312
- })));
313
- const polled = s.bob.poll({ since: 0, limit: 100 });
314
- assert.equal(polled.messages.length, 45);
315
- assert.equal(new Set(polled.messages.map((message) => message.id)).size, 45);
316
- assert.equal(new Set(polled.messages.map((message) => message.body)).size, 45);
317
- } finally {
318
- s.cleanup();
319
- }
320
- });
321
-
322
- // 11. Alias (1:1) and role (1:N, deduplicated) addressing.
323
- await test('alias and role addressing', async () => {
324
- const s = setup('addressing');
325
- try {
326
- fs.writeFileSync(path.join(s.root, 'aliases.json'), JSON.stringify({ reviewer: 'session-bob' }));
327
- fs.writeFileSync(path.join(s.root, 'roles.json'), JSON.stringify({ team: ['session-bob', 'session-carol', 'session-bob'] }));
328
- s.alice.send({ to: '@reviewer', body: 'alias hello' });
329
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 1);
330
- const carol = s.make('session-carol');
331
- const roleSend = s.alice.send({ to: '#team', body: 'role hello' });
332
- assert.equal(roleSend.results.length, 2);
333
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 2);
334
- assert.equal(carol.poll({ since: 0 }).messages.length, 1);
335
- await assert.rejects(
336
- async () => s.alice.send({ to: '@nobody', body: 'x' }),
337
- (error) => error.code === 'E_UNKNOWN_TARGET',
338
- );
339
- } finally {
340
- s.cleanup();
341
- }
342
- });
343
-
344
- // 12. Foreign-host messages are never delivered; remote peers are refused.
345
- await test('cross-host isolation', async () => {
346
- const s = setup('foreign');
347
- try {
348
- writeLiveRow(s.memoryRoot, 'session-remote', { host: 'other-host', heartbeatAgeMs: 0, ttlMs: 60000 });
349
- await assert.rejects(
350
- async () => s.alice.send({ to: 'session-remote', body: 'x' }),
351
- (error) => error.code === 'E_REMOTE_HOST',
352
- );
353
- const inbox = path.join(s.root, 'inbox', 'session-bob');
354
- fs.mkdirSync(inbox, { recursive: true });
355
- const id = 'm-000000000-0001-000001-deadbeef';
356
- fs.writeFileSync(path.join(inbox, `${id}.json`), JSON.stringify({
357
- v: 1, id, from: 'session-alice', to: 'session-bob', kind: 'note',
358
- body: 'foreign', ts: Date.now(), ttl: 60000, host: 'other-host',
359
- }));
360
- const polled = s.bob.poll({ since: 0 });
361
- assert.equal(polled.messages.length, 0);
362
- assert.equal(polled.foreignHost, 1);
363
- assert.equal(fs.existsSync(path.join(inbox, `${id}.json`)), false);
364
- } finally {
365
- s.cleanup();
366
- }
367
- });
368
-
369
- // 13. A corrupt file is quarantined and does not block later messages.
370
- await test('corrupt message isolation', async () => {
371
- const s = setup('corrupt');
372
- try {
373
- const inbox = path.join(s.root, 'inbox', 'session-bob');
374
- fs.mkdirSync(inbox, { recursive: true });
375
- fs.writeFileSync(path.join(inbox, 'm-bad.json'), '{ this is not json');
376
- const first = s.bob.poll({ since: 0 });
377
- assert.equal(first.messages.length, 0);
378
- assert.equal(first.corrupt, 1);
379
- assert.equal(fs.existsSync(path.join(inbox, 'm-bad.json')), false);
380
- s.alice.send({ to: 'session-bob', body: 'after chaos' });
381
- const second = s.bob.poll({ since: 0 });
382
- assert.equal(second.messages.length, 1);
383
- assert.equal(second.messages[0].body, 'after chaos');
384
- } finally {
385
- s.cleanup();
386
- }
387
- });
388
-
389
- // 14. Input limits: body size, kind, ttl, dedupKey, empty target.
390
- await test('input validation limits', async () => {
391
- const s = setup('limits');
392
- try {
393
- await assert.rejects(
394
- async () => s.alice.send({ to: 'session-bob', body: 'x'.repeat(DEFAULT_MAX_BODY_CHARS + 1) }),
395
- (error) => error.code === 'E_BODY_TOO_LARGE',
396
- );
397
- await assert.rejects(
398
- async () => s.alice.send({ to: 'session-bob', body: 'x', kind: 'evil' }),
399
- (error) => error.code === 'E_BAD_KIND',
400
- );
401
- await assert.rejects(
402
- async () => s.alice.send({ to: 'session-bob', body: 'x', ttl: 0 }),
403
- (error) => error.code === 'E_BAD_TTL',
404
- );
405
- await assert.rejects(
406
- async () => s.alice.send({ to: 'session-bob', body: 'x', ttl: MAX_TTL_MS + 1 }),
407
- (error) => error.code === 'E_BAD_TTL',
408
- );
409
- await assert.rejects(
410
- async () => s.alice.send({ to: 'session-bob', body: 'x', dedupKey: '' }),
411
- (error) => error.code === 'E_BAD_DEDUP_KEY',
412
- );
413
- await assert.rejects(
414
- async () => s.alice.send({ to: '', body: 'x' }),
415
- (error) => error.code === 'E_BAD_TARGET',
416
- );
417
- } finally {
418
- s.cleanup();
419
- }
420
- });
421
-
422
- // 15. Single-writer lease: held, released, and stale takeover.
423
- await test('writer lease uniqueness and stale takeover', async () => {
424
- const s = setup('lease');
425
- try {
426
- const first = s.alice.claimWriter();
427
- assert.equal(first.ok, true);
428
- const blocked = s.bob.claimWriter();
429
- assert.equal(blocked.ok, false);
430
- assert.equal(blocked.holder, 'session-alice');
431
- assert.equal(s.alice.writerStatus().held, true);
432
- assert.equal(s.alice.releaseWriter({ nonce: first.nonce, token: first.token }).released, true);
433
- const second = s.bob.claimWriter();
434
- assert.equal(second.ok, true);
435
- assert.equal(s.bob.writerStatus().holder, 'session-bob');
436
- s.bob.releaseWriter({ nonce: second.nonce, token: second.token });
437
- fs.writeFileSync(path.join(s.root, 'writer.lock'), JSON.stringify({
438
- schemaVersion: 1,
439
- holder: 'session-carol',
440
- pid: 999999,
441
- host: HOST,
442
- acquiredAt: '2000-01-01T00:00:00.000Z',
443
- expiresAt: '2000-01-01T00:01:00.000Z',
444
- ttlMs: 60000,
445
- }));
446
- const takeover = s.alice.claimWriter();
447
- assert.equal(takeover.ok, true);
448
- assert.equal(takeover.holder, 'session-alice');
449
- } finally {
450
- s.cleanup();
451
- }
452
- });
453
-
454
- // 16. ack gate: only `turn/end` with reason.kind === 'completed'.
455
- await test('t6a ack gate accepts only completed turns', async () => {
456
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/end', data: { reason: { kind: 'completed' } } }), true);
457
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/end', data: { reason: { kind: 'error' } } }), false);
458
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/end', data: { reason: { kind: 'aborted' } } }), false);
459
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/end', data: {} }), false);
460
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/end' }), false);
461
- assert.equal(shouldAckOnTurnEnd({ type: 'turn/start', data: { reason: { kind: 'completed' } } }), false);
462
- assert.equal(shouldAckOnTurnEnd(null), false);
463
- });
464
-
465
- // 17. Error/aborted turns must not consume the message; completed does.
466
- await test('t6b failed turn leaves message pending and redeliverable', async () => {
467
- const s = setup('t6b');
468
- try {
469
- const sent = s.alice.send({ to: 'session-bob', body: 'retry me', kind: 'task' });
470
- const record = { pendingAckIds: [sent.id] };
471
- const finishTurn = (event) => {
472
- if (!shouldAckOnTurnEnd(event)) return;
473
- for (const id of record.pendingAckIds) s.bob.ack(id);
474
- record.pendingAckIds = [];
475
- };
476
- finishTurn({ type: 'turn/end', data: { reason: { kind: 'error' } } });
477
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 1);
478
- finishTurn({ type: 'turn/end', data: { reason: { kind: 'aborted' } } });
479
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 1);
480
- assert.deepEqual(record.pendingAckIds, [sent.id]);
481
- finishTurn({ type: 'turn/end', data: { reason: { kind: 'completed' } } });
482
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 0);
483
- } finally {
484
- s.cleanup();
485
- }
486
- });
487
-
488
- // 18. ack already:true must remove a leftover inbox copy (half-ack repair).
489
- await test('t2c ack already branch deletes the inbox copy', async () => {
490
- const s = setup('t2c');
491
- try {
492
- const sent = s.alice.send({ to: 'session-bob', body: 'half acked', dedupKey: 'half-1' });
493
- const inbox = path.join(s.root, 'inbox', 'session-bob');
494
- const acked = path.join(inbox, 'acked');
495
- fs.mkdirSync(acked, { recursive: true });
496
- fs.copyFileSync(path.join(inbox, `${sent.id}.json`), path.join(acked, `${sent.id}.json`));
497
- const result = s.bob.ack(sent.id);
498
- assert.equal(result.ok, true);
499
- assert.equal(result.already, true);
500
- assert.equal(fs.existsSync(path.join(inbox, `${sent.id}.json`)), false);
501
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 0);
502
- } finally {
503
- s.cleanup();
504
- }
505
- });
506
-
507
- // 19. Dedup after ack: the acked copy is a tombstone, not a new delivery.
508
- await test('t2f ack-then-retry with the same dedupKey does not resurrect the id', async () => {
509
- const s = setup('t2f');
510
- try {
511
- const first = s.alice.send({ to: 'session-bob', body: 'job payload', kind: 'task', dedupKey: 'job-42' });
512
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 1);
513
- assert.equal(s.bob.ack(first.id).already, false);
514
- const retry = s.alice.send({ to: 'session-bob', body: 'job payload', kind: 'task', dedupKey: 'job-42' });
515
- assert.equal(retry.id, first.id);
516
- assert.equal(retry.results[0].deduped, true);
517
- assert.equal(retry.results[0].acked, true);
518
- assert.equal(fs.existsSync(path.join(s.root, 'inbox', 'session-bob', `${first.id}.json`)), false);
519
- assert.equal(s.bob.poll({ since: 0 }).messages.length, 0);
520
- assert.equal(s.bob.ack(first.id).already, true);
521
- assert.equal(fs.existsSync(path.join(s.root, 'inbox', 'session-bob', `${first.id}.json`)), false);
522
- await assert.rejects(
523
- async () => s.alice.send({ to: 'session-bob', body: 'different body', kind: 'task', dedupKey: 'job-42' }),
524
- (error) => error.code === 'E_DEDUP_CONFLICT',
525
- );
526
- } finally {
527
- s.cleanup();
528
- }
529
- });
530
-
531
- // 20. Sender-side clock validation: ts must be positive.
532
- await test('t4f send rejects non-positive timestamps', async () => {
533
- const s = setup('t4f');
534
- try {
535
- for (const value of [0, -1, -1_700_000_000_000]) {
536
- s.clock.value = value;
537
- await assert.rejects(
538
- async () => s.alice.send({ to: 'session-bob', body: 'poison' }),
539
- (error) => error.code === 'E_BAD_CLOCK',
540
- );
541
- }
542
- assert.equal(fs.existsSync(path.join(s.root, 'inbox', 'session-bob')), false);
543
- s.clock.value = 1;
544
- const sent = s.alice.send({ to: 'session-bob', body: 'epoch-ish' });
545
- assert.equal(sent.ok, true);
546
- const polled = s.bob.poll({ since: 0 });
547
- assert.equal(polled.messages.length, 1);
548
- assert.equal(polled.messages[0].ts, 1);
549
- } finally {
550
- s.cleanup();
551
- }
552
- });
553
-
554
- // 21. Envelope id must equal its file name (otherwise ack can never find it).
555
- await test('t3e envelope id / file name mismatch is quarantined', async () => {
556
- const s = setup('t3e');
557
- try {
558
- const dir = path.join(s.root, 'inbox', 'session-bob');
559
- fs.mkdirSync(dir, { recursive: true });
560
- const fileId = 'm-000000000-0001-000001-abcdef';
561
- const envelopeId = 'm-000000000-0002-000001-fedcba';
562
- const forged = {
563
- v: 1,
564
- id: envelopeId,
565
- from: 'session-alice',
566
- to: 'session-bob',
567
- kind: 'note',
568
- body: 'forged id',
569
- ts: s.clock.value,
570
- ttl: 60000,
571
- host: HOST,
572
- };
573
- fs.writeFileSync(path.join(dir, `${fileId}.json`), JSON.stringify(forged));
574
- const first = s.bob.poll({ since: 0 });
575
- assert.equal(first.messages.length, 0);
576
- assert.equal(first.corrupt, 1);
577
- assert.equal(fs.existsSync(path.join(dir, `${fileId}.json`)), false);
578
- const quarantined = fs.readdirSync(path.join(dir, 'dead'));
579
- assert.equal(quarantined.length, 1);
580
- assert.match(quarantined[0], /id-mismatch/);
581
- assert.equal(s.alice.send({ to: 'session-bob', body: 'real one' }).ok, true);
582
- assert.equal(s.bob.poll({ since: 0 }).messages[0].body, 'real one');
583
- } finally {
584
- s.cleanup();
585
- }
586
- });
587
-
588
- // 22. Fresh heartbeat must not mask a dead pid.
589
- await test('t5b fresh heartbeat with dead pid is still dead', async () => {
590
- const s = setup('t5b');
591
- try {
592
- writeLiveRow(s.memoryRoot, 'session-ghost', {
593
- pid: 999999, heartbeatAgeMs: 0, ttlMs: 60000, state: 'running',
594
- });
595
- const peer = s.alice.peerState('session-ghost');
596
- assert.equal(peer.state, 'dead');
597
- assert.equal(peer.reason, 'pid');
598
- await assert.rejects(
599
- async () => s.alice.send({ to: 'session-ghost', body: 'anyone there?' }),
600
- (error) => error.code === 'E_PEER_DEAD',
601
- );
602
- const allowed = s.alice.send({ to: 'session-ghost', body: 'stored anyway', allowDead: true });
603
- assert.equal(allowed.ok, true);
604
- assert.equal(s.make('session-ghost').poll({ since: 0 }).messages.length, 1);
605
- } finally {
606
- s.cleanup();
607
- }
608
- });
609
-
610
- // 23. Same-holder re-claim renews in place, but only with the acquisition nonce.
611
- await test('t8a same-holder re-claim requires the nonce and refreshes in place', async () => {
612
- const s = setup('t8a');
613
- try {
614
- s.clock.value = 1_700_000_000_000;
615
- const first = s.alice.claimWriter({ ttlMs: 60_000 });
616
- assert.equal(first.ok, true);
617
- const before = Date.parse(first.expiresAt);
618
- s.clock.value += 30_000;
619
- const wrong = s.alice.claimWriter({ ttlMs: 60_000, nonce: '0'.repeat(32) });
620
- assert.equal(wrong.ok, false);
621
- assert.equal(wrong.reason, 'auth');
622
- assert.equal(wrong.code, 'E_LEASE_AUTH');
623
- // P08: knowing the session id is not ownership. A re-claim without the
624
- // acquisition nonce must not extend the lease or hand the nonce back.
625
- const missing = s.alice.claimWriter({ ttlMs: 60_000 });
626
- assert.equal(missing.ok, false);
627
- assert.equal(missing.reason, 'auth');
628
- assert.equal(missing.code, 'E_NONCE_REQUIRED');
629
- const again = s.alice.claimWriter({ ttlMs: 60_000, nonce: first.nonce });
630
- assert.equal(again.ok, true);
631
- assert.equal(again.renewed, true);
632
- assert.equal(again.token, first.token);
633
- assert.equal(again.nonce, first.nonce);
634
- assert.ok(Date.parse(again.expiresAt) > before);
635
- s.clock.value += 10_000;
636
- const renewed = s.alice.renewWriter({
637
- nonce: first.nonce, token: first.token, ttlMs: 60_000,
638
- });
639
- assert.equal(renewed.ok, true);
640
- assert.equal(renewed.renewed, true);
641
- assert.ok(Date.parse(renewed.expiresAt) > Date.parse(again.expiresAt));
642
- } finally {
643
- s.cleanup();
644
- }
645
- });
646
-
647
- // 24. renewWriter requires the acquisition nonce and refuses expired leases.
648
- await test('t8b renewWriter authenticates and refuses expired leases', async () => {
649
- const s = setup('t8b');
650
- try {
651
- s.clock.value = 1_700_000_000_000;
652
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
653
- s.clock.value += 10_000;
654
- const bad = s.alice.renewWriter({ nonce: '0'.repeat(32), token: lease.token });
655
- assert.equal(bad.ok, false);
656
- assert.equal(bad.reason, 'bad-nonce');
657
- const renewed = s.alice.renewWriter({ nonce: lease.nonce, token: lease.token });
658
- assert.equal(renewed.ok, true);
659
- s.clock.value += 120_000;
660
- const expired = s.alice.renewWriter({ nonce: lease.nonce, token: lease.token });
661
- assert.equal(expired.ok, false);
662
- assert.equal(expired.reason, 'expired');
663
- assert.equal(expired.code, 'E_LEASE_LOST');
664
- } finally {
665
- s.cleanup();
666
- }
667
- });
668
-
669
- // 25. releaseWriter requires the per-acquisition nonce; session id is not enough.
670
- await test('t8c releaseWriter rejects cross-session and nonce-less release', async () => {
671
- const s = setup('t8c');
672
- try {
673
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
674
- assert.equal(lease.ok, true);
675
- const unbound = createBus({
676
- root: s.root, host: HOST, memoryRoot: s.memoryRoot, now: () => s.clock.value,
677
- });
678
- const cross = unbound.releaseWriter({ holder: 'session-alice' });
679
- assert.equal(cross.released, false);
680
- assert.equal(cross.reason, 'bad-nonce');
681
- await assert.rejects(
682
- async () => s.bob.releaseWriter({ holder: 'session-alice', nonce: lease.nonce }),
683
- (error) => error.code === 'E_FROM_MISMATCH',
684
- );
685
- assert.equal(s.alice.releaseWriter().reason, 'bad-nonce');
686
- assert.equal(s.alice.releaseWriter({ nonce: 'f'.repeat(32) }).reason, 'bad-nonce');
687
- assert.equal(
688
- s.alice.releaseWriter({ nonce: lease.nonce, token: lease.token + 1 }).reason,
689
- 'stale-token',
690
- );
691
- assert.equal(s.alice.writerStatus().held, true);
692
- const released = s.alice.releaseWriter({ nonce: lease.nonce, token: lease.token });
693
- assert.equal(released.released, true);
694
- assert.equal(s.alice.writerStatus().held, false);
695
- } finally {
696
- s.cleanup();
697
- }
698
- });
699
-
700
- // 26. Fencing: an expired holder cannot overwrite the new holder's value.
701
- await test('t8d stale holder fencedWrite fails (lost update prevented)', async () => {
702
- const s = setup('t8d');
703
- try {
704
- s.clock.value = 1_700_000_000_000;
705
- const file = path.join(s.root, 'handover.json');
706
- fs.writeFileSync(file, JSON.stringify({ writer: 'seed' }));
707
- const alice = s.alice.claimWriter({ ttlMs: 1000 });
708
- assert.equal(alice.ok, true);
709
- assert.equal(s.alice.fencedWrite({
710
- file, token: alice.token, nonce: alice.nonce, value: { writer: 'alice' },
711
- }).ok, true);
712
- s.clock.value += 5000;
713
- const bob = s.bob.claimWriter({ ttlMs: 60_000 });
714
- assert.equal(bob.ok, true);
715
- assert.ok(bob.token > alice.token);
716
- await assert.rejects(
717
- async () => s.alice.fencedWrite({
718
- file, token: alice.token, nonce: alice.nonce, value: { writer: 'alice-stale' },
719
- }),
720
- (error) => error.code === 'E_LEASE_LOST',
721
- );
722
- assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { writer: 'alice' });
723
- assert.equal(s.bob.fencedWrite({
724
- file, token: bob.token, nonce: bob.nonce, value: { writer: 'bob' },
725
- }).ok, true);
726
- assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { writer: 'bob' });
727
- assert.equal(s.alice.releaseWriter({ nonce: alice.nonce, token: alice.token }).reason, 'not-holder');
728
- assert.equal(s.alice.renewWriter({ nonce: alice.nonce, token: alice.token }).reason, 'not-holder');
729
- } finally {
730
- s.cleanup();
731
- }
732
- });
733
-
734
- // 27. Tokens are strictly monotonic across acquisitions (fencing invariant).
735
- await test('t8e fencing tokens increase across stale takeovers', async () => {
736
- const s = setup('t8e');
737
- try {
738
- s.clock.value = 1_700_000_000_000;
739
- const a = s.alice.claimWriter({ ttlMs: 1000 });
740
- s.clock.value += 5000;
741
- const b = s.bob.claimWriter({ ttlMs: 1000 });
742
- s.clock.value += 5000;
743
- const c = s.alice.claimWriter({ ttlMs: 1000 });
744
- assert.equal(a.ok && b.ok && c.ok, true);
745
- assert.ok(b.token > a.token);
746
- assert.ok(c.token > b.token);
747
- assert.notEqual(a.nonce, c.nonce);
748
- } finally {
749
- s.cleanup();
750
- }
751
- });
752
-
753
- // 28. The writer TTL is one number in ms across code and both docs.
754
- await test('t9 busWriterTtlMs is 1800000 ms in code, DESIGN and WIRING', async () => {
755
- assert.equal(DEFAULT_WRITER_TTL_MS, 1_800_000);
756
- const design = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS.md'), 'utf8');
757
- const wiring = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS-WIRING.md'), 'utf8');
758
- assert.match(design, /`busWriterTtlMs`[^\n]*1800000/);
759
- assert.match(wiring, /`busWriterTtlMs`[^\n]*1800000/);
760
- assert.match(design, /1800000 ms/);
761
- assert.match(wiring, /1800000 ms/);
762
- assert.doesNotMatch(design, /`busWriterTtlMs`[^\n]*120000/);
763
- assert.doesNotMatch(wiring, /`busWriterTtlMs`[^\n]*120000/);
764
- });
765
-
766
- // 29. The wake API claim: DESIGN/WIRING must say the host API exists and that
767
- // v0's mailbox-only choice is a tradeoff, not a physical limit.
768
- await test('t7 wake API documented: live followup, cold resume, mailbox is a choice', async () => {
769
- const design = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS.md'), 'utf8');
770
- const wiring = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS-WIRING.md'), 'utf8');
771
- assert.match(design, /ctx\.agents\.get\(id\)\?\.followup\(msg\)/);
772
- assert.match(design, /api-session-controller/);
773
- assert.match(design, /取舍/);
774
- assert.doesNotMatch(design, /没有验证过的宿主 API/);
775
- assert.match(wiring, /### 5\.4/);
776
- assert.match(wiring, /followup\(msg\)/);
777
- assert.match(wiring, /cold/);
778
- });
779
-
780
- // ---------------------------------------------------------------------------
781
- // Red-team round 2 regression tests (P08 / P12 / P13 / P14 and the mutation
782
- // blind spots M13 / M14).
783
- // ---------------------------------------------------------------------------
784
-
785
- function expectCode(fn, code) {
786
- let thrown;
787
- try {
788
- fn();
789
- } catch (error) {
790
- thrown = error;
791
- }
792
- assert.ok(thrown, `expected ${code}, but nothing was thrown`);
793
- assert.equal(thrown.code, code, `expected ${code}, got ${thrown.code}: ${thrown.message}`);
794
- }
795
-
796
- async function waitForFile(file, timeoutMs = 10_000) {
797
- const deadline = Date.now() + timeoutMs;
798
- while (Date.now() < deadline) {
799
- if (fs.existsSync(file)) return;
800
- await new Promise((resolve) => setTimeout(resolve, 20));
801
- }
802
- throw new Error(`timed out waiting for ${file}`);
803
- }
804
-
805
- // 30. fencedWrite must check the fencing token. Deleting the token comparison
806
- // (mutation M13) has to turn this test red.
807
- await test('t10a fencedWrite rejects a missing or stale token without writing', async () => {
808
- const s = setup('t10a');
809
- try {
810
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
811
- assert.equal(lease.ok, true);
812
- fs.mkdirSync(path.join(s.root, 'fenced'), { recursive: true });
813
- expectCode(
814
- () => s.alice.fencedWrite({
815
- file: 'fenced/stale.json', token: lease.token + 1, nonce: lease.nonce, value: { v: 1 },
816
- }),
817
- 'E_LEASE_LOST',
818
- );
819
- expectCode(
820
- () => s.alice.fencedWrite({ file: 'fenced/missing-token.json', nonce: lease.nonce, value: { v: 1 } }),
821
- 'E_LEASE_LOST',
822
- );
823
- assert.equal(fs.existsSync(path.join(s.root, 'fenced', 'stale.json')), false);
824
- assert.equal(fs.existsSync(path.join(s.root, 'fenced', 'missing-token.json')), false);
825
- const ok = s.alice.fencedWrite({
826
- file: 'fenced/ok.json', token: lease.token, nonce: lease.nonce, value: { v: 2 },
827
- });
828
- assert.equal(ok.ok, true);
829
- assert.equal(JSON.parse(fs.readFileSync(path.join(s.root, 'fenced', 'ok.json'), 'utf8')).v, 2);
830
- } finally {
831
- s.cleanup();
832
- }
833
- });
834
-
835
- // 31. fencedWrite must check the acquisition nonce. Deleting the nonce
836
- // comparison (mutation M14) has to turn this test red.
837
- await test('t10b fencedWrite rejects a wrong or missing nonce without writing', async () => {
838
- const s = setup('t10b');
839
- try {
840
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
841
- fs.mkdirSync(path.join(s.root, 'fenced'), { recursive: true });
842
- expectCode(
843
- () => s.alice.fencedWrite({
844
- file: 'fenced/wrong-nonce.json', token: lease.token, nonce: '0'.repeat(32), value: { v: 1 },
845
- }),
846
- 'E_LEASE_AUTH',
847
- );
848
- expectCode(
849
- () => s.alice.fencedWrite({ file: 'fenced/no-nonce.json', token: lease.token, value: { v: 1 } }),
850
- 'E_LEASE_AUTH',
851
- );
852
- assert.equal(fs.existsSync(path.join(s.root, 'fenced', 'wrong-nonce.json')), false);
853
- assert.equal(fs.existsSync(path.join(s.root, 'fenced', 'no-nonce.json')), false);
854
- const ok = s.alice.fencedWrite({
855
- file: 'fenced/ok.json', token: lease.token, nonce: lease.nonce, value: { v: 3 },
856
- });
857
- assert.equal(ok.ok, true);
858
- } finally {
859
- s.cleanup();
860
- }
861
- });
862
-
863
- // 32. P08: same-holder re-claim without the acquisition nonce is rejected and
864
- // leaves the lock byte-identical (no silent renewal, no nonce leak).
865
- await test('t10c same-holder re-claim without nonce is E_NONCE_REQUIRED and changes nothing', async () => {
866
- const s = setup('t10c');
867
- try {
868
- s.clock.value = 1_700_000_000_000;
869
- const first = s.alice.claimWriter({ ttlMs: 60_000 });
870
- assert.equal(first.ok, true);
871
- const lockPath = path.join(s.root, 'writer.lock');
872
- const before = fs.readFileSync(lockPath, 'utf8');
873
- s.clock.value += 30_000;
874
- const missing = s.alice.claimWriter({ ttlMs: 600_000 });
875
- assert.equal(missing.ok, false);
876
- assert.equal(missing.reason, 'auth');
877
- assert.equal(missing.code, 'E_NONCE_REQUIRED');
878
- assert.equal(fs.readFileSync(lockPath, 'utf8'), before);
879
- const wrong = s.alice.claimWriter({ ttlMs: 600_000, nonce: 'f'.repeat(32) });
880
- assert.equal(wrong.ok, false);
881
- assert.equal(wrong.code, 'E_LEASE_AUTH');
882
- assert.equal(fs.readFileSync(lockPath, 'utf8'), before);
883
- // A different holder is a plain "held", not an authentication error.
884
- const other = s.bob.claimWriter({ ttlMs: 600_000 });
885
- assert.equal(other.ok, false);
886
- assert.equal(other.reason, 'held');
887
- } finally {
888
- s.cleanup();
889
- }
890
- });
891
-
892
- // 33. P13: a takeover that lands between JSON.stringify and rename must abort
893
- // the write with E_LEASE_LOST and leave the previous file intact.
894
- await test('t10d fencedWrite re-checks the lease after serialization (TOCTOU)', async () => {
895
- const s = setup('t10d');
896
- try {
897
- s.clock.value = 1_700_000_000_000;
898
- const lease = s.alice.claimWriter({ ttlMs: 5_000 });
899
- assert.equal(lease.ok, true);
900
- fs.mkdirSync(path.join(s.root, 'race'), { recursive: true });
901
- const target = path.join(s.root, 'race', 'target.json');
902
- const first = s.alice.fencedWrite({
903
- file: 'race/target.json', token: lease.token, nonce: lease.nonce, value: { writer: 'alice' },
904
- });
905
- assert.equal(first.ok, true);
906
-
907
- let takeover = null;
908
- const hooked = s.make('session-alice', {
909
- hooks: {
910
- afterSerialize: () => {
911
- // The old holder is still inside JSON.stringify/rename when the new
912
- // holder takes over. Deterministic: no timers, no sleeps.
913
- s.clock.value += 10_000;
914
- takeover = s.bob.claimWriter({ ttlMs: 60_000 });
915
- },
916
- },
917
- });
918
- expectCode(
919
- () => hooked.fencedWrite({
920
- file: 'race/target.json', token: lease.token, nonce: lease.nonce, value: { writer: 'alice-late' },
921
- }),
922
- 'E_LEASE_LOST',
923
- );
924
- assert.equal(takeover.ok, true);
925
- assert.ok(takeover.token > lease.token);
926
- assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).writer, 'alice');
927
- const bobWrite = s.bob.fencedWrite({
928
- file: 'race/target.json', token: takeover.token, nonce: takeover.nonce, value: { writer: 'bob' },
929
- });
930
- assert.equal(bobWrite.ok, true);
931
- assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).writer, 'bob');
932
- } finally {
933
- s.cleanup();
934
- }
935
- });
936
-
937
- // 34. Writers serialize through a cross-process commit lock; the lock file is
938
- // visible inside the critical section and removed afterwards.
939
- await test('t10e commit lock serializes the lease path and reclaims stale locks', async () => {
940
- const s = setup('t10e');
941
- try {
942
- const lockPath = path.join(s.root, 'writer.commit.lock');
943
- let seen = null;
944
- const hooked = s.make('session-alice', {
945
- hooks: {
946
- afterSerialize: () => {
947
- const exists = fs.existsSync(lockPath);
948
- seen = { exists, body: exists ? JSON.parse(fs.readFileSync(lockPath, 'utf8')) : null };
949
- },
950
- },
951
- });
952
- const lease = hooked.claimWriter({ ttlMs: 60_000 });
953
- assert.equal(lease.ok, true);
954
- fs.mkdirSync(path.join(s.root, 'lock'), { recursive: true });
955
- hooked.fencedWrite({
956
- file: 'lock/probe.json', token: lease.token, nonce: lease.nonce, value: { ok: true },
957
- });
958
- assert.equal(seen.exists, true);
959
- assert.equal(seen.body.pid, process.pid);
960
- assert.equal(String(seen.body.host).toLowerCase(), HOST);
961
- assert.equal(fs.existsSync(lockPath), false);
962
-
963
- const released = hooked.releaseWriter({ nonce: lease.nonce, token: lease.token });
964
- assert.equal(released.ok, true);
965
- // A commit lock left behind by a dead pid must not wedge the bus.
966
- fs.writeFileSync(lockPath, JSON.stringify({ pid: 999999, host: HOST, at: new Date().toISOString() }));
967
- const claimed = s.bob.claimWriter({ ttlMs: 60_000 });
968
- assert.equal(claimed.ok, true);
969
- assert.equal(fs.existsSync(lockPath), false);
970
- } finally {
971
- s.cleanup();
972
- }
973
- });
974
-
975
- // 35. P12: a junction inside the root that points outside must be refused by
976
- // the real-path check, with no file landing outside the jail.
977
- await test('t10f fencedWrite refuses a junction that escapes the root', async () => {
978
- const s = setup('t10f');
979
- const outside = fs.mkdtempSync(path.join(TMP_BASE, 'junction-outside-'));
980
- const link = path.join(s.root, 'escape');
981
- try {
982
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
983
- fs.symlinkSync(outside, link, 'junction');
984
- assert.equal(fs.lstatSync(link).isSymbolicLink(), true);
985
- expectCode(
986
- () => s.alice.fencedWrite({
987
- file: 'escape/pwn.json', token: lease.token, nonce: lease.nonce, value: { escaped: true },
988
- }),
989
- 'E_PATH_OUTSIDE_ROOT',
990
- );
991
- assert.equal(fs.existsSync(path.join(outside, 'pwn.json')), false);
992
- } finally {
993
- try { fs.unlinkSync(link); } catch { /* already gone */ }
994
- fs.rmSync(outside, { recursive: true, force: true });
995
- s.cleanup();
996
- }
997
- });
998
-
999
- // 36. P12 continued: resolve the nearest existing ancestor for paths that do
1000
- // not exist yet, and refuse symlinked file targets too.
1001
- await test('t10g jail resolves the nearest existing ancestor and rejects file symlinks', async () => {
1002
- const s = setup('t10g');
1003
- const outside = fs.mkdtempSync(path.join(TMP_BASE, 'ancestor-outside-'));
1004
- const link = path.join(s.root, 'escape-j');
1005
- try {
1006
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
1007
- fs.symlinkSync(outside, link, 'junction');
1008
- expectCode(
1009
- () => s.alice.fencedWrite({
1010
- file: 'escape-j/missing/deep/pwn.json', token: lease.token, nonce: lease.nonce, value: { escaped: true },
1011
- }),
1012
- 'E_PATH_OUTSIDE_ROOT',
1013
- );
1014
- assert.equal(fs.existsSync(path.join(outside, 'missing')), false);
1015
-
1016
- // A legal path with missing intermediate directories passes the jail and
1017
- // fails at the filesystem write (E_WRITE), never as a path escape.
1018
- expectCode(
1019
- () => s.alice.fencedWrite({
1020
- file: 'nested/a/b.json', token: lease.token, nonce: lease.nonce, value: { ok: true },
1021
- }),
1022
- 'E_WRITE',
1023
- );
1024
- fs.mkdirSync(path.join(s.root, 'nested', 'a'), { recursive: true });
1025
- const written = s.alice.fencedWrite({
1026
- file: 'nested/a/b.json', token: lease.token, nonce: lease.nonce, value: { ok: true },
1027
- });
1028
- assert.equal(written.ok, true);
1029
-
1030
- const secret = path.join(outside, 'secret.json');
1031
- fs.writeFileSync(secret, '{"outside":true}');
1032
- const fileLink = path.join(s.root, 'file-link.json');
1033
- fs.symlinkSync(secret, fileLink, 'file');
1034
- expectCode(
1035
- () => s.alice.fencedWrite({
1036
- file: 'file-link.json', token: lease.token, nonce: lease.nonce, value: { overwritten: true },
1037
- }),
1038
- 'E_PATH_OUTSIDE_ROOT',
1039
- );
1040
- assert.equal(fs.readFileSync(secret, 'utf8'), '{"outside":true}');
1041
- } finally {
1042
- try { fs.unlinkSync(link); } catch { /* already gone */ }
1043
- try { fs.unlinkSync(path.join(s.root, 'file-link.json')); } catch { /* already gone */ }
1044
- fs.rmSync(outside, { recursive: true, force: true });
1045
- s.cleanup();
1046
- }
1047
- });
1048
-
1049
- // 37. P14 send side: a clock far in the future is refused before any file is
1050
- // written; a small skew inside maxClockSkewMs is still accepted.
1051
- await test('t10h send rejects a timestamp ahead of the wall clock', async () => {
1052
- const s = setup('t10h');
1053
- try {
1054
- const wall = Date.now();
1055
- s.clock.value = wall + 3_600_000;
1056
- expectCode(
1057
- () => s.alice.send({ to: 'session-bob', body: 'time traveller', kind: 'note' }),
1058
- 'E_BAD_CLOCK',
1059
- );
1060
- const inbox = path.join(s.root, 'inbox', 'session-bob');
1061
- assert.equal(
1062
- fs.existsSync(inbox) ? fs.readdirSync(inbox).filter((name) => name.endsWith('.json')).length : 0,
1063
- 0,
1064
- );
1065
- s.clock.value = wall + 4 * 60_000;
1066
- const accepted = s.alice.send({ to: 'session-bob', body: 'slightly ahead', kind: 'note' });
1067
- assert.equal(accepted.ok, true);
1068
- // maxClockSkewMs=0 makes even a one-second lead a hard error.
1069
- const strict = s.make('session-alice', { maxClockSkewMs: 0 });
1070
- s.clock.value = Date.now() + 1_000;
1071
- expectCode(
1072
- () => strict.send({ to: 'session-bob', body: 'strict clock', kind: 'note' }),
1073
- 'E_BAD_CLOCK',
1074
- );
1075
- } finally {
1076
- s.cleanup();
1077
- }
1078
- });
1079
-
1080
- // 38. P14 receive side: a future-dated message is quarantined instead of
1081
- // becoming the cursor; a poisoned `since` cannot hide pending traffic.
1082
- await test('t10i poll quarantines future messages and never poisons the cursor', async () => {
1083
- const s = setup('t10i');
1084
- try {
1085
- s.clock.value = 1_700_000_000_000;
1086
- const normal = s.alice.send({ to: 'session-bob', body: 'normal', kind: 'note' });
1087
- const inbox = path.join(s.root, 'inbox', 'session-bob');
1088
- const futureTs = s.clock.value + 3_600_000;
1089
- const time = Math.floor(futureTs).toString(36).padStart(9, '0');
1090
- const futureId = `m-${time}-aa-future-abcdef`;
1091
- fs.writeFileSync(path.join(inbox, `${futureId}.json`), JSON.stringify({
1092
- v: 1,
1093
- id: futureId,
1094
- from: 'session-alice',
1095
- to: 'session-bob',
1096
- kind: 'note',
1097
- body: 'from the future',
1098
- ts: futureTs,
1099
- ttl: 86_400_000,
1100
- host: HOST,
1101
- }, null, 2));
1102
-
1103
- const first = s.bob.poll({ since: 0 });
1104
- assert.equal(first.futureTs, 1);
1105
- assert.equal(first.messages.length, 1);
1106
- assert.equal(first.messages[0].id, normal.id);
1107
- assert.equal(first.cursor, normal.id);
1108
- assert.equal(
1109
- fs.existsSync(path.join(inbox, 'dead', `${futureId}.future-ts.json`)),
1110
- true,
1111
- );
1112
-
1113
- // A future `since` (e.g. persisted by an older build) must replay pending
1114
- // messages rather than hide them.
1115
- const poisoned = s.bob.poll({ since: futureId });
1116
- assert.equal(poisoned.futureCursor, true);
1117
- assert.equal(poisoned.messages.length, 1);
1118
- assert.equal(poisoned.messages[0].id, normal.id);
1119
-
1120
- // And normal traffic after the poison is still delivered.
1121
- s.clock.value += 1_000;
1122
- const later = s.alice.send({ to: 'session-bob', body: 'after', kind: 'note' });
1123
- const next = s.bob.poll({ since: first.cursor });
1124
- assert.equal(next.messages.length, 1);
1125
- assert.equal(next.messages[0].id, later.id);
1126
- } finally {
1127
- s.cleanup();
1128
- }
1129
- });
1130
-
1131
- // 39. P13 across processes: a second writer blocks on the commit lock instead
1132
- // of racing the rename.
1133
- await test('t11a commit lock serializes a second process claiming concurrently', async () => {
1134
- const s = setup('t11a');
1135
- try {
1136
- const marker = path.join(s.memoryRoot, 'serialized.marker');
1137
- const target = path.join(s.root, 'serialize', 'target.json');
1138
- fs.mkdirSync(path.join(s.root, 'serialize'), { recursive: true });
1139
- const child = runNode(`
1140
- import fs from 'node:fs';
1141
- const { createBus } = await import(process.env.BUS_URL);
1142
- const bus = createBus({
1143
- root: process.env.BUS_ROOT,
1144
- sessionId: 'session-alice',
1145
- host: 'testhost',
1146
- hooks: {
1147
- afterSerialize: () => {
1148
- fs.writeFileSync(process.env.BUS_MARKER, 'serialized');
1149
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
1150
- },
1151
- },
1152
- });
1153
- const lease = bus.claimWriter({ ttlMs: 30_000 });
1154
- if (!lease.ok) throw new Error('child claim failed');
1155
- bus.fencedWrite({
1156
- file: 'serialize/target.json',
1157
- token: lease.token,
1158
- nonce: lease.nonce,
1159
- value: { writer: 'alice' },
1160
- });
1161
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
1162
- `, { BUS_URL, BUS_ROOT: s.root, BUS_MARKER: marker });
1163
-
1164
- await waitForFile(marker);
1165
- const startedAt = Date.now();
1166
- const held = s.bob.claimWriter({ ttlMs: 30_000 });
1167
- const waited = Date.now() - startedAt;
1168
- assert.equal(held.ok, false);
1169
- assert.equal(held.reason, 'held');
1170
- assert.ok(waited >= 800, `claimWriter should wait behind the commit lock (waited ${waited}ms)`);
1171
-
1172
- await child;
1173
- const takeover = s.bob.claimWriter({ ttlMs: 30_000 });
1174
- assert.equal(takeover.ok, true);
1175
- const finalWrite = s.bob.fencedWrite({
1176
- file: 'serialize/target.json', token: takeover.token, nonce: takeover.nonce, value: { writer: 'bob' },
1177
- });
1178
- assert.equal(finalWrite.ok, true);
1179
- assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).writer, 'bob');
1180
- } finally {
1181
- s.cleanup();
1182
- }
1183
- });
1184
-
1185
- // 40. WIRING must merge pendingAckIds on retry (not overwrite them) and must
1186
- // use the same sessionKeyFor(agent) key as the card builder.
1187
- await test('t12a WIRING retries pendingAckIds by merge and uses sessionKeyFor(agent)', async () => {
1188
- assert.equal(typeof mergePendingAckIds, 'function');
1189
- assert.deepEqual(mergePendingAckIds(['m-a'], ['m-b', 'm-a']), ['m-a', 'm-b']);
1190
- assert.deepEqual(mergePendingAckIds(undefined, ['m-a']), ['m-a']);
1191
- assert.deepEqual(mergePendingAckIds(['m-a'], [null, '', 7, 'm-a']), ['m-a']);
1192
- const wiring = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS-WIRING.md'), 'utf8');
1193
- assert.match(wiring, /mergePendingAckIds/);
1194
- assert.match(wiring, /pendingAckIds/);
1195
- assert.match(wiring, /stateFor\(sessionKeyFor\(agent\)\)/);
1196
- assert.doesNotMatch(wiring, /stateFor\(session\?\.id \?\? 'unknown'\)/);
1197
- });
1198
-
1199
- // 41. DESIGN documents the new invariants the tests above enforce.
1200
- await test('t12b DESIGN documents commit lock, realpath jail, nonce and clock skew', async () => {
1201
- const design = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS.md'), 'utf8');
1202
- assert.match(design, /writer\.commit\.lock/);
1203
- assert.match(design, /E_NONCE_REQUIRED/);
1204
- assert.match(design, /maxClockSkewMs/);
1205
- assert.match(design, /future-ts/);
1206
- assert.match(design, /realpath/);
1207
- assert.match(design, /E_PATH_OUTSIDE_ROOT/);
1208
- });
1209
-
1210
- // ---------------------------------------------------------------------------
1211
- // Red-team round 3 regression tests (F1 owner-checked lock release, F2 root /
1212
- // directory targets, F3 skew-cursor message loss).
1213
- // ---------------------------------------------------------------------------
1214
-
1215
- // 42. F1: releasing the commit lock must be owner-checked. A holder that lost
1216
- // the lock to a stale reclaim must leave the new owner's record alone.
1217
- await test('t13a commit lock release checks the owner before unlinking', async () => {
1218
- const s = setup('t13a');
1219
- try {
1220
- const lockPath = path.join(s.root, 'writer.commit.lock');
1221
- const owners = [];
1222
- const hooked = s.make('session-alice', {
1223
- hooks: {
1224
- afterSerialize: () => {
1225
- const record = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
1226
- owners.push(record.ownerId);
1227
- if (owners.length === 1) {
1228
- // Simulate a reclaim: another acquisition unlinked our record and
1229
- // installed its own ownerId while we were still inside.
1230
- fs.writeFileSync(lockPath, JSON.stringify({
1231
- pid: process.pid,
1232
- host: HOST,
1233
- at: new Date().toISOString(),
1234
- ownerId: 'foreign-owner',
1235
- }));
1236
- }
1237
- },
1238
- },
1239
- });
1240
- const lease = hooked.claimWriter({ ttlMs: 60_000 });
1241
- assert.equal(lease.ok, true);
1242
- fs.mkdirSync(path.join(s.root, 'fenced'), { recursive: true });
1243
-
1244
- const first = hooked.fencedWrite({
1245
- file: 'fenced/owner.json', token: lease.token, nonce: lease.nonce, value: { v: 1 },
1246
- });
1247
- assert.equal(first.ok, true);
1248
- assert.equal(typeof owners[0], 'string');
1249
- assert.ok(owners[0].length >= 32, 'the lock record must carry a random ownerId');
1250
- assert.equal(fs.existsSync(lockPath), true, 'finally unlinked a lock owned by another acquisition');
1251
- assert.equal(JSON.parse(fs.readFileSync(lockPath, 'utf8')).ownerId, 'foreign-owner');
1252
-
1253
- // Clear the foreign record as the normal stale path would, then verify that
1254
- // an owner-matched release still removes its own lock.
1255
- fs.unlinkSync(lockPath);
1256
- const second = hooked.fencedWrite({
1257
- file: 'fenced/owner.json', token: lease.token, nonce: lease.nonce, value: { v: 2 },
1258
- });
1259
- assert.equal(second.ok, true);
1260
- assert.notEqual(owners[0], owners[1], 'each acquisition must get a fresh ownerId');
1261
- assert.equal(fs.existsSync(lockPath), false, 'an owner must still remove its own lock');
1262
- } finally {
1263
- s.cleanup();
1264
- }
1265
- });
1266
-
1267
- // 43. F1 across processes (P09): B's lock record looks stale (dead pid), C
1268
- // legitimately reclaims and holds the lock; when B exits it must not delete
1269
- // C's lock. D watches the lock file while C is inside its critical section.
1270
- await test('t13b three processes: a stale-steal victim must not delete the new lock', async () => {
1271
- const s = setup('t13b');
1272
- try {
1273
- const lockPath = path.join(s.root, 'writer.commit.lock');
1274
- const markerB = path.join(s.memoryRoot, 'b.marker');
1275
- const markerC = path.join(s.memoryRoot, 'c.marker');
1276
- const outB = path.join(s.memoryRoot, 'b.out.json');
1277
- const outD = path.join(s.memoryRoot, 'd.out.json');
1278
- fs.mkdirSync(path.join(s.root, 'owner'), { recursive: true });
1279
-
1280
- const bCode = `
1281
- import fs from 'node:fs';
1282
- const { createBus } = await import(process.env.BUS_URL);
1283
- const waitFor = (file, ms) => {
1284
- const end = Date.now() + ms;
1285
- while (Date.now() < end) {
1286
- if (fs.existsSync(file)) return true;
1287
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
1288
- }
1289
- return false;
1290
- };
1291
- const bus = createBus({
1292
- root: process.env.BUS_ROOT,
1293
- sessionId: 'session-alice',
1294
- host: 'testhost',
1295
- hooks: {
1296
- afterSerialize: () => {
1297
- // Make our own record look stale (dead pid), so C reclaims it legally.
1298
- fs.writeFileSync(process.env.LOCK_PATH, JSON.stringify({
1299
- pid: 999999, host: 'testhost', at: new Date().toISOString(),
1300
- }));
1301
- fs.writeFileSync(process.env.MARKER_B, 'b');
1302
- if (!waitFor(process.env.MARKER_C, 15000)) throw new Error('B never saw C holding the lock');
1303
- },
1304
- },
1305
- });
1306
- const lease = bus.claimWriter({ ttlMs: 60000 });
1307
- let code = 'none';
1308
- try {
1309
- bus.fencedWrite({ file: 'owner/target.json', token: lease.token, nonce: lease.nonce, value: { writer: 'alice' } });
1310
- } catch (error) {
1311
- code = error.code || String(error.message);
1312
- }
1313
- fs.writeFileSync(process.env.OUT_B, JSON.stringify({ claimed: lease.ok, code }));
1314
- `;
1315
- const dCode = `
1316
- import fs from 'node:fs';
1317
- const waitFor = (file, ms) => {
1318
- const end = Date.now() + ms;
1319
- while (Date.now() < end) {
1320
- if (fs.existsSync(file)) return true;
1321
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
1322
- }
1323
- return false;
1324
- };
1325
- const saw = waitFor(process.env.MARKER_C, 15000);
1326
- const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1327
- nap(250);
1328
- const early = fs.existsSync(process.env.LOCK_PATH);
1329
- nap(250);
1330
- const late = fs.existsSync(process.env.LOCK_PATH);
1331
- fs.writeFileSync(process.env.OUT_D, JSON.stringify({ saw, early, late }));
1332
- `;
1333
-
1334
- s.clock.value = Date.now();
1335
- const bChild = runNode(bCode, {
1336
- BUS_URL, BUS_ROOT: s.root, LOCK_PATH: lockPath, MARKER_B: markerB, MARKER_C: markerC, OUT_B: outB,
1337
- });
1338
- await waitForFile(markerB);
1339
-
1340
- // Parent is C: its clock is 31 minutes ahead, so alice's 60s lease has
1341
- // expired and B's lock record (dead pid) is stale. C takes both.
1342
- s.clock.value = Date.now() + 31 * 60_000;
1343
- const bobLease = s.bob.claimWriter({ ttlMs: 120_000 });
1344
- assert.equal(bobLease.ok, true);
1345
-
1346
- const dChild = runNode(dCode, { MARKER_C: markerC, LOCK_PATH: lockPath, OUT_D: outD });
1347
- const hookedBob = s.make('session-bob', {
1348
- hooks: {
1349
- afterSerialize: () => {
1350
- fs.writeFileSync(markerC, 'c');
1351
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
1352
- },
1353
- },
1354
- });
1355
- const write = hookedBob.fencedWrite({
1356
- file: 'owner/target.json', token: bobLease.token, nonce: bobLease.nonce, value: { writer: 'bob' },
1357
- });
1358
- assert.equal(write.ok, true);
1359
-
1360
- await bChild;
1361
- await dChild;
1362
- const bResult = JSON.parse(fs.readFileSync(outB, 'utf8'));
1363
- assert.equal(bResult.claimed, true);
1364
- assert.equal(bResult.code, 'E_LEASE_LOST');
1365
- const dResult = JSON.parse(fs.readFileSync(outD, 'utf8'));
1366
- assert.equal(dResult.saw, true);
1367
- assert.equal(dResult.early, true, 'C lock disappeared while C was still inside the critical section');
1368
- assert.equal(dResult.late, true, 'C lock disappeared while C was still inside the critical section');
1369
- assert.equal(fs.existsSync(lockPath), false, 'C must release its own lock when it exits');
1370
- assert.equal(
1371
- JSON.parse(fs.readFileSync(path.join(s.root, 'owner', 'target.json'), 'utf8')).writer,
1372
- 'bob',
1373
- );
1374
- } finally {
1375
- s.cleanup();
1376
- }
1377
- });
1378
-
1379
- // 44. F2 (P17): `file: "."` resolves to the root itself, which the lexical and
1380
- // realpath jails both accept, but dirname(root) is outside the jail. Reject
1381
- // the root before any temp file can be created next to it.
1382
- await test('t13c fencedWrite refuses the bus root as a target', async () => {
1383
- const s = setup('t13c');
1384
- try {
1385
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
1386
- assert.equal(lease.ok, true);
1387
- const parent = path.dirname(s.root);
1388
- const before = fs.readdirSync(parent).sort();
1389
- for (const file of ['.', './', s.root]) {
1390
- expectCode(
1391
- () => s.alice.fencedWrite({ file, token: lease.token, nonce: lease.nonce, value: { v: 1 } }),
1392
- 'E_BAD_PATH',
1393
- );
1394
- }
1395
- assert.deepEqual(fs.readdirSync(parent).sort(), before, 'a temp file escaped next to the root');
1396
- assert.equal(fs.existsSync(path.join(s.root, 'writer.commit.lock')), false);
1397
- assert.equal(fs.lstatSync(s.root).isDirectory(), true);
1398
- assert.equal(fs.readdirSync(s.root).includes('undefined.json'), false);
1399
- } finally {
1400
- s.cleanup();
1401
- }
1402
- });
1403
-
1404
- // 45. F2: an existing directory is not a file target either; refusing it must
1405
- // not disturb normal writes, and the commit lock must still be released.
1406
- await test('t13d fencedWrite refuses a directory target', async () => {
1407
- const s = setup('t13d');
1408
- try {
1409
- const lease = s.alice.claimWriter({ ttlMs: 60_000 });
1410
- fs.mkdirSync(path.join(s.root, 'dir-target'), { recursive: true });
1411
- for (const file of ['dir-target', 'dir-target/']) {
1412
- expectCode(
1413
- () => s.alice.fencedWrite({ file, token: lease.token, nonce: lease.nonce, value: { v: 1 } }),
1414
- 'E_BAD_PATH',
1415
- );
1416
- }
1417
- assert.deepEqual(fs.readdirSync(path.join(s.root, 'dir-target')), []);
1418
- const ok = s.alice.fencedWrite({
1419
- file: 'dir-target/file.json', token: lease.token, nonce: lease.nonce, value: { v: 2 },
1420
- });
1421
- assert.equal(ok.ok, true);
1422
- assert.equal(
1423
- JSON.parse(fs.readFileSync(path.join(s.root, 'dir-target', 'file.json'), 'utf8')).v,
1424
- 2,
1425
- );
1426
- assert.equal(fs.existsSync(path.join(s.root, 'writer.commit.lock')), false);
1427
- } finally {
1428
- s.cleanup();
1429
- }
1430
- });
1431
-
1432
- // 46. F3 (P24): a message 299s ahead of the receiver's clock is inside the 5
1433
- // minute window, so it is delivered. It must not become the cursor: a later
1434
- // message with a smaller ts has to stay visible.
1435
- await test('t13e poll clamps the cursor for ahead-of-clock messages', async () => {
1436
- const s = setup('t13e');
1437
- try {
1438
- const W = 1_700_000_000_000;
1439
- s.clock.value = W + 299_000;
1440
- const ahead = s.alice.send({ to: 'session-bob', body: 'ahead', kind: 'note' });
1441
- s.clock.value = W;
1442
- const first = s.bob.poll({ since: 0 });
1443
- assert.equal(first.futureCursor, false);
1444
- assert.equal(first.futureTs, 0);
1445
- assert.deepEqual(first.messages.map((m) => m.id), [ahead.id]);
1446
- assert.equal(first.cursor, W, 'the cursor must be clamped to now, not the ahead-of-clock id');
1447
-
1448
- s.clock.value = W + 1_000;
1449
- const normal = s.alice.send({ to: 'session-bob', body: 'normal', kind: 'note' });
1450
- const second = s.bob.poll({ since: first.cursor });
1451
- const ids = second.messages.map((m) => m.id);
1452
- assert.ok(ids.includes(normal.id), 'a later message with a smaller ts was silently hidden');
1453
- assert.ok(ids.includes(ahead.id), 'the ahead-of-clock message must be replayed at least once');
1454
- assert.equal(second.cursor, W + 1_000);
1455
-
1456
- s.bob.ack(ahead.id);
1457
- s.bob.ack(normal.id);
1458
- const drained = s.bob.poll({ since: second.cursor });
1459
- assert.deepEqual(drained.messages, []);
1460
- } finally {
1461
- s.cleanup();
1462
- }
1463
- });
1464
-
1465
- // 47. F3 the other way: on-time traffic keeps id cursors (no over-clamping), and
1466
- // a message exactly at the 300000ms boundary is still delivered and clamped.
1467
- await test('t13f poll keeps id cursors for on-time traffic', async () => {
1468
- const s = setup('t13f');
1469
- try {
1470
- const W = 1_700_000_000_000;
1471
- s.clock.value = W;
1472
- const m1 = s.alice.send({ to: 'session-bob', body: 'one', kind: 'note' });
1473
- const first = s.bob.poll({ since: 0 });
1474
- assert.deepEqual(first.messages.map((m) => m.id), [m1.id]);
1475
- assert.equal(first.cursor, m1.id, 'on-time traffic must keep the id cursor');
1476
-
1477
- s.clock.value = W + 2_000;
1478
- const m2 = s.alice.send({ to: 'session-bob', body: 'two', kind: 'note' });
1479
- const second = s.bob.poll({ since: first.cursor });
1480
- assert.deepEqual(second.messages.map((m) => m.id), [m2.id]);
1481
- assert.equal(second.cursor, m2.id);
1482
-
1483
- s.clock.value = W + 300_000;
1484
- const edge = s.alice.send({ to: 'session-bob', body: 'edge', kind: 'note' });
1485
- s.clock.value = W;
1486
- const third = s.bob.poll({ since: m2.id });
1487
- assert.equal(third.futureTs, 0, 'exactly 300000ms ahead is inside the window');
1488
- assert.deepEqual(third.messages.map((m) => m.id), [edge.id]);
1489
- assert.equal(third.cursor, W);
1490
- } finally {
1491
- s.cleanup();
1492
- }
1493
- });
1494
-
1495
- // 48. F1/F2/F3 must be documented where the wire-up reads them.
1496
- await test('t13g DESIGN/WIRING document owner, cursor and skew-window semantics', async () => {
1497
- const design = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS.md'), 'utf8');
1498
- const wiring = fs.readFileSync(path.join(REPO, 'docs', 'PRESENCE-BUS-WIRING.md'), 'utf8');
1499
- assert.match(design, /ownerId/);
1500
- assert.match(design, /锁只能由持有者释放/);
1501
- assert.match(design, /at-least-once/);
1502
- assert.match(design, /E_BAD_PATH/);
1503
- assert.match(design, /50 例/);
1504
- assert.match(wiring, /### 3\.5/);
1505
- assert.match(wiring, /5 分钟/);
1506
- assert.match(wiring, /at-least-once/);
1507
- assert.match(wiring, /cursor/);
1508
- });
1509
-
1510
- // 49. m3: an unwritable bus root is a permission error, not a malformed path.
1511
- await test('t14 ensureRoot reports EPERM for a read-only bus root', async () => {
1512
- const parent = path.join(TMP_BASE, 'bus-t14');
1513
- const root = path.join(parent, 'denied-root');
1514
- fs.mkdirSync(parent, { recursive: true });
1515
- const original = fs.mkdirSync;
1516
- fs.mkdirSync = function patchedMkdir(target, options) {
1517
- if (String(target).toLowerCase().endsWith('denied-root')) {
1518
- const error = new Error("EPERM: operation not permitted, mkdir '" + target + "'");
1519
- error.code = 'EPERM';
1520
- throw error;
1521
- }
1522
- return original.call(fs, target, options);
1523
- };
1524
- try {
1525
- assert.throws(
1526
- () => createBus({ root }),
1527
- (error) => error.code === 'EPERM' && /not writable/.test(error.message),
1528
- );
1529
- assert.equal(fs.existsSync(root), false, 'a failed root must not leave a directory behind');
1530
- } finally {
1531
- fs.mkdirSync = original;
1532
- fs.rmSync(parent, { recursive: true, force: true });
1533
- }
1534
- });
1535
-
1536
- // 50. m3: a root that denies a subdirectory is EPERM too, not a malformed path.
1537
- await test('t15 ensureDirInside reports EPERM when the root denies inbox creation', async () => {
1538
- const parent = path.join(TMP_BASE, 'bus-t15');
1539
- const root = path.join(parent, 'root');
1540
- fs.mkdirSync(root, { recursive: true });
1541
- const bus = createBus({ root, sessionId: 'session-alice' });
1542
- const original = fs.mkdirSync;
1543
- fs.mkdirSync = function patchedMkdir(target, options) {
1544
- if (String(target).replaceAll('\\', '/').includes('/inbox/')) {
1545
- const error = new Error("EPERM: operation not permitted, mkdir '" + target + "'");
1546
- error.code = 'EPERM';
1547
- throw error;
1548
- }
1549
- return original.call(fs, target, options);
1550
- };
1551
- try {
1552
- assert.throws(
1553
- () => bus.send({ to: 'session-bob', body: 'x' }),
1554
- (error) => error.code === 'EPERM' && /cannot create/.test(error.message),
1555
- );
1556
- } finally {
1557
- fs.mkdirSync = original;
1558
- fs.rmSync(parent, { recursive: true, force: true });
1559
- }
1560
- });
1561
-
1562
- const passed = results.filter((item) => item.ok).length;
1563
- const failed = results.length - passed;
1564
- console.log(`# tests ${results.length}`);
1565
- console.log(`# pass ${passed}`);
1566
- console.log(`# fail ${failed}`);
1567
- if (failed > 0) {
1568
- for (const item of results.filter((entry) => !entry.ok)) {
1569
- console.log(`FAILED: ${item.name}: ${item.error?.message ?? item.error}`);
1570
- }
1571
- process.exitCode = 1;
1572
- }