brane 0.0.2 → 0.3.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 (338) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +81 -23
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.npmignore +0 -2
  336. package/.travis.yml +0 -5
  337. package/index.js +0 -8
  338. package/index.js~ +0 -12
@@ -0,0 +1,193 @@
1
+ /**
2
+ * What an agent's token may reach (src/daemon/agent-scope.ts): a
3
+ * default-deny list of routes, and only from this machine.
4
+ */
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+ import { AGENT_LOOPBACK_REFUSAL, AGENT_REVIVE_REFUSAL, AGENT_ROUTES, AGENT_SCOPE_REFUSAL, agentMayUse, agentRefusal, agentReviveRefusal, } from "../daemon/agent-scope.js";
11
+ import { markUnixConnection } from "../daemon/local-socket.js";
12
+ import { agentRequestRefusal, reviveNarrative } from "../daemon/server.js";
13
+ import { createTestDb } from "./helpers.js";
14
+ describe("agentMayUse", () => {
15
+ it("opens reading brane and the session lifecycle agents drive", () => {
16
+ for (const [method, path] of [
17
+ ["GET", "/api/status"],
18
+ ["GET", "/api/narratives"],
19
+ ["GET", "/api/narratives/recent"],
20
+ ["GET", "/api/narratives/claude-abcd1234"],
21
+ ["GET", "/api/narratives/claude-abcd1234/events"],
22
+ ["GET", "/api/narratives/claude-abcd1234/tool_uses/k1"],
23
+ ["GET", "/api/payloads/claude-abcd1234/42_0.png"],
24
+ ["GET", "/api/payloads/claude-abcd1234/nested/file.txt"],
25
+ ["GET", "/api/files/find"],
26
+ ["POST", "/api/search"],
27
+ ["POST", "/api/spawn"],
28
+ ["POST", "/api/revive/claude-abcd1234"],
29
+ ["GET", "/ws/mcp"],
30
+ ]) {
31
+ assert.equal(agentMayUse(method, path), true, `${method} ${path}`);
32
+ }
33
+ });
34
+ it("closes the app's channels, signals, the operator's housekeeping and gated acts", () => {
35
+ for (const [method, path] of [
36
+ ["GET", "/ws/terminal/claude-abcd1234"],
37
+ ["GET", "/ws/control"],
38
+ ["POST", "/api/narratives/claude-abcd1234/signal"],
39
+ ["GET", "/api/tokens"],
40
+ ["POST", "/api/tokens"],
41
+ ["POST", "/api/tokens/t1/revoke"],
42
+ ["POST", "/api/token/rotate"],
43
+ ["POST", "/api/presence/cancel"],
44
+ ["POST", "/api/folds"],
45
+ ["PATCH", "/api/folds/archive"],
46
+ ["PATCH", "/api/narratives/claude-abcd1234/archive"],
47
+ ["POST", "/api/absorb"],
48
+ ["GET", "/api/absorb/survey"],
49
+ ["POST", "/api/cost/backfill"],
50
+ ["POST", "/api/cost/reprice"],
51
+ ["POST", "/api/run"],
52
+ ]) {
53
+ assert.equal(agentMayUse(method, path), false, `${method} ${path}`);
54
+ }
55
+ });
56
+ it("denies a route it does not know: default-deny", () => {
57
+ assert.equal(agentMayUse("GET", "/api/something-new"), false);
58
+ assert.equal(agentMayUse("POST", "/api/narratives"), false, "the method is part of the route");
59
+ });
60
+ it("does not let a parameter match an empty or extra segment", () => {
61
+ assert.equal(agentMayUse("GET", "/api/narratives/"), false);
62
+ assert.equal(agentMayUse("POST", "/api/revive/"), false);
63
+ assert.equal(agentMayUse("GET", "/api/narratives/x/events/extra"), false);
64
+ assert.equal(agentMayUse("GET", "/api/payloads/x/"), false);
65
+ assert.equal(agentMayUse("GET", "/api/status/"), false);
66
+ });
67
+ it("matches the method case-insensitively", () => {
68
+ assert.equal(agentMayUse("get", "/api/status"), true);
69
+ });
70
+ });
71
+ describe("agentRefusal", () => {
72
+ const listed = { method: "GET", path: "/api/narratives" };
73
+ it("lets an agent's token in from this machine only", () => {
74
+ assert.equal(agentRefusal({ ...listed, loopback: true }), null);
75
+ assert.equal(agentRefusal({ ...listed, loopback: false }), AGENT_LOOPBACK_REFUSAL);
76
+ });
77
+ it("refuses off this machine before looking at the route", () => {
78
+ assert.equal(agentRefusal({ method: "GET", path: "/ws/control", loopback: false }), AGENT_LOOPBACK_REFUSAL);
79
+ });
80
+ it("names a utility session, not a CLI command, as the way to do a closed act", () => {
81
+ assert.equal(agentRefusal({ method: "POST", path: "/api/absorb", loopback: true }), AGENT_SCOPE_REFUSAL);
82
+ assert.match(AGENT_SCOPE_REFUSAL, /utility session/);
83
+ assert.doesNotMatch(AGENT_SCOPE_REFUSAL, /brane run/);
84
+ });
85
+ });
86
+ describe("agentReviveRefusal", () => {
87
+ it("refuses a utility session, whose env carries no agent token", () => {
88
+ assert.equal(agentReviveRefusal("utility"), AGENT_REVIVE_REFUSAL);
89
+ });
90
+ it("leaves reviving and prompting agents open", () => {
91
+ for (const role of ["worker", "twin", "keeper", "absorbed", null, undefined]) {
92
+ assert.equal(agentReviveRefusal(role), null, String(role));
93
+ }
94
+ });
95
+ });
96
+ describe("reviveNarrative, asked by an agent", () => {
97
+ // Refused before anything reaches tmux, so no tmux is needed here.
98
+ function dbWith(role) {
99
+ const db = createTestDb();
100
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES ('zsh-0001', 'brane-test', 'zsh', ?, 'active', '/tmp', ?)").run(role, Date.now());
101
+ return db;
102
+ }
103
+ it("refuses a utility session, with or without a prompt", () => {
104
+ assert.throws(() => reviveNarrative(dbWith("utility"), "zsh-0001", { prompt: "brane absorb", byAgent: true }), {
105
+ message: AGENT_REVIVE_REFUSAL,
106
+ });
107
+ assert.throws(() => reviveNarrative(dbWith("utility"), "zsh-0001", { byAgent: true }), { message: AGENT_REVIVE_REFUSAL });
108
+ });
109
+ });
110
+ /** A request as the daemon sees one: over `daemon.sock`, or from an address. */
111
+ function request(method, from) {
112
+ const socket = from === "socket" ? {} : { remoteAddress: from };
113
+ if (from === "socket")
114
+ markUnixConnection(socket);
115
+ return { method, headers: { host: "localhost" }, socket };
116
+ }
117
+ describe("agentRequestRefusal, as the HTTP handler and the upgrade ask it", () => {
118
+ const agent = { kind: "agent" };
119
+ it("lets an agent's token in over the socket and loopback, on an open route", () => {
120
+ assert.equal(agentRequestRefusal(agent, request("GET", "socket"), "/api/narratives"), null);
121
+ assert.equal(agentRequestRefusal(agent, request("GET", "127.0.0.1"), "/api/narratives"), null);
122
+ assert.equal(agentRequestRefusal(agent, request("GET", "::ffff:127.0.0.1"), "/ws/mcp"), null);
123
+ });
124
+ it("refuses an agent's token from a tailnet address", () => {
125
+ assert.equal(agentRequestRefusal(agent, request("GET", "100.64.1.2"), "/api/narratives"), AGENT_LOOPBACK_REFUSAL);
126
+ });
127
+ it("refuses an agent's token on a closed route, even from the socket", () => {
128
+ assert.equal(agentRequestRefusal(agent, request("GET", "socket"), "/ws/terminal/claude-abcd1234"), AGENT_SCOPE_REFUSAL);
129
+ assert.equal(agentRequestRefusal(agent, request("POST", "socket"), "/api/narratives/x/signal"), AGENT_SCOPE_REFUSAL);
130
+ });
131
+ it("never refuses a device or this account", () => {
132
+ assert.equal(agentRequestRefusal({ kind: "device" }, request("GET", "100.64.1.2"), "/ws/terminal/x"), null);
133
+ assert.equal(agentRequestRefusal({ kind: "local" }, request("POST", "socket"), "/api/absorb"), null);
134
+ });
135
+ });
136
+ // Every route the daemon serves, read from its `// Route:` and `// WS:`
137
+ // comments, must be decided here: open to agents (and listed in
138
+ // AGENT_ROUTES) or closed. A new route then fails this test until someone
139
+ // writes down which it is, and a renamed one shows up as a stale entry.
140
+ // Reads source, which an installed package does not ship (`brane doctor`).
141
+ const serverSource = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "src", "daemon", "server.ts");
142
+ const CLOSED_TO_AGENTS = new Set([
143
+ "GET /api/absorb/survey",
144
+ "POST /api/absorb",
145
+ "POST /api/cost/backfill",
146
+ "POST /api/cost/reprice",
147
+ "POST /api/folds",
148
+ "PATCH /api/folds/archive",
149
+ "PATCH /api/narratives/:/archive",
150
+ "POST /api/narratives/:/signal",
151
+ "POST /api/presence/cancel",
152
+ "POST /api/run",
153
+ "POST /api/token/rotate",
154
+ "GET /api/tokens",
155
+ "POST /api/tokens",
156
+ "POST /api/tokens/remote",
157
+ "POST /api/tokens/:/revoke",
158
+ "GET /ws/control",
159
+ "GET /ws/terminal/:",
160
+ // Before authentication, with its own check (agent token, loopback only).
161
+ "POST /api/hooks/tool-use",
162
+ ]);
163
+ /** `METHOD /path` with every parameter (`:id`, `*`) spelled `:`. */
164
+ function normalize(route) {
165
+ const [method, path] = route.split(" ");
166
+ return `${method} ${path.split("/").map((s) => (s.startsWith(":") || s === "*" ? ":" : s)).join("/")}`;
167
+ }
168
+ describe("every daemon route is open or closed to agents, on purpose", { skip: existsSync(serverSource) ? false : "src not shipped" }, () => {
169
+ const served = new Set();
170
+ for (const m of readFileSync(serverSource, "utf8").matchAll(/\/\/ (Route|WS): (?:([A-Z]+) )?(\/[^\s?]+)/g)) {
171
+ served.add(normalize(`${m[2] ?? "GET"} ${m[3]}`));
172
+ }
173
+ const open = new Set(AGENT_ROUTES.map(normalize));
174
+ it("finds the route table", () => {
175
+ assert.ok(served.size > 30, `only ${served.size} routes found`);
176
+ });
177
+ it("decides each one", () => {
178
+ const undecided = [...served].filter((r) => !open.has(r) && !CLOSED_TO_AGENTS.has(r));
179
+ assert.deepEqual(undecided, [], "add each to AGENT_ROUTES or to CLOSED_TO_AGENTS");
180
+ });
181
+ it("lists nothing the daemon does not serve", () => {
182
+ assert.deepEqual([...open].filter((r) => !served.has(r)), [], "stale AGENT_ROUTES entries");
183
+ assert.deepEqual([...CLOSED_TO_AGENTS].filter((r) => !served.has(r)), [], "stale CLOSED_TO_AGENTS entries");
184
+ });
185
+ it("asks on both ways in: the HTTP handler and the WebSocket upgrade", () => {
186
+ const calls = readFileSync(serverSource, "utf8").match(/refuseAgent\(auth\.record, req, path\)/g) ?? [];
187
+ assert.equal(calls.length, 2);
188
+ });
189
+ it("never lists a route as both", () => {
190
+ assert.deepEqual([...open].filter((r) => CLOSED_TO_AGENTS.has(r)), []);
191
+ });
192
+ });
193
+ //# sourceMappingURL=agent-scope.test.js.map
@@ -0,0 +1,55 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir, homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { getAgentAdapter } from "../agents.js";
7
+ describe("claudeAdapter.resumeArgs", () => {
8
+ // Regression: previously, resumeArgs fell back to discoverSessionId(foldPath),
9
+ // which scans the fold-wide project dir and returns the most-recently-modified
10
+ // .jsonl. Two narratives sharing a fold could revive each other's sessions.
11
+ it("returns [] when sessionId is missing (does not fall back to fold-wide discovery)", () => {
12
+ const adapter = getAgentAdapter("claude");
13
+ assert.deepEqual(adapter.resumeArgs({ foldPath: "/Users/nobody/nonexistent" }), []);
14
+ assert.deepEqual(adapter.resumeArgs({ sessionId: undefined, foldPath: "/Users/nobody/nonexistent" }), []);
15
+ });
16
+ it("returns [] when sessionId points at a non-existent .jsonl", () => {
17
+ const adapter = getAgentAdapter("claude");
18
+ const args = adapter.resumeArgs({
19
+ sessionId: "00000000-0000-0000-0000-000000000000",
20
+ foldPath: "/Users/nobody/nonexistent",
21
+ });
22
+ assert.deepEqual(args, []);
23
+ });
24
+ // Regression: a plain `--resume` rotates to a fresh session id that the
25
+ // daemon never learns, stranding agent_session_id on the dead predecessor
26
+ // (frozen cost readings; next revive resumes the pre-revive fork point).
27
+ // With a daemon-minted newSessionId, every launch shape must pin it.
28
+ it("pins newSessionId on a fresh start (no resumable session)", () => {
29
+ const adapter = getAgentAdapter("claude");
30
+ assert.deepEqual(adapter.resumeArgs({ foldPath: "/Users/nobody/nonexistent", newSessionId: "new-id" }), ["--session-id", "new-id"]);
31
+ assert.deepEqual(adapter.resumeArgs({
32
+ sessionId: "00000000-0000-0000-0000-000000000000",
33
+ foldPath: "/Users/nobody/nonexistent",
34
+ newSessionId: "new-id",
35
+ }), ["--session-id", "new-id"]);
36
+ });
37
+ it("resumes fork-style with a pinned id whenever newSessionId is supplied", (t) => {
38
+ const adapter = getAgentAdapter("claude");
39
+ const foldPath = mkdtempSync(join(tmpdir(), "brane-agents-test-"));
40
+ const cwdEncoded = foldPath.replace(/\//g, "-");
41
+ const projectDir = join(homedir(), ".claude", "projects", cwdEncoded);
42
+ mkdirSync(projectDir, { recursive: true });
43
+ const sessionId = "11111111-1111-1111-1111-111111111111";
44
+ writeFileSync(join(projectDir, `${sessionId}.jsonl`), "");
45
+ t.after(() => rmSync(projectDir, { recursive: true, force: true }));
46
+ // Pinned resume always forks, live or dead — rotation happens either way,
47
+ // and forking is the shape that lets the daemon own the successor's id.
48
+ assert.deepEqual(adapter.resumeArgs({ sessionId, foldPath, newSessionId: "new-id" }), ["--resume", sessionId, "--fork-session", "--session-id", "new-id"]);
49
+ assert.deepEqual(adapter.resumeArgs({ sessionId, foldPath, forkSession: true, newSessionId: "new-id" }), ["--resume", sessionId, "--fork-session", "--session-id", "new-id"]);
50
+ // Un-pinned shapes are unchanged (no daemon-minted id supplied).
51
+ assert.deepEqual(adapter.resumeArgs({ sessionId, foldPath, forkSession: true }), ["--resume", sessionId, "--fork-session"]);
52
+ assert.deepEqual(adapter.resumeArgs({ sessionId, foldPath }), ["--resume", sessionId]);
53
+ });
54
+ });
55
+ //# sourceMappingURL=agents.test.js.map
@@ -0,0 +1,122 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { DEFAULT_TIMEOUT_MS, JSON_HEADERS, MAX_ATTEMPTS, NO_HEADERS, isRetryableError, isRetryableMethod, requiresPreflight, retryDelayMs, withToken, } from "../shared/api-request.js";
7
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ describe("requiresPreflight", () => {
9
+ test("a bare GET is simple — the case the whole design turns on", () => {
10
+ assert.equal(requiresPreflight("GET", NO_HEADERS), false);
11
+ assert.equal(requiresPreflight("GET"), false);
12
+ });
13
+ test("an Authorization header forces a preflight", () => {
14
+ // This is the regression. It is why the token rides in the query string:
15
+ // on a LAN this header costs a millisecond, at 741ms RTT it cost the app.
16
+ assert.equal(requiresPreflight("GET", { Authorization: "Bearer abc" }), true);
17
+ });
18
+ test("application/json forces a preflight even on a POST", () => {
19
+ assert.equal(requiresPreflight("POST", JSON_HEADERS), true);
20
+ });
21
+ test("safelisted content types do not", () => {
22
+ assert.equal(requiresPreflight("POST", { "Content-Type": "text/plain" }), false);
23
+ assert.equal(requiresPreflight("POST", { "Content-Type": "text/plain;charset=UTF-8" }), false);
24
+ });
25
+ test("PATCH and DELETE always preflight, headers or not", () => {
26
+ assert.equal(requiresPreflight("PATCH", NO_HEADERS), true);
27
+ assert.equal(requiresPreflight("DELETE", NO_HEADERS), true);
28
+ });
29
+ test("header names are matched case-insensitively", () => {
30
+ assert.equal(requiresPreflight("GET", { "content-type": "application/json" }), true);
31
+ assert.equal(requiresPreflight("GET", { "CONTENT-TYPE": "text/plain" }), false);
32
+ });
33
+ });
34
+ describe("withToken", () => {
35
+ test("appends to a bare path", () => {
36
+ assert.equal(withToken("/api/status", "abc"), "/api/status?token=abc");
37
+ });
38
+ test("joins an existing query string with & rather than a second ?", () => {
39
+ assert.equal(withToken("/api/narratives?archived=1", "abc"), "/api/narratives?archived=1&token=abc");
40
+ });
41
+ test("escapes tokens that would otherwise break the query string", () => {
42
+ assert.equal(withToken("/api/status", "a b&c=d"), "/api/status?token=a%20b%26c%3Dd");
43
+ });
44
+ test("stays in front of a fragment, which is never sent to the server", () => {
45
+ assert.equal(withToken("/api/files?path=x#L10", "abc"), "/api/files?path=x&token=abc#L10");
46
+ assert.equal(withToken("/api/status#top", "abc"), "/api/status?token=abc#top");
47
+ });
48
+ });
49
+ describe("retry policy", () => {
50
+ test("replays only what cannot have had an effect", () => {
51
+ assert.equal(isRetryableMethod("GET"), true);
52
+ assert.equal(isRetryableMethod("head"), true);
53
+ // A timed-out spawn may have spawned. Asking twice is worse than failing.
54
+ assert.equal(isRetryableMethod("POST"), false);
55
+ assert.equal(isRetryableMethod("PATCH"), false);
56
+ });
57
+ test("retries transport failures, not answers", () => {
58
+ const abort = new Error("aborted");
59
+ abort.name = "AbortError";
60
+ assert.equal(isRetryableError(abort), true);
61
+ assert.equal(isRetryableError(new TypeError("Failed to fetch")), true);
62
+ // An HTTP error is an answer; the caller decides what it means.
63
+ assert.equal(isRetryableError(new Error("Status 404")), false);
64
+ });
65
+ test("backs off exponentially", () => {
66
+ assert.equal(retryDelayMs(0), 500);
67
+ assert.equal(retryDelayMs(1), 1000);
68
+ assert.equal(retryDelayMs(2), 2000);
69
+ });
70
+ test("a ceiling that clears a satellite link's fixed costs", () => {
71
+ // ~0.75s TCP + ~1.5s TLS + ~10 RTT of slow start is ~10s before a large
72
+ // response can be moving at speed. The old 10s budget was spent before the
73
+ // transfer began.
74
+ assert.ok(DEFAULT_TIMEOUT_MS >= 30_000, "must survive a high-RTT link");
75
+ assert.ok(MAX_ATTEMPTS >= 2, "one attempt is not a retry policy");
76
+ });
77
+ });
78
+ // The three checks below read `expo/lib/api.ts` as text. The tarball ships
79
+ // `expo/dist/` (the built app) but not the source, so from an installed
80
+ // package — `brane doctor` — there is nothing to lint and the whole block
81
+ // skips with a reason rather than failing on ENOENT.
82
+ const apiSourcePath = join(repoRoot, "expo", "lib", "api.ts");
83
+ describe("the app's own request layer obeys the rules", { skip: existsSync(apiSourcePath) ? false : "expo source not shipped" }, () => {
84
+ // Guards against the regression that a browser on a fast network cannot
85
+ // show you: reinstating a header that quietly reintroduces a preflight.
86
+ const api = existsSync(apiSourcePath) ? readFileSync(apiSourcePath, "utf8") : "";
87
+ test("no request sets an Authorization header", () => {
88
+ // Matches an object key, not the word in prose — the module explains
89
+ // itself in comments and should be allowed to name what it avoids.
90
+ assert.equal(/["']?Authorization["']?\s*:/i.test(api), false, "auth belongs in the query string; a header would force a preflight");
91
+ });
92
+ test("every request's headers come from the two shared constants", () => {
93
+ const headerValues = [...api.matchAll(/headers:\s*([A-Za-z_$][\w$]*)/g)].map((m) => m[1]);
94
+ assert.ok(headerValues.length > 0, "expected to find request call sites");
95
+ for (const v of headerValues) {
96
+ assert.ok(v === "NO_HEADERS" || v === "JSON_HEADERS", `unexpected headers value ${v} — use NO_HEADERS or JSON_HEADERS`);
97
+ }
98
+ });
99
+ test("nothing appends its own token — apiUrl already did", () => {
100
+ // Caught in review: `payloadUrl` and `wikiAssetUrl` built their own query
101
+ // strings, so once apiUrl started supplying the token they appended a
102
+ // second one and the URL stopped authenticating. Images silently broke.
103
+ // Matches a token being *put into* a URL or param bag, not the word
104
+ // appearing as an interface field or a function argument.
105
+ const stray = [
106
+ ...api.matchAll(/token:\s*conn\.token/g),
107
+ ...api.matchAll(/token=\$\{/g),
108
+ ].map((m) => m[0]);
109
+ assert.deepEqual(stray, [], "let apiUrl supply the token; a second one breaks auth");
110
+ });
111
+ test("bodied requests are the only ones carrying JSON_HEADERS", () => {
112
+ // JSON_HEADERS on a bodiless request would buy a preflight for nothing.
113
+ const blocks = api.split("fetchWithTimeout(");
114
+ for (const b of blocks.slice(1)) {
115
+ const head = b.slice(0, b.indexOf("});") + 1);
116
+ if (head.includes("JSON_HEADERS")) {
117
+ assert.ok(head.includes("body:"), `JSON_HEADERS without a body:\n${head}`);
118
+ }
119
+ }
120
+ });
121
+ });
122
+ //# sourceMappingURL=api-request.test.js.map
@@ -0,0 +1,54 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { awaitingGlow, isAwaiting } from "../shared/narrative-status.js";
4
+ describe("awaiting glow", () => {
5
+ it("is honoured only while bridged", () => {
6
+ assert.equal(isAwaiting({ mcpConnected: true, awaiting: true }), true);
7
+ assert.equal(isAwaiting({ mcpConnected: true, awaiting: false }), false);
8
+ // A parked call cannot outlive its socket: a stale bit is not a fact.
9
+ assert.equal(isAwaiting({ mcpConnected: false, awaiting: true }), false);
10
+ assert.equal(isAwaiting({}), false);
11
+ });
12
+ it("is inset only, so it takes no layout and never spills", () => {
13
+ for (const layer of awaitingGlow(271).split(/,\s*(?=inset)/)) {
14
+ assert.match(layer, /^inset /);
15
+ }
16
+ });
17
+ it("wears the hue it is given", () => {
18
+ for (const h of [0, 90, 271, 359]) {
19
+ for (const layer of awaitingGlow(h).split(/,\s*(?=inset)/)) {
20
+ assert.match(layer, new RegExp(`oklch\\([\\d.]+ [\\d.]+ ${h} /`));
21
+ }
22
+ }
23
+ });
24
+ });
25
+ describe("awaiting glow layers", () => {
26
+ // The layers centred on the box, i.e. all but the one-sided top band.
27
+ const centred = (h) => awaitingGlow(h).split(/,\s*(?=inset)/).filter((l) => l.startsWith("inset 0 0 "));
28
+ it("are near-opaque at the rim, so the hue survives a dark row", () => {
29
+ const alphas = centred(271).map((l) => Number(/\/ ([\d.]+)\)/.exec(l)[1]));
30
+ assert.ok(alphas[0] >= 0.9, `rim alpha ${alphas[0]}`);
31
+ for (let i = 1; i < alphas.length; i++)
32
+ assert.ok(alphas[i] < alphas[i - 1], `alphas ${alphas}`);
33
+ });
34
+ it("hold lightness and chroma fixed across hues", () => {
35
+ const lc = (h) => awaitingGlow(h).replace(new RegExp(` ${h} /`, "g"), " H /");
36
+ for (const h of [0, 90, 180, 271])
37
+ assert.equal(lc(h), lc(0));
38
+ });
39
+ it("are all blurred: no solid edge line", () => {
40
+ for (const layer of awaitingGlow(271).split(/,\s*(?=inset)/)) {
41
+ const [, , blur] = layer.replace(/^inset /, "").split(" ");
42
+ assert.notEqual(blur, "0", layer);
43
+ }
44
+ });
45
+ it("thicken the top edge by one pixel, and only the top", () => {
46
+ const [top] = awaitingGlow(271).split(/,\s*(?=inset)/).filter((l) => !l.startsWith("inset 0 0 "));
47
+ const [, y, blur, spread] = top.replace(/^inset /, "").split(" ").map(parseFloat);
48
+ // The hole, grown by -spread on every side, sits y + spread below the
49
+ // top, and -spread past the sides, out of the blur's reach.
50
+ assert.equal(y + spread, 1);
51
+ assert.ok(-spread >= blur, top);
52
+ });
53
+ });
54
+ //# sourceMappingURL=awaiting-glow.test.js.map
@@ -0,0 +1,122 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { AwaitingTracker } from "../daemon/awaiting.js";
5
+ import { cancelMcpCall, handleMcpNarrate, startMcpListen } from "../daemon/server.js";
6
+ import { appendEvent } from "../db/events.js";
7
+ // AGOG: a narrative parked in a blocking listen, or a waiting narrate, is
8
+ // waiting for direction. The tracker turns parks and resolutions into one
9
+ // boolean per narrative and reports only its edges.
10
+ const SELF = "claude-aaaaaaaa";
11
+ const BRANE = "brane-home";
12
+ function tracker() {
13
+ const edges = [];
14
+ const tr = new AwaitingTracker((nid, a) => edges.push([nid, a]));
15
+ return { tr, edges };
16
+ }
17
+ describe("AwaitingTracker", () => {
18
+ it("is awaiting while parked, and a delivery ends it at once", () => {
19
+ const { tr, edges } = tracker();
20
+ tr.parked(SELF);
21
+ assert.equal(tr.isAwaiting(SELF), true);
22
+ tr.resolved(SELF, "delivered");
23
+ assert.equal(tr.isAwaiting(SELF), false);
24
+ assert.deepEqual(edges, [[SELF, true], [SELF, false]]);
25
+ });
26
+ it("ends at once on a timeout too, and the re-park glows again", () => {
27
+ // The flicker is the agent's turn between the two calls, shown as it is.
28
+ const { tr, edges } = tracker();
29
+ tr.parked(SELF);
30
+ tr.resolved(SELF, "timeout");
31
+ assert.equal(tr.isAwaiting(SELF), false);
32
+ tr.parked(SELF);
33
+ assert.deepEqual(edges, [[SELF, true], [SELF, false], [SELF, true]]);
34
+ });
35
+ it("ends at once on a cancel: the harness stopped waiting", () => {
36
+ const { tr, edges } = tracker();
37
+ tr.parked(SELF);
38
+ tr.resolved(SELF, "cancelled");
39
+ assert.equal(tr.isAwaiting(SELF), false);
40
+ assert.deepEqual(edges, [[SELF, true], [SELF, false]]);
41
+ });
42
+ it("stays awaiting while any bridge of the narrative is still parked", () => {
43
+ const { tr, edges } = tracker();
44
+ tr.parked(SELF);
45
+ tr.parked(SELF);
46
+ tr.resolved(SELF, "closed");
47
+ assert.equal(tr.isAwaiting(SELF), true);
48
+ tr.resolved(SELF, "closed");
49
+ assert.equal(tr.isAwaiting(SELF), false);
50
+ assert.deepEqual(edges, [[SELF, true], [SELF, false]]);
51
+ });
52
+ it("ignores a resolution it never saw park", () => {
53
+ const { tr, edges } = tracker();
54
+ tr.resolved(SELF, "delivered");
55
+ assert.deepEqual(edges, []);
56
+ });
57
+ });
58
+ // The wiring: startMcpListen is what parks, so drive it as the dispatcher does.
59
+ function db() {
60
+ const d = createTestDb();
61
+ d.prepare("INSERT INTO narratives (id, brane_id, created_at, status) VALUES (?, ?, 0, 'active')").run(SELF, BRANE);
62
+ return d;
63
+ }
64
+ function client(tr) {
65
+ const ws = { readyState: 1, send: () => { } };
66
+ return { ws: ws, narrativeId: SELF, agentType: "claude", pendingListens: new Map(), awaiting: tr };
67
+ }
68
+ describe("AGOG wiring", () => {
69
+ it("a blocking listen parks as awaiting; the signal that wakes it ends it", () => {
70
+ const d = db();
71
+ const { tr, edges } = tracker();
72
+ const c = client(tr);
73
+ const waiters = new Map();
74
+ startMcpListen(c, d, "1", { after: 0 }, waiters, () => { }, BRANE);
75
+ assert.deepEqual(edges, [[SELF, true]]);
76
+ appendEvent(d, SELF, "human_signal", { content: "go", source: "web" });
77
+ for (const wake of waiters.get(SELF) ?? [])
78
+ wake();
79
+ assert.deepEqual(edges, [[SELF, true], [SELF, false]]);
80
+ });
81
+ it("a waiting narrate parks as awaiting too", () => {
82
+ const d = db();
83
+ const { tr, edges } = tracker();
84
+ const c = client(tr);
85
+ const result = handleMcpNarrate({ say: "done", wait: true }, d, SELF, () => { });
86
+ const { park, ...extra } = result;
87
+ startMcpListen(c, d, "1", { after: park.after }, new Map(), () => { }, BRANE, { omit: park.omit, extra });
88
+ assert.deepEqual(edges, [[SELF, true]]);
89
+ });
90
+ it("neither a non-blocking read nor a listen answered at once counts as parking", () => {
91
+ const d = db();
92
+ const { tr, edges } = tracker();
93
+ const c = client(tr);
94
+ startMcpListen(c, d, "1", { after: 0, wait: false }, new Map(), () => { }, BRANE);
95
+ appendEvent(d, SELF, "human_signal", { content: "hi", source: "web" });
96
+ startMcpListen(c, d, "2", { after: 0 }, new Map(), () => { }, BRANE);
97
+ assert.deepEqual(edges, []);
98
+ assert.equal(tr.isAwaiting(SELF), false);
99
+ });
100
+ it("a cancelled listen ends it now, is settled, and never answers", () => {
101
+ const d = db();
102
+ const { tr, edges } = tracker();
103
+ const sent = [];
104
+ const c = client(tr);
105
+ c.ws.send = (m) => sent.push(m);
106
+ const waiters = new Map();
107
+ const settled = [];
108
+ startMcpListen(c, d, "1", { after: 0 }, waiters, () => { }, BRANE);
109
+ assert.equal(cancelMcpCall(c, "1", (_nid, name) => settled.push(name)), true);
110
+ assert.deepEqual(edges, [[SELF, true], [SELF, false]]);
111
+ assert.deepEqual(settled, ["mcp__brane__listen"]);
112
+ assert.equal(c.pendingListens.size, 0);
113
+ assert.equal(waiters.get(SELF)?.size ?? 0, 0, "its wakeup is unregistered");
114
+ // The next signal finds nobody to answer.
115
+ appendEvent(d, SELF, "human_signal", { content: "go", source: "web" });
116
+ for (const wake of waiters.get(SELF) ?? [])
117
+ wake();
118
+ assert.deepEqual(sent, []);
119
+ assert.equal(cancelMcpCall(c, "1", () => { }), false, "a second cancel is a no-op");
120
+ });
121
+ });
122
+ //# sourceMappingURL=awaiting.test.js.map
@@ -0,0 +1,88 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { adoptBindTokens, mintAgentToken, settleAgentLaunch, verifyAgentToken } from "../db/tokens.js";
5
+ import { braneTmuxEnv, ensureBindToken, buildGooseExtensionArg } from "../commands/spawn.js";
6
+ describe("braneTmuxEnv", () => {
7
+ it("emits BRANE_NARRATIVE_ID and BRANE_NARRATIVE_SECRET when a token is present", () => {
8
+ const env = braneTmuxEnv("claude-abc123", "tok-xyz");
9
+ assert.equal(env, "BRANE_NARRATIVE_ID=claude-abc123 BRANE_NARRATIVE_SECRET=tok-xyz");
10
+ });
11
+ it("omits the secret assignment when the token is absent (migration grace)", () => {
12
+ assert.equal(braneTmuxEnv("claude-abc123", null), "BRANE_NARRATIVE_ID=claude-abc123");
13
+ assert.equal(braneTmuxEnv("claude-abc123"), "BRANE_NARRATIVE_ID=claude-abc123");
14
+ assert.equal(braneTmuxEnv("claude-abc123", ""), "BRANE_NARRATIVE_ID=claude-abc123");
15
+ });
16
+ });
17
+ describe("ensureBindToken", () => {
18
+ // Every launch hands its session a fresh agent token and revokes the one
19
+ // its previous launch held; the daemon keeps only the hash.
20
+ function insert(db, id, token) {
21
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status, bind_token)
22
+ VALUES (?, 'brane-test', 'claude', 'worker', 0, 'active', ?)`).run(id, token);
23
+ }
24
+ it("mints an agent token for the narrative, kept only as a hash", () => {
25
+ const db = createTestDb();
26
+ insert(db, "claude-alpha", null);
27
+ const secret = ensureBindToken(db, "claude-alpha");
28
+ assert.ok(verifyAgentToken(db, "claude-alpha", secret));
29
+ assert.equal(verifyAgentToken(db, "claude-beta", secret), false, "bound to its own narrative");
30
+ const row = db.prepare("SELECT bind_token FROM narratives WHERE id = ?").get("claude-alpha");
31
+ assert.equal(row.bind_token, null, "no plaintext on the narrative");
32
+ const stored = db.prepare("SELECT COUNT(*) AS n FROM tokens WHERE hash = ?").get(secret);
33
+ assert.equal(stored.n, 0, "the table holds its hash, not the secret");
34
+ });
35
+ it("revokes the previous launch's token, so one is live per narrative", () => {
36
+ const db = createTestDb();
37
+ insert(db, "claude-alpha", "existing-token");
38
+ adoptBindTokens(db);
39
+ assert.ok(verifyAgentToken(db, "claude-alpha", "existing-token"), "an adopted pre-agent-token secret works");
40
+ const next = ensureBindToken(db, "claude-alpha");
41
+ assert.notEqual(next, "existing-token");
42
+ assert.equal(verifyAgentToken(db, "claude-alpha", "existing-token"), false);
43
+ assert.ok(verifyAgentToken(db, "claude-alpha", next));
44
+ const live = db
45
+ .prepare("SELECT COUNT(*) AS n FROM tokens WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL")
46
+ .get("claude-alpha");
47
+ assert.equal(live.n, 1);
48
+ });
49
+ it("a revive that fails to launch leaves the running session's token alone", () => {
50
+ const db = createTestDb();
51
+ insert(db, "claude-alpha", null);
52
+ const running = ensureBindToken(db, "claude-alpha");
53
+ const loser = mintAgentToken(db, "claude-alpha", Date.now(), undefined, false);
54
+ assert.ok(verifyAgentToken(db, "claude-alpha", running), "still live while the launch is pending");
55
+ settleAgentLaunch(db, "claude-alpha", loser, false);
56
+ assert.ok(verifyAgentToken(db, "claude-alpha", running));
57
+ assert.equal(verifyAgentToken(db, "claude-alpha", loser), false);
58
+ });
59
+ it("a revive that launches retires the previous token", () => {
60
+ const db = createTestDb();
61
+ insert(db, "claude-alpha", null);
62
+ const old = ensureBindToken(db, "claude-alpha");
63
+ const fresh = mintAgentToken(db, "claude-alpha", Date.now(), undefined, false);
64
+ settleAgentLaunch(db, "claude-alpha", fresh, true);
65
+ assert.equal(verifyAgentToken(db, "claude-alpha", old), false);
66
+ assert.ok(verifyAgentToken(db, "claude-alpha", fresh));
67
+ });
68
+ it("adopting twice adds nothing, and never brings back a revoked secret", () => {
69
+ const db = createTestDb();
70
+ insert(db, "claude-alpha", "existing-token");
71
+ assert.equal(adoptBindTokens(db), 1);
72
+ ensureBindToken(db, "claude-alpha");
73
+ assert.equal(adoptBindTokens(db), 0);
74
+ assert.equal(verifyAgentToken(db, "claude-alpha", "existing-token"), false);
75
+ });
76
+ });
77
+ describe("buildGooseExtensionArg", () => {
78
+ it("carries the bind secret inline in the goose extension env", () => {
79
+ const arg = buildGooseExtensionArg("goose-xyz", "tok-goose");
80
+ assert.match(arg, /^BRANE_NARRATIVE_ID=goose-xyz BRANE_NARRATIVE_SECRET=tok-goose /);
81
+ });
82
+ it("omits the secret when absent", () => {
83
+ const arg = buildGooseExtensionArg("goose-xyz");
84
+ assert.match(arg, /^BRANE_NARRATIVE_ID=goose-xyz /);
85
+ assert.ok(!arg.includes("BRANE_NARRATIVE_SECRET"));
86
+ });
87
+ });
88
+ //# sourceMappingURL=bind-token.test.js.map