brane 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -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 +133 -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 +360 -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 +1101 -0
  37. package/dist/commands/token.js +154 -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 +6353 -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 +643 -0
  58. package/dist/db/tokens.js +624 -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/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The CLI commands that became daemon clients: `absorb`, `list`,
3
+ * `fold`, `memory`. Each is a request builder plus a formatter around
4
+ * `daemonRequest` / `daemonStream`, so that is what is pinned here — the
5
+ * body each sends, the text each prints for a given daemon answer, and the
6
+ * NDJSON transport absorb rides on. The daemon-side rule that moved with
7
+ * them (fold registration: loopback keeps full reach, everyone else is
8
+ * confined to $HOME) is pinned against a temp dir and an in-memory DB.
9
+ */
10
+ import { describe, it, before, after } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { createServer } from "node:http";
13
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { createTestDb } from "./helpers.js";
17
+ import { daemonStream } from "../daemon/client.js";
18
+ import { buildAbsorbBody, formatProgress, formatSummary, } from "../commands/absorb.js";
19
+ import { runHistory } from "../commands/run.js";
20
+ import { formatNarrativeList } from "../commands/list.js";
21
+ import { formatFoldInfo, formatFoldList } from "../commands/fold.js";
22
+ import { buildRecentQuery, formatRecentNarratives, formatSearchResults, formatIndexResult, } from "../commands/memory.js";
23
+ import { foldPathRefusal, registerFold, FoldRegistrationError } from "../folds.js";
24
+ import { isLoopbackAddress, parseRecentQuery, singleFlight } from "../daemon/server.js";
25
+ import { runAbsorb, AbsorbInputError, deriveBraneId } from "../absorb/run.js";
26
+ describe("buildAbsorbBody", () => {
27
+ it("sends only what the operator said, so the daemon's defaults stay in charge", () => {
28
+ assert.deepEqual(buildAbsorbBody({ provider: "organelle" }, "/work"), {
29
+ provider: "organelle",
30
+ });
31
+ });
32
+ it("resolves relative paths against this shell's cwd, not the daemon's", () => {
33
+ assert.deepEqual(buildAbsorbBody({ provider: "chatgpt", path: "export", fold: "../proj" }, "/work/here"), { provider: "chatgpt", path: "/work/here/export", fold: "/work/proj" });
34
+ });
35
+ it("leaves ~/ paths portable for the daemon to expand", () => {
36
+ assert.deepEqual(buildAbsorbBody({ provider: "chatgpt", path: "/abs/export", fold: "~/proj" }, "/work"), { provider: "chatgpt", path: "/abs/export", fold: "~/proj" });
37
+ });
38
+ it("maps --dry-run onto dry_run and passes identity and brane through", () => {
39
+ assert.deepEqual(buildAbsorbBody({ provider: "chatgpt", fold: "~/p", identity: "me@x", brane: "self", dryRun: true }, "/work"), { provider: "chatgpt", fold: "~/p", identity: "me@x", brane: "self", dry_run: true });
40
+ });
41
+ });
42
+ describe("absorb formatting", () => {
43
+ const summary = {
44
+ provider: "chatgpt",
45
+ conversations: 3,
46
+ messages: 40,
47
+ oldest: { time: Date.UTC(2024, 0, 2), title: "first" },
48
+ newest: { time: Date.UTC(2025, 5, 1), title: null },
49
+ models: { "gpt-4o": 2, "o3": 1 },
50
+ skipped: 1,
51
+ appended: 1,
52
+ assets: 2,
53
+ assetsBytes: 2048,
54
+ unresolvedAssets: 1,
55
+ };
56
+ it("prints the summary the in-process command printed", () => {
57
+ assert.deepEqual(formatSummary(summary, false), [
58
+ "Absorbed: 3 conversations, 40 messages (5 total parsed)",
59
+ "Updated: 1 (new messages to append)",
60
+ "Skipped: 1 (already absorbed)",
61
+ "Assets: 2/3 resolved (2.0 KB)",
62
+ " Unresolved: 1 (files not included in export)",
63
+ 'Oldest: 2024-01-02 "first"',
64
+ "Newest: 2025-06-01 null",
65
+ "Models: gpt-4o (2), o3 (1)",
66
+ ]);
67
+ });
68
+ it("switches verbs for a dry run and says where assets would go", () => {
69
+ const lines = formatSummary(summary, true);
70
+ assert.equal(lines[0], "Would absorb: 3 conversations, 40 messages (5 total parsed)");
71
+ assert.equal(lines[1], "Would update: 1 (new messages to append)");
72
+ assert.match(lines[3], /would copy to ~\/\.brane\/payloads\/$/);
73
+ });
74
+ it("labels organelle's models as agent types", () => {
75
+ const lines = formatSummary({ ...summary, provider: "organelle", models: { claude: 4 } }, false);
76
+ assert.equal(lines.at(-1), "Agent types: claude (4)");
77
+ });
78
+ it("truncates long titles on the progress line and pads to a fixed width", () => {
79
+ const line = formatProgress(12, "a".repeat(60), false);
80
+ assert.ok(line.startsWith("\r 12 absorbed: " + "a".repeat(47) + "..."));
81
+ assert.equal(line.length, 80);
82
+ assert.ok(formatProgress(1, null, true).startsWith("\r 1 scanned: (untitled)"));
83
+ });
84
+ });
85
+ describe("daemonStream", () => {
86
+ let server;
87
+ let endpoint;
88
+ let mode = "ok";
89
+ before(async () => {
90
+ server = createServer((req, res) => {
91
+ let body = "";
92
+ req.on("data", (c) => (body += c));
93
+ req.on("end", () => {
94
+ if (mode === "reject") {
95
+ res.writeHead(400, { "Content-Type": "application/json" });
96
+ res.end(JSON.stringify({ error: "--fold is required for chatgpt provider." }));
97
+ return;
98
+ }
99
+ res.writeHead(200, { "Content-Type": "application/x-ndjson" });
100
+ res.write(JSON.stringify({ type: "info", message: `got ${JSON.parse(body).provider}` }) + "\n");
101
+ // Two records in one chunk, then one split across chunks.
102
+ res.write(JSON.stringify({ type: "progress", done: 1, title: "a" }) + "\n" +
103
+ JSON.stringify({ type: "progress", done: 2, title: "b" }) + "\n" +
104
+ '{"type":"prog');
105
+ setTimeout(() => {
106
+ res.write('ress","done":3,"title":null}\n');
107
+ if (mode === "midstream-error") {
108
+ res.end(JSON.stringify({ type: "error", error: "disk full" }) + "\n");
109
+ }
110
+ else if (mode === "no-trailing-newline") {
111
+ res.end(JSON.stringify({ type: "info", message: "last" }));
112
+ }
113
+ else {
114
+ res.end();
115
+ }
116
+ }, 5);
117
+ });
118
+ });
119
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
120
+ const port = server.address().port;
121
+ endpoint = { baseUrl: `http://127.0.0.1:${port}`, token: "tok", port: String(port) };
122
+ });
123
+ after(async () => {
124
+ await new Promise((r) => server.close(() => r()));
125
+ });
126
+ it("delivers every record in order, whether packed together or split across chunks", async () => {
127
+ mode = "ok";
128
+ const seen = [];
129
+ await daemonStream(endpoint, "/api/absorb", { method: "POST", body: { provider: "chatgpt" } }, (l) => seen.push(l));
130
+ assert.deepEqual(seen, [
131
+ { type: "info", message: "got chatgpt" },
132
+ { type: "progress", done: 1, title: "a" },
133
+ { type: "progress", done: 2, title: "b" },
134
+ { type: "progress", done: 3, title: null },
135
+ ]);
136
+ });
137
+ it("hands a mid-stream error line to the caller rather than throwing — the daemon already accepted the request", async () => {
138
+ mode = "midstream-error";
139
+ const seen = [];
140
+ await daemonStream(endpoint, "/api/absorb", { body: { provider: "x" } }, (l) => seen.push(l));
141
+ assert.deepEqual(seen.at(-1), { type: "error", error: "disk full" });
142
+ });
143
+ it("parses a final record the daemon ended without a newline", async () => {
144
+ mode = "no-trailing-newline";
145
+ const seen = [];
146
+ await daemonStream(endpoint, "/api/absorb", { body: { provider: "x" } }, (l) => seen.push(l));
147
+ assert.deepEqual(seen.at(-1), { type: "info", message: "last" });
148
+ });
149
+ it("throws the daemon's own error string for a request it refused up front", async () => {
150
+ mode = "reject";
151
+ await assert.rejects(() => daemonStream(endpoint, "/api/absorb", { body: { provider: "chatgpt" } }, () => { }), /^Error: --fold is required for chatgpt provider\.$/);
152
+ });
153
+ });
154
+ describe("runHistory", () => {
155
+ function narrative(db, id, agentType, role, createdAt, archived = 0) {
156
+ db.prepare(`INSERT INTO narratives (id, brane_id, fold_path, agent_type, role, created_at, status, archived)
157
+ VALUES (?, 'brane-test', '~/dev/x', ?, ?, ?, 'ended', ?)`).run(id, agentType, role, createdAt, archived);
158
+ }
159
+ it("groups utility runs by command, most recently used first, counting archived ones", () => {
160
+ const db = createTestDb();
161
+ narrative(db, "lazygit-1", "lazygit", "utility", 100, 1);
162
+ narrative(db, "zsh-1", "zsh", "utility", 200, 1);
163
+ narrative(db, "lazygit-2", "lazygit", "utility", 300, 1);
164
+ narrative(db, "htop-1", "htop", "utility", 150);
165
+ assert.deepEqual(runHistory(db), [
166
+ { command: "lazygit", last_used: 300, use_count: 2 },
167
+ { command: "zsh", last_used: 200, use_count: 1 },
168
+ { command: "htop", last_used: 150, use_count: 1 },
169
+ ]);
170
+ });
171
+ it("leaves out agents and rows with no command", () => {
172
+ const db = createTestDb();
173
+ narrative(db, "claude-1", "claude", "worker", 500);
174
+ narrative(db, "blank-1", "", "utility", 400);
175
+ narrative(db, "null-1", null, "utility", 300);
176
+ narrative(db, "zsh-1", "zsh", "utility", 100);
177
+ assert.deepEqual(runHistory(db).map((h) => h.command), ["zsh"]);
178
+ });
179
+ it("honors the limit", () => {
180
+ const db = createTestDb();
181
+ for (let i = 0; i < 5; i++)
182
+ narrative(db, `c${i}-1`, `c${i}`, "utility", i);
183
+ assert.deepEqual(runHistory(db, 2).map((h) => h.command), ["c4", "c3"]);
184
+ });
185
+ });
186
+ describe("formatNarrativeList", () => {
187
+ const now = Date.UTC(2026, 8, 4, 12, 0, 0);
188
+ const narratives = [
189
+ { id: "claude-aaaaaaaa", brane_id: "brane-home", agent_type: "claude", role: "worker", fold_path: "~/p", status: "active", summary: "Doing the thing that is quite long indeed and more than fifty", created_at: now - 90_000 },
190
+ { id: "chatgpt-bbbbbbbb", brane_id: "brane-chatgpt-1", agent_type: "chatgpt", role: "absorbed", fold_path: "~/p", status: "ended", summary: null, created_at: now - 3 * 86_400_000 },
191
+ { id: "claude-cccccccc", brane_id: "brane-home", agent_type: "claude", role: "worker", fold_path: "~/p", status: "ended", summary: null, created_at: now - 7_200_000 },
192
+ ];
193
+ const branes = [
194
+ { brane_id: "brane-home", is_home: true },
195
+ { brane_id: "brane-chatgpt-1", is_home: false },
196
+ ];
197
+ it("groups by brane in the order the daemon listed them, marking home", () => {
198
+ assert.equal(formatNarrativeList(narratives, branes, { limit: 10, now }), [
199
+ "",
200
+ " ● brane-home (home) [1 active, 2 total]",
201
+ " ● claude-aaaaaaaa Doing the thing that is quite long indeed and m... 1m ago",
202
+ " ○ claude-cccccccc claude/worker 2h ago",
203
+ "",
204
+ " ○ brane-chatgpt-1 [0 active, 1 total]",
205
+ " ○ chatgpt-bbbbbbbb chatgpt/absorbed 3d ago",
206
+ "",
207
+ ].join("\n"));
208
+ });
209
+ it("applies the per-brane limit and says how many it hid", () => {
210
+ const out = formatNarrativeList(narratives, branes, { limit: 1, now });
211
+ assert.match(out, /claude-aaaaaaaa/);
212
+ assert.doesNotMatch(out, /claude-cccccccc/);
213
+ assert.match(out, /\.\.\. and 1 more \(--limit 0 to show all\)/);
214
+ });
215
+ it("points at spawn when there is nothing to list", () => {
216
+ assert.equal(formatNarrativeList([], branes, { limit: 10 }), "No narratives. Use `brane spawn <agent>` to start one.");
217
+ });
218
+ });
219
+ describe("fold formatting", () => {
220
+ const fold = { path: "~/dev/p", created_at: Date.UTC(2026, 0, 1), last_spawned: null, brane_id: "brane-home", archived: 0 };
221
+ it("shows only the narratives spawned from this fold", () => {
222
+ const out = formatFoldInfo(fold, [
223
+ { id: "claude-11111111", agent_type: "claude", role: "worker", status: "active", fold_path: "~/dev/p" },
224
+ { id: "claude-22222222", agent_type: "claude", role: "worker", status: "ended", fold_path: "~/dev/other" },
225
+ ]);
226
+ assert.match(out, /^Fold: ~\/dev\/p\n/);
227
+ assert.match(out, /last spawned: never brane: brane-home/);
228
+ assert.match(out, /● claude-11111111 \(claude, worker\)/);
229
+ assert.doesNotMatch(out, /claude-22222222/);
230
+ });
231
+ it("lists folds and flags archived ones", () => {
232
+ const out = formatFoldList([fold, { ...fold, path: "~/old", archived: 1 }]);
233
+ assert.match(out, /^Registered folds:\n\n ~\/dev\/p\n/);
234
+ assert.match(out, / ~\/old \(archived\)\n/);
235
+ assert.equal(formatFoldList([]), "No folds registered. Run `brane fold` in a directory to register it.");
236
+ });
237
+ });
238
+ describe("memory formatting and queries", () => {
239
+ it("builds the recent query with a cwd-resolved fold and a numeric limit", () => {
240
+ const q = new URLSearchParams(buildRecentQuery({ since: "2d", search: "daemon restart", status: "active", limit: "abc", fold: "sub" }, "/work"));
241
+ assert.equal(q.get("since"), "2d");
242
+ assert.equal(q.get("search"), "daemon restart");
243
+ assert.equal(q.get("status"), "active");
244
+ assert.equal(q.get("limit"), "20");
245
+ assert.equal(q.get("fold"), "/work/sub");
246
+ assert.equal(new URLSearchParams(buildRecentQuery({ since: "1h", limit: "5", fold: "~/p" }, "/w")).get("fold"), "~/p");
247
+ assert.equal(q.get("archived"), null);
248
+ assert.equal(new URLSearchParams(buildRecentQuery({ since: "1h", limit: "5", archived: true }, "/w")).get("archived"), "1");
249
+ });
250
+ it("parses the recent query the way the route does", () => {
251
+ const ok = parseRecentQuery(new URLSearchParams("since=2d&search=x&status=active&fold=~/p&limit=0&archived=1&brane_ids=a, b,,c"));
252
+ assert.ok("args" in ok);
253
+ assert.deepEqual(ok.braneIds, ["a", "b", "c"]);
254
+ assert.deepEqual(ok.args, { since: "2d", search: "x", status: "active", fold: "~/p", archived: true, limit: 0 });
255
+ const bare = parseRecentQuery(new URLSearchParams(""));
256
+ assert.ok("args" in bare);
257
+ assert.deepEqual(bare.braneIds, []);
258
+ assert.deepEqual(bare.args, { since: undefined, search: undefined, status: undefined, fold: undefined, archived: false });
259
+ for (const bad of ["limit=abc", "limit=1.5", "limit=-1"]) {
260
+ const r = parseRecentQuery(new URLSearchParams(bad));
261
+ assert.ok("error" in r, bad);
262
+ }
263
+ });
264
+ it("prints recent narratives with title, recency and event count", () => {
265
+ const out = formatRecentNarratives([
266
+ { id: "claude-11111111", agent_type: "claude", role: "worker", status: "active", title: "Thin CLI", summary: "wrapping", last_activity: 0, last_activity_ago: "3m ago", event_count: 1 },
267
+ { id: "goose-22222222", agent_type: "goose", role: "worker", status: "ended", title: null, summary: null, last_activity: 0, last_activity_ago: "2h ago", event_count: 12 },
268
+ ], "24h");
269
+ assert.equal(out, ['● claude-11111111 "Thin CLI" 3m ago (1 event)', " Summary: wrapping", "○ goose-22222222 (goose) 2h ago (12 events)"].join("\n"));
270
+ assert.equal(formatRecentNarratives([], "2d"), "No narratives active in the last 2d.");
271
+ });
272
+ it("prints search hits with an attach hint, and index stats", () => {
273
+ const out = formatSearchResults([{ narrative_id: "claude-11111111", seq: 4, content: "x".repeat(130), score: 1.234 }]);
274
+ assert.match(out, /^Found 1 results:\n\n1\. claude-11111111 \(seq 4\) - Score: 1\.23\n "x{120}\.\.\."\n > brane attach claude-11111111\n$/);
275
+ assert.equal(formatSearchResults([]), "No results found.");
276
+ assert.equal(formatIndexResult({ rebuild: false, narratives_scanned: 10, narratives_indexed: 2, chunks_created: 30 }), "Done. Scanned 10 narratives, indexed 2, created 30 chunks.");
277
+ });
278
+ });
279
+ describe("fold registration reach", () => {
280
+ const root = mkdtempSync(join(tmpdir(), "brane-folds-"));
281
+ const home = join(root, "home");
282
+ const project = join(home, "project");
283
+ const outside = join(root, "elsewhere");
284
+ mkdirSync(project, { recursive: true });
285
+ mkdirSync(outside, { recursive: true });
286
+ it("recognizes loopback in every spelling and nothing else", () => {
287
+ for (const a of ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"])
288
+ assert.ok(isLoopbackAddress(a), a);
289
+ for (const a of ["100.101.102.103", "192.168.1.9", "::ffff:192.168.1.9", "", "localhost"])
290
+ assert.ok(!isLoopbackAddress(a), a);
291
+ });
292
+ it("confines a remote client to $HOME and refuses $HOME itself", () => {
293
+ assert.equal(foldPathRefusal(project, { confineToHome: true, home })?.status, undefined);
294
+ assert.equal(foldPathRefusal(outside, { confineToHome: true, home })?.status, 403);
295
+ assert.equal(foldPathRefusal(home, { confineToHome: true, home })?.status, 403);
296
+ });
297
+ it("gives a loopback caller the operator's reach, but still wants a directory", () => {
298
+ assert.equal(foldPathRefusal(outside, { confineToHome: false, home }), null);
299
+ writeFileSync(join(outside, "file.txt"), "");
300
+ assert.equal(foldPathRefusal(join(outside, "file.txt"), { confineToHome: false, home })?.status, 400);
301
+ assert.equal(foldPathRefusal(join(outside, "missing"), { confineToHome: true, home: root })?.status, 400);
302
+ });
303
+ it("registers once, then rebinds the brane and un-archives on re-registration", () => {
304
+ const db = createTestDb();
305
+ const first = registerFold(db, outside, { confineToHome: false, braneId: "brane-a", home });
306
+ assert.equal(first.created, true);
307
+ assert.equal(first.fold.brane_id, "brane-a");
308
+ db.prepare("UPDATE folds SET archived = 1 WHERE path = ?").run(first.fold.path);
309
+ const again = registerFold(db, outside, { confineToHome: false, braneId: "brane-b", home });
310
+ assert.equal(again.created, false);
311
+ assert.equal(again.fold.brane_id, "brane-b");
312
+ assert.equal(again.fold.archived, 0);
313
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM folds").get().n, 1);
314
+ assert.throws(() => registerFold(db, outside, { confineToHome: true, braneId: "brane-c", home }), (err) => err instanceof FoldRegistrationError && err.status === 403);
315
+ // Re-registering without naming a brane is a no-op on the binding.
316
+ const kept = registerFold(db, outside, { confineToHome: false, home });
317
+ assert.equal(kept.created, false);
318
+ assert.equal(kept.fold.brane_id, "brane-b");
319
+ db.close();
320
+ });
321
+ });
322
+ describe("singleFlight", () => {
323
+ it("never overlaps two passes, and a rejecting pass does not poison the next", async () => {
324
+ const log = [];
325
+ const gates = [];
326
+ const flight = singleFlight(async (label) => {
327
+ log.push(`start ${label}`);
328
+ await new Promise((r) => gates.push(r));
329
+ log.push(`end ${label}`);
330
+ if (label === "b")
331
+ throw new Error("pass b failed");
332
+ return label.toUpperCase();
333
+ });
334
+ const a = flight.run("a");
335
+ const b = flight.run("b");
336
+ const c = flight.run("c");
337
+ assert.ok(flight.busy());
338
+ await new Promise((r) => setImmediate(r));
339
+ assert.deepEqual(log, ["start a"]); // b and c are queued, not started
340
+ gates.shift()();
341
+ assert.equal(await a, "A");
342
+ await new Promise((r) => setImmediate(r));
343
+ assert.deepEqual(log, ["start a", "end a", "start b"]);
344
+ gates.shift()();
345
+ await assert.rejects(b, /pass b failed/);
346
+ await new Promise((r) => setImmediate(r));
347
+ assert.deepEqual(log, ["start a", "end a", "start b", "end b", "start c"]);
348
+ gates.shift()();
349
+ assert.equal(await c, "C"); // c ran despite b's failure, and got its own result
350
+ assert.ok(!flight.busy());
351
+ await flight.inflight(); // resolves, never rejects
352
+ });
353
+ });
354
+ describe("runAbsorb input validation", () => {
355
+ const root = mkdtempSync(join(tmpdir(), "brane-absorb-"));
356
+ it("derives a stable brane id from provider and identity", () => {
357
+ assert.equal(deriveBraneId("chatgpt", "user-1"), deriveBraneId("chatgpt", "user-1"));
358
+ assert.match(deriveBraneId("chatgpt", "user-1"), /^brane-chatgpt-[0-9a-f]{6}$/);
359
+ assert.notEqual(deriveBraneId("chatgpt", "user-1"), deriveBraneId("organelle", "user-1"));
360
+ });
361
+ it("rejects before emitting anything, so the route can still answer 400", async () => {
362
+ const db = createTestDb();
363
+ const emitted = [];
364
+ const emit = (e) => emitted.push(e);
365
+ const opts = { confineFoldsToHome: false };
366
+ await assert.rejects(() => runAbsorb(db, { provider: "nope", path: root }, emit, opts), (err) => err instanceof AbsorbInputError && /Unknown provider: nope/.test(err.message));
367
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: join(root, "missing"), fold: root }, emit, opts), (err) => err instanceof AbsorbInputError && /Not found/.test(err.message));
368
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: root }, emit, opts), (err) => err instanceof AbsorbInputError && /--fold is required/.test(err.message));
369
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: root, fold: root }, emit, opts), (err) => err instanceof AbsorbInputError && /No conversation files/.test(err.message));
370
+ // A fold the daemon would refuse is caught here too, not mid-stream —
371
+ // and on a dry run, which never reaches registerFold.
372
+ const exp = join(root, "export");
373
+ mkdirSync(exp);
374
+ writeFileSync(join(exp, "conversations.json"), "[]");
375
+ writeFileSync(join(exp, "user.json"), JSON.stringify({ id: "u1", email: "u@x" }));
376
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: exp, fold: join(root, "missing-fold"), dry_run: true }, emit, opts), (err) => err instanceof FoldRegistrationError && err.status === 400);
377
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: exp, fold: exp }, emit, { confineFoldsToHome: true }), (err) => err instanceof FoldRegistrationError && err.status === 403);
378
+ assert.deepEqual(emitted, []);
379
+ db.close();
380
+ });
381
+ });
382
+ //# sourceMappingURL=thin-cli.test.js.map
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `bootstrapTokens` brings an install to "hashes only, and none for this
3
+ * machine" at daemon start. Two properties matter most, and an operator would
4
+ * notice either: upgrading signs no device out (the pre-hashing shared token
5
+ * is adopted as a device row), and afterwards this machine holds no token at
6
+ * all — the local token an older brane kept in `.daemon_token` is revoked,
7
+ * the file emptied, and the CLI acts as an account row no token can open.
8
+ */
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
12
+ import Database from "better-sqlite3";
13
+ import { SCHEMA_SQL, applyMigrations, getDaemonAddress } from "../db/schema.js";
14
+ import { resolveCredential } from "../daemon/client.js";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { createTestDb } from "./helpers.js";
18
+ import { ACCOUNT_HASH_PREFIX, bootstrapTokens, DbTokenStore, isTokenHash, LEGACY_TOKEN_LABEL, legacyTokenWarning, recordLauncher, tokenFileStanding, } from "../db/tokens.js";
19
+ import { TokenRing, hashToken, ROTATED_LEGACY_LABEL } from "../daemon/token-ring.js";
20
+ function setup() {
21
+ const db = createTestDb();
22
+ const tokenFile = join(mkdtempSync(join(tmpdir(), "brane-tokens-")), ".daemon_token");
23
+ const queue = ["mint-1", "mint-2", "mint-3"];
24
+ const mint = () => queue.shift();
25
+ return { db, tokenFile, mint };
26
+ }
27
+ const plaintextAnywhere = (db, token) => JSON.stringify(db.prepare("SELECT * FROM tokens").all()).includes(token) ||
28
+ JSON.stringify(db.prepare("SELECT * FROM meta").all()).includes(token);
29
+ const liveLocals = (db) => db.prepare("SELECT id, hash, label FROM tokens WHERE kind = 'local' AND revoked_at IS NULL").all();
30
+ describe("bootstrapTokens", () => {
31
+ it("adopts the pre-hashing token as a device, so every melded device keeps working", () => {
32
+ const { db, tokenFile, mint } = setup();
33
+ db.prepare("INSERT INTO meta (key, value) VALUES ('daemon_token', 'legacy')").run();
34
+ writeFileSync(tokenFile, "legacy\n", { mode: 0o644 });
35
+ const boot = bootstrapTokens(db, { tokenFile, mint });
36
+ assert.deepEqual(boot, { adopted: [LEGACY_TOKEN_LABEL], retiredLocal: 0, tokenFile: "emptied" });
37
+ const ring = new TokenRing(new DbTokenStore(db), mint);
38
+ const legacy = ring.authenticate("legacy");
39
+ assert.ok(legacy.ok);
40
+ assert.equal(legacy.record.kind, "device");
41
+ const account = ring.authenticateAccount();
42
+ assert.ok(account.ok);
43
+ assert.equal(account.record.kind, "local");
44
+ });
45
+ it("leaves no plaintext behind: not in meta, not in the table, not in the file", () => {
46
+ const { db, tokenFile, mint } = setup();
47
+ db.prepare("INSERT INTO meta (key, value) VALUES ('daemon_token', 'legacy')").run();
48
+ writeFileSync(tokenFile, "legacy\n", { mode: 0o644 });
49
+ bootstrapTokens(db, { tokenFile, mint });
50
+ assert.equal(plaintextAnywhere(db, "legacy"), false);
51
+ // Emptied, not removed: removing it is the operator's to do.
52
+ assert.equal(readFileSync(tokenFile, "utf-8"), "");
53
+ assert.equal(statSync(tokenFile).mode & 0o777, 0o600);
54
+ });
55
+ it("is idempotent once migrated, and never creates the file", () => {
56
+ const { db, tokenFile, mint } = setup();
57
+ assert.deepEqual(bootstrapTokens(db, { tokenFile, mint }), { adopted: [], retiredLocal: 0, tokenFile: "absent" });
58
+ const [account] = liveLocals(db);
59
+ const second = bootstrapTokens(db, { tokenFile, mint });
60
+ assert.deepEqual(second, { adopted: [], retiredLocal: 0, tokenFile: "absent" });
61
+ assert.deepEqual(liveLocals(db), [account], "the same account row, so what it minted stays its own");
62
+ assert.equal(existsSync(tokenFile), false);
63
+ });
64
+ it("never re-adopts a revoked token, from a stale file or a stale --token", () => {
65
+ const { db, tokenFile, mint } = setup();
66
+ writeFileSync(tokenFile, "legacy\n");
67
+ bootstrapTokens(db, { tokenFile, mint });
68
+ const store = new DbTokenStore(db);
69
+ const legacy = store.find(hashToken("legacy"), 0);
70
+ store.revoke([legacy.id], 1);
71
+ writeFileSync(tokenFile, "legacy\n");
72
+ const boot = bootstrapTokens(db, { tokenFile, configured: "legacy", mint });
73
+ assert.deepEqual(boot.adopted, []);
74
+ assert.equal(new TokenRing(store, mint).authenticate("legacy").ok, false);
75
+ assert.deepEqual(liveLocals(db).map((r) => r.label), ["this machine"]);
76
+ assert.equal(readFileSync(tokenFile, "utf-8"), "", "and the stale file is emptied again");
77
+ });
78
+ it("adopts a configured --token as a device", () => {
79
+ const { db, tokenFile, mint } = setup();
80
+ const boot = bootstrapTokens(db, { tokenFile, configured: "from-flag", mint });
81
+ assert.deepEqual(boot.adopted, ["configured with --token"]);
82
+ const a = new TokenRing(new DbTokenStore(db), mint).authenticate("from-flag");
83
+ assert.ok(a.ok);
84
+ assert.equal(a.record.kind, "device");
85
+ });
86
+ });
87
+ describe("retiring the local token", () => {
88
+ /** An install as the previous brane left it: a `local` row whose token is
89
+ * in `.daemon_token`, and a device that row minted with `brane meld`. */
90
+ function previous() {
91
+ const s = setup();
92
+ const store = new DbTokenStore(s.db);
93
+ const oldLocal = store.insert(hashToken("old-local"), "local", "this machine", 10);
94
+ const phone = store.insert(hashToken("phone"), "device", "phone", 11, { used: true, mintedBy: oldLocal.id });
95
+ s.db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-shell001', 'b', 'claude', 12, 'active')").run();
96
+ recordLauncher(s.db, "claude-shell001", oldLocal.id);
97
+ writeFileSync(s.tokenFile, "old-local\n", { mode: 0o600 });
98
+ return { ...s, store, oldLocal, phone };
99
+ }
100
+ it("revokes the token the file held, empties the file, and keeps the account", () => {
101
+ const { db, tokenFile, mint, store, oldLocal, phone } = previous();
102
+ const boot = bootstrapTokens(db, { tokenFile, mint, now: 20 });
103
+ assert.deepEqual(boot, { adopted: [], retiredLocal: 1, tokenFile: "emptied" });
104
+ const ring = new TokenRing(store, mint);
105
+ assert.deepEqual(ring.authenticate("old-local", 21), { ok: false }, "a copy of the old file opens nothing");
106
+ assert.equal(readFileSync(tokenFile, "utf-8"), "");
107
+ const account = ring.authenticateAccount(21);
108
+ assert.ok(account.ok);
109
+ assert.notEqual(account.record.id, oldLocal.id);
110
+ assert.equal(ring.authenticate("phone", 21).ok, true, "no device is signed out");
111
+ assert.equal(store.get(phone.id)?.minted_by, account.record.id, "still added by this machine");
112
+ assert.deepEqual(store.launched().get(account.record.id)?.map((a) => a.id), ["claude-shell001"], "and what the shell spawned is still listed under it");
113
+ // Revoked, never deleted: its hash stays known, so an older brane started
114
+ // later cannot adopt the same token as a device.
115
+ assert.equal(store.known(hashToken("old-local")), true);
116
+ assert.deepEqual(bootstrapTokens(db, { tokenFile, mint, now: 30 }), { adopted: [], retiredLocal: 0, tokenFile: "absent" });
117
+ });
118
+ it("carries over what an earlier, already revoked local row launched and minted", () => {
119
+ // A shell `brane token rotate` on an older brane replaced the local row
120
+ // and moved what it had minted, but never what it had launched.
121
+ const { db, tokenFile, mint, store, oldLocal } = previous();
122
+ const before = store.insert(hashToken("local-before-rotate"), "local", "this machine", 1);
123
+ store.revoke([before.id], 5);
124
+ const tablet = store.insert(hashToken("tablet"), "device", "tablet", 6, { used: true, mintedBy: before.id });
125
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-shell000', 'b', 'claude', 2, 'active')").run();
126
+ recordLauncher(db, "claude-shell000", before.id);
127
+ bootstrapTokens(db, { tokenFile, mint, now: 20 });
128
+ const [account] = liveLocals(db);
129
+ assert.notEqual(account.id, oldLocal.id);
130
+ assert.equal(store.get(tablet.id)?.minted_by, account.id);
131
+ assert.deepEqual(store.launched().get(account.id)?.map((a) => a.id).sort(), ["claude-shell000", "claude-shell001"]);
132
+ });
133
+ it("survives an older daemon started in between, which puts a local token back", () => {
134
+ const { db, tokenFile, mint, store, phone } = previous();
135
+ bootstrapTokens(db, { tokenFile, mint, now: 20 });
136
+ const [first] = liveLocals(db);
137
+ // What the previous brane's start does: one live local row, opened by a
138
+ // token it writes to the file.
139
+ store.revoke([first.id], 30);
140
+ store.insert(hashToken("local-again"), "local", "this machine", 30);
141
+ writeFileSync(tokenFile, "local-again\n", { mode: 0o600 });
142
+ const boot = bootstrapTokens(db, { tokenFile, mint, now: 40 });
143
+ assert.deepEqual(boot, { adopted: [], retiredLocal: 1, tokenFile: "emptied" });
144
+ const ring = new TokenRing(store, mint);
145
+ assert.deepEqual(ring.authenticate("local-again", 41), { ok: false });
146
+ const [account] = liveLocals(db);
147
+ assert.notEqual(account.id, first.id);
148
+ assert.equal(isTokenHash(account.hash), false);
149
+ assert.equal(store.get(phone.id)?.minted_by, account.id, "lineage follows through the row revoked in between");
150
+ assert.deepEqual(store.launched().get(account.id)?.map((a) => a.id), ["claude-shell001"]);
151
+ });
152
+ it("retires a local token even when its file is gone", () => {
153
+ const { db, mint, store } = previous();
154
+ const elsewhere = join(mkdtempSync(join(tmpdir(), "brane-tokens-")), ".daemon_token");
155
+ const boot = bootstrapTokens(db, { tokenFile: elsewhere, mint, now: 20 });
156
+ assert.deepEqual(boot, { adopted: [], retiredLocal: 1, tokenFile: "absent" });
157
+ assert.deepEqual(new TokenRing(store, mint).authenticate("old-local", 21), { ok: false });
158
+ });
159
+ it("gives the account a row no token can open", () => {
160
+ const { db, tokenFile, mint } = setup();
161
+ bootstrapTokens(db, { tokenFile, mint });
162
+ const [account] = liveLocals(db);
163
+ assert.equal(account.hash, `${ACCOUNT_HASH_PREFIX}mint-1`);
164
+ assert.equal(isTokenHash(account.hash), false);
165
+ assert.equal(isTokenHash(hashToken("anything")), true);
166
+ const ring = new TokenRing(new DbTokenStore(db), mint);
167
+ for (const guess of ["mint-1", account.hash, account.id, ""]) {
168
+ assert.deepEqual(ring.authenticate(guess), { ok: false }, `"${guess}" opens nothing`);
169
+ }
170
+ });
171
+ it("reports a file it could not empty, and still retires the token", { skip: process.getuid?.() === 0 && "root can write anything" }, () => {
172
+ const { db, tokenFile, mint, store } = previous();
173
+ chmodSync(tokenFile, 0o400);
174
+ const boot = bootstrapTokens(db, { tokenFile, mint, now: 20 });
175
+ assert.equal(boot.retiredLocal, 1);
176
+ assert.ok(typeof boot.tokenFile === "object" && /EACCES|EPERM/.test(boot.tokenFile.error));
177
+ assert.deepEqual(new TokenRing(store, mint).authenticate("old-local", 21), { ok: false });
178
+ // What `brane doctor` then says about the token still sitting there.
179
+ assert.equal(tokenFileStanding(db, "old-local"), "dead");
180
+ assert.equal(tokenFileStanding(db, "phone"), "live");
181
+ });
182
+ });
183
+ describe("legacyTokenWarning", () => {
184
+ it("speaks while the pre-hashing token is live, and stops once a rotation retires it", () => {
185
+ const { db, tokenFile, mint } = setup();
186
+ assert.equal(legacyTokenWarning(db), null);
187
+ writeFileSync(tokenFile, "legacy\n");
188
+ bootstrapTokens(db, { tokenFile, mint });
189
+ assert.match(legacyTokenWarning(db) ?? "", /Sign out other devices/);
190
+ const ring = new TokenRing(new DbTokenStore(db), () => "rotated");
191
+ const legacy = ring.authenticate("legacy");
192
+ assert.ok(legacy.ok);
193
+ ring.beginRotation(legacy.record);
194
+ const rotated = ring.authenticate("rotated");
195
+ assert.ok(rotated.ok);
196
+ assert.equal(legacyTokenWarning(db), null);
197
+ // The replacement was minted just now, so it must not keep a label that
198
+ // says it was melded before hashing.
199
+ assert.equal(rotated.record.label, ROTATED_LEGACY_LABEL);
200
+ });
201
+ });
202
+ // File-backed, because the claims these pin are about bytes on disk: what a
203
+ // copied ~/.brane holds.
204
+ function fileDb() {
205
+ const dir = mkdtempSync(join(tmpdir(), "brane-tokens-file-"));
206
+ const db = new Database(join(dir, "brane.db"));
207
+ db.pragma("journal_mode = WAL");
208
+ db.exec(SCHEMA_SQL);
209
+ applyMigrations(db);
210
+ db.prepare("INSERT INTO meta (key, value) VALUES ('brane_id', 'brane-test')").run();
211
+ return { db, dir, tokenFile: join(dir, ".daemon_token") };
212
+ }
213
+ describe("hashed tokens on disk", () => {
214
+ it("leaves the pre-hashing token's bytes in no file of the brane dir, the token file included", () => {
215
+ const { db, dir, tokenFile } = fileDb();
216
+ const secret = "legacy-0b7e1c52-secret";
217
+ db.prepare("INSERT INTO meta (key, value) VALUES ('daemon_token', ?)").run(secret);
218
+ writeFileSync(tokenFile, secret + "\n");
219
+ bootstrapTokens(db, { tokenFile });
220
+ db.close();
221
+ for (const name of readdirSync(dir)) {
222
+ assert.equal(readFileSync(join(dir, name)).includes(secret), false, `${name} still holds the old token`);
223
+ }
224
+ });
225
+ it("leaves a retired local token's bytes in no file either", () => {
226
+ const { db, dir, tokenFile } = fileDb();
227
+ const secret = "local-5d1f0c9a-secret";
228
+ new DbTokenStore(db).insert(hashToken(secret), "local", "this machine", 1);
229
+ writeFileSync(tokenFile, secret + "\n");
230
+ bootstrapTokens(db, { tokenFile });
231
+ db.close();
232
+ for (const name of readdirSync(dir)) {
233
+ assert.equal(readFileSync(join(dir, name)).includes(secret), false, `${name} still holds the local token`);
234
+ }
235
+ });
236
+ it("finding the daemon needs no token, and outside a session neither does talking to it", () => {
237
+ const { db, tokenFile } = fileDb();
238
+ db.prepare("INSERT INTO meta (key, value) VALUES ('daemon_port', '4000'), ('daemon_pid', ?)").run(String(process.pid));
239
+ assert.deepEqual(getDaemonAddress(db), { port: "4000", version: undefined });
240
+ assert.equal(resolveCredential({}), null);
241
+ // A file an older brane left is not read: what it holds is never presented.
242
+ writeFileSync(tokenFile, "local-token\n");
243
+ assert.equal(resolveCredential({}), null);
244
+ });
245
+ it("an agent session presents its own token", () => {
246
+ assert.equal(resolveCredential({ BRANE_NARRATIVE_SECRET: "agent-secret" }), "agent-secret");
247
+ });
248
+ });
249
+ describe("adopted tokens are marked by origin, not by label", () => {
250
+ it("still warns after the legacy device is renamed", () => {
251
+ const { db, tokenFile, mint } = setup();
252
+ writeFileSync(tokenFile, "legacy\n");
253
+ bootstrapTokens(db, { tokenFile, mint });
254
+ db.prepare("UPDATE tokens SET label = 'my phone' WHERE label = ?").run(LEGACY_TOKEN_LABEL);
255
+ assert.notEqual(legacyTokenWarning(db), null);
256
+ });
257
+ it("warns for a --token too, which older daemons logged the same way", () => {
258
+ const { db, tokenFile, mint } = setup();
259
+ bootstrapTokens(db, { tokenFile, configured: "from-flag", mint });
260
+ assert.notEqual(legacyTokenWarning(db), null);
261
+ });
262
+ });
263
+ describe("bootstrapTokens where no token file could be written", () => {
264
+ it("has nothing to write, so the daemon starts all the same", () => {
265
+ const { db, mint } = setup();
266
+ const dir = join(tmpdir(), "brane-no-such-dir-" + process.pid);
267
+ const tokenFile = join(dir, "nested", ".daemon_token");
268
+ assert.deepEqual(bootstrapTokens(db, { tokenFile, mint }), { adopted: [], retiredLocal: 0, tokenFile: "absent" });
269
+ assert.equal(existsSync(dir), false);
270
+ });
271
+ });
272
+ //# sourceMappingURL=token-bootstrap.test.js.map