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,583 @@
1
+ /**
2
+ * The `tokens` table: the daemon's bearer tokens, by hash only. The ring that
3
+ * reads it — authentication, rotation, and why only hashes — is
4
+ * `src/daemon/token-ring.ts`; this module is the storage and the one-time
5
+ * migration from the plaintext token brane kept before.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { hashToken, LEGACY_TOKEN_LABEL, UNUSED_TOKEN_LAPSE_MS, } from "../daemon/token-ring.js";
10
+ /** A user agent is shown in a devices list and nowhere else; past this it is
11
+ * noise, and an unbounded header is not something to keep forever. */
12
+ const MAX_USER_AGENT_LENGTH = 512;
13
+ /** A header or address as it may be stored and shown: control characters
14
+ * become spaces (a newline in a list is a forged row), capped, or null. */
15
+ function cleanSighting(value, max) {
16
+ if (typeof value !== "string")
17
+ return null;
18
+ const clean = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, max);
19
+ return clean || null;
20
+ }
21
+ /** An IPv4 peer arrives on a dual-stack socket as `::ffff:100.64.0.1`. */
22
+ export function normalizeRemoteAddr(addr) {
23
+ const clean = cleanSighting(addr, 64);
24
+ return clean?.startsWith("::ffff:") && clean.includes(".") ? clean.slice("::ffff:".length) : clean;
25
+ }
26
+ /** The sighting recorded for a connection on the daemon's Unix socket,
27
+ * which has no address of its own. */
28
+ export const UNIX_SOCKET_SIGHTING = "daemon.sock";
29
+ /** Whether two sightings are the same place. Loopback is one place however
30
+ * it is spelled: the MCP bridge dials `localhost` (often `::1`), the CLI
31
+ * dials `127.0.0.1` or `daemon.sock`, and a token used by both is not a
32
+ * copy. */
33
+ export function samePlace(a, b) {
34
+ const loopback = (x) => x === "::1" || x === UNIX_SOCKET_SIGHTING || /^127\.\d+\.\d+\.\d+$/.test(x);
35
+ return a === b || (loopback(a) && loopback(b));
36
+ }
37
+ /** Record which token's request launched (spawned, ran or revived) a
38
+ * narrative's process, on the narrative and on the agent token that launch
39
+ * minted — which is what lets revoking a device cascade to the agents it
40
+ * started. Best-effort: it labels, and must never fail the launch it
41
+ * follows. */
42
+ export function recordLauncher(db, nid, tokenId) {
43
+ try {
44
+ db.prepare("UPDATE narratives SET launched_by = ? WHERE id = ?").run(tokenId, nid);
45
+ db.prepare("UPDATE tokens SET minted_by = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND minted_by IS NULL").run(tokenId, nid);
46
+ }
47
+ catch { }
48
+ }
49
+ /** Mint a narrative's agent token: the credential its session uses for
50
+ * everything (the MCP bridge, the `brane` CLI in its shell, hooks), handed
51
+ * to it in the tmux env as `BRANE_NARRATIVE_SECRET`. The daemon keeps only
52
+ * the hash. Every launch mints a fresh one and revokes the narrative's
53
+ * previous ones in the same write, so exactly one is live per narrative and
54
+ * a revoked agent comes back only by being revived. Returns the plaintext,
55
+ * which the caller puts in the session's env and nowhere else. */
56
+ export function mintAgentToken(db, nid, now = Date.now(), mint = () => randomUUID(),
57
+ /** false: leave the previous token live, for a launch that may still fail —
58
+ * the caller then calls `settleAgentLaunch` with the outcome. */
59
+ revokePrevious = true) {
60
+ const token = mint();
61
+ db.transaction(() => {
62
+ if (revokePrevious) {
63
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL").run(now, nid);
64
+ }
65
+ db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
66
+ VALUES (?, ?, 'agent', ?, 'minted', ?, ?, ?)`).run(randomUUID(), hashToken(token), nid, now, now, nid);
67
+ })();
68
+ return token;
69
+ }
70
+ /** Finish a launch that minted with `revokePrevious = false`. On success the
71
+ * narrative's other agent tokens are revoked, so one is live; on failure the
72
+ * new token is, and the previous one stands. Without this split, a revive
73
+ * that raced another (a double tap) would revoke the running session's
74
+ * token and then fail to launch — leaving the live agent cut off by a revive
75
+ * that never happened. */
76
+ export function settleAgentLaunch(db, nid, token, launched, now = Date.now()) {
77
+ const hash = hashToken(token);
78
+ if (launched) {
79
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND hash != ?").run(now, nid, hash);
80
+ }
81
+ else {
82
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE hash = ? AND revoked_at IS NULL").run(now, hash);
83
+ }
84
+ }
85
+ /** The id of `nid`'s live agent token row, if it has one. */
86
+ export function liveAgentTokenId(db, nid) {
87
+ const row = db
88
+ .prepare("SELECT id FROM tokens WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1")
89
+ .get(nid);
90
+ return row?.id ?? null;
91
+ }
92
+ /** Whether `secret` is `nid`'s live agent token. What `/ws/mcp` and the hook
93
+ * route check a narrative claim against, in place of comparing the plaintext
94
+ * `narratives.bind_token` brane used to keep. */
95
+ export function verifyAgentToken(db, nid, secret) {
96
+ return (db
97
+ .prepare("SELECT 1 FROM tokens WHERE hash = ? AND kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL")
98
+ .get(hashToken(secret), nid) !== undefined);
99
+ }
100
+ /** Adopt the plaintext bind secret of each narrative in `only` as an agent
101
+ * token, so sessions launched before agent tokens and still running keep
102
+ * working: their tmux env holds that secret, and it is now checked by hash.
103
+ * Only running sessions: a dead or archived narrative's stored secret would
104
+ * otherwise become a permanent bearer token for a session nobody is running
105
+ * (367 of them on the first machine this ran on, claude-jrjh1e8w:13 #3).
106
+ * Omit `only` to adopt every stored secret (tests). Idempotent, and a hash
107
+ * already known — revoked or not — is never adopted again. The plaintext
108
+ * column is left as it is; a secret not adopted authenticates nothing. */
109
+ export function adoptBindTokens(db, now = Date.now(), only) {
110
+ const rows = db
111
+ .prepare("SELECT id, bind_token FROM narratives WHERE bind_token IS NOT NULL AND bind_token != ''")
112
+ .all().filter((r) => !only || only.has(r.id));
113
+ const known = db.prepare("SELECT 1 FROM tokens WHERE hash = ?");
114
+ const insert = db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
115
+ VALUES (?, ?, 'agent', ?, 'adopted', ?, NULL, ?)`);
116
+ let adopted = 0;
117
+ db.transaction(() => {
118
+ for (const r of rows) {
119
+ const hash = hashToken(r.bind_token);
120
+ if (known.get(hash))
121
+ continue;
122
+ insert.run(randomUUID(), hash, r.id, now, r.id);
123
+ adopted++;
124
+ }
125
+ })();
126
+ return adopted;
127
+ }
128
+ /** `last_used_at` is a "last seen" for a devices list, not an audit log, so a
129
+ * busy token writes it at most this often rather than on every request. */
130
+ const TOUCH_INTERVAL_MS = 60_000;
131
+ export { LEGACY_TOKEN_LABEL };
132
+ const ROW_COLUMNS = "id, kind, label, origin, created_at, last_used_at, user_agent, remote_addr, " +
133
+ "last_user_agent, last_remote_addr, elsewhere_at, minted_by, owner_nid";
134
+ /** `ROW_COLUMNS` qualified as `t.`, plus what the minting token is, for the list. */
135
+ const LISTING_COLUMNS = ROW_COLUMNS.split(", ").map((c) => `t.${c}`).join(", ") +
136
+ ", m.kind AS minted_by_kind, m.user_agent AS minted_by_user_agent";
137
+ /** The rows `UNUSED_TOKEN_LAPSE_MS` applies to, as SQL: minted device tokens
138
+ * never presented. */
139
+ const UNUSED_DEVICE = "kind = 'device' AND origin = 'minted' AND last_used_at IS NULL";
140
+ export class DbTokenStore {
141
+ db;
142
+ touched = new Map();
143
+ /** Rows presented since this daemon started. A token's first use is what
144
+ * saves it from lapsing, and the `last_used_at` stamp that records it is
145
+ * best-effort; this is the in-memory record that cannot fail. */
146
+ used = new Set();
147
+ /** Rows whose sighting is on disk, so a busy token checks it once per
148
+ * daemon rather than on every request. */
149
+ sighted = new Set();
150
+ /** When this daemon last stamped `elsewhere_at`, per row, so a copy in
151
+ * steady use writes at most once a minute. */
152
+ elsewhereStamped = new Map();
153
+ constructor(db) {
154
+ this.db = db;
155
+ }
156
+ find(hash, now, seen) {
157
+ const row = this.db
158
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE hash = ? AND revoked_at IS NULL`)
159
+ .get(hash);
160
+ return row ? this.admit(row, now, seen) : null;
161
+ }
162
+ account(now, seen) {
163
+ const row = this.db
164
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1`)
165
+ .get();
166
+ return row ? this.admit(row, now, seen) : null;
167
+ }
168
+ /** A live row being used: refuse it if it has lapsed, else stamp its
169
+ * sighting and last use, and return it. */
170
+ admit(row, now, seen) {
171
+ if (this.lapsed(row, now)) {
172
+ // Refused whether or not the revoke lands; the sweep retries it.
173
+ try {
174
+ this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(now, row.id);
175
+ }
176
+ catch { }
177
+ return null;
178
+ }
179
+ this.used.add(row.id);
180
+ if (seen && !this.sighted.has(row.id)) {
181
+ if (row.user_agent !== null || row.remote_addr !== null)
182
+ this.sighted.add(row.id);
183
+ // Best-effort, like the stamp below; the next request retries it.
184
+ else
185
+ try {
186
+ if (this.recordSighting(row.id, seen))
187
+ this.sighted.add(row.id);
188
+ }
189
+ catch { }
190
+ }
191
+ // The first sighting names the row; the latest, and whether it came from
192
+ // somewhere else, is what shows a copied token. An address is the one
193
+ // thing about a request its sender cannot choose, so it is what's compared.
194
+ const userAgent = seen ? cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH) : null;
195
+ const remoteAddr = seen ? normalizeRemoteAddr(seen.remoteAddr) : null;
196
+ const elsewhere = remoteAddr !== null && row.remote_addr !== null && !samePlace(remoteAddr, row.remote_addr);
197
+ const last = this.touched.get(row.id) ?? row.last_used_at ?? 0;
198
+ const lastElsewhere = this.elsewhereStamped.get(row.id) ?? row.elsewhere_at ?? 0;
199
+ if (now - last >= TOUCH_INTERVAL_MS || (elsewhere && now - lastElsewhere >= TOUCH_INTERVAL_MS)) {
200
+ // Best-effort: a "last seen" stamp must never fail the request it rides
201
+ // on (a busy or read-only database, say). The next request retries it.
202
+ try {
203
+ this.db
204
+ .prepare(`UPDATE tokens SET last_used_at = ?,
205
+ last_user_agent = COALESCE(?, last_user_agent),
206
+ last_remote_addr = COALESCE(?, last_remote_addr),
207
+ elsewhere_at = CASE WHEN ? THEN ? ELSE elsewhere_at END
208
+ WHERE id = ?`)
209
+ .run(now, userAgent, remoteAddr, elsewhere ? 1 : 0, now, row.id);
210
+ this.touched.set(row.id, now);
211
+ if (elsewhere)
212
+ this.elsewhereStamped.set(row.id, now);
213
+ }
214
+ catch { }
215
+ }
216
+ return { id: row.id, kind: row.kind, label: row.label, ...(row.owner_nid ? { ownerNid: row.owner_nid } : {}) };
217
+ }
218
+ /** Whether this hash has *ever* been a token, revoked or not — so a
219
+ * revoked token presented again at startup is never re-adopted. */
220
+ known(hash) {
221
+ return this.db.prepare("SELECT 1 FROM tokens WHERE hash = ?").get(hash) !== undefined;
222
+ }
223
+ insert(hash, kind, label, now, opts = {}) {
224
+ const record = this.write(hash, kind, label, now, "minted");
225
+ if (opts.mintedBy)
226
+ this.db.prepare("UPDATE tokens SET minted_by = ? WHERE id = ?").run(opts.mintedBy, record.id);
227
+ if (opts.used) {
228
+ this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").run(now, record.id);
229
+ this.used.add(record.id);
230
+ this.touched.set(record.id, now);
231
+ }
232
+ // Best-effort: a rotation's replacement is inserted inside the rotation's
233
+ // transaction, and a label for a list must never be what aborts it.
234
+ if (opts.seen)
235
+ try {
236
+ if (this.recordSighting(record.id, opts.seen))
237
+ this.sighted.add(record.id);
238
+ }
239
+ catch { }
240
+ return record;
241
+ }
242
+ /** Write a row's first sighting, if it has none. Never overwrites one: the
243
+ * row should keep naming the device that first presented it, not whoever
244
+ * presents it next. Returns whether anything was written. */
245
+ recordSighting(id, seen) {
246
+ const userAgent = cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH);
247
+ const remoteAddr = normalizeRemoteAddr(seen.remoteAddr);
248
+ if (userAgent === null && remoteAddr === null)
249
+ return false;
250
+ this.db
251
+ .prepare("UPDATE tokens SET user_agent = ?, remote_addr = ? WHERE id = ? AND user_agent IS NULL AND remote_addr IS NULL")
252
+ .run(userAgent, remoteAddr, id);
253
+ return true;
254
+ }
255
+ /** Every live row, oldest first, for the Devices list. A minted token that
256
+ * has already lapsed but not yet been swept is left out: it can no longer
257
+ * authenticate, so it is not a device. */
258
+ list(now) {
259
+ const rows = this.db
260
+ .prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
261
+ WHERE t.revoked_at IS NULL ORDER BY t.created_at, t.id`)
262
+ .all();
263
+ return rows
264
+ .filter((r) => !this.lapsed(r, now))
265
+ .map((r) => ({ ...r, unused: this.unused(r) }));
266
+ }
267
+ retireDependents(_retired, requesterId, replacementId, now) {
268
+ this.db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?").run(replacementId, requesterId);
269
+ // Every external whose dialer is no longer live — the rows this rotation
270
+ // retired, and any revoked earlier without its externals (by a store
271
+ // from before that rule, say) — so no rotation leaves one standing.
272
+ const ids = this.db
273
+ .prepare(`SELECT t.id FROM tokens t
274
+ JOIN narratives n ON n.id = t.owner_nid
275
+ LEFT JOIN tokens d ON d.id = t.minted_by
276
+ WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
277
+ AND t.minted_by IS NOT NULL AND (d.id IS NULL OR d.revoked_at IS NOT NULL)`)
278
+ .all().map((r) => r.id);
279
+ this.revoke(ids, now);
280
+ return ids;
281
+ }
282
+ /** The live agent tokens of external narratives that any of `dialers`
283
+ * dialed in. An external's token is its dialer's credential in another
284
+ * form — one `/ws/mcp` dial mints it — so it goes wherever its dialer
285
+ * goes, without asking (claude-jrjh1e8w:22). */
286
+ externalsOf(dialers) {
287
+ if (dialers.length === 0)
288
+ return [];
289
+ return this.db
290
+ .prepare(`SELECT t.id FROM tokens t JOIN narratives n ON n.id = t.owner_nid
291
+ WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
292
+ AND t.minted_by IN (${dialers.map(() => "?").join(", ")})`)
293
+ .all(...dialers).map((r) => r.id);
294
+ }
295
+ /** Every live device row minted by `rootId`, directly or through a chain of
296
+ * devices, revoked links included (a laptop revoked last week still
297
+ * connects the phone to the tablet it added) — what a cascading revoke may
298
+ * take. Never the `keep` row or anything it added by a path that does not
299
+ * run through `rootId`: that is the requester, and a cascade that reached
300
+ * it would strand the device doing the revoking. (Not through the root,
301
+ * because the requester is often an *ancestor*: `brane meld` from this
302
+ * machine added the phone, and revoking the phone from here must still
303
+ * take the laptop the phone added.) */
304
+ descendants(rootId, keep) {
305
+ const rows = this.db.prepare("SELECT id, kind, minted_by, revoked_at FROM tokens").all();
306
+ const children = new Map();
307
+ for (const r of rows) {
308
+ if (r.minted_by === null)
309
+ continue;
310
+ const list = children.get(r.minted_by) ?? [];
311
+ list.push(r);
312
+ children.set(r.minted_by, list);
313
+ }
314
+ const subtree = (from, stopAt) => {
315
+ const seen = new Set([from]);
316
+ const queue = [from];
317
+ while (queue.length) {
318
+ for (const c of children.get(queue.shift()) ?? []) {
319
+ if (c.id === stopAt || seen.has(c.id))
320
+ continue;
321
+ seen.add(c.id);
322
+ queue.push(c.id);
323
+ }
324
+ }
325
+ return seen;
326
+ };
327
+ const kept = keep ? subtree(keep, rootId) : new Set();
328
+ const out = [];
329
+ for (const id of subtree(rootId)) {
330
+ if (id === rootId || kept.has(id))
331
+ continue;
332
+ const row = rows.find((r) => r.id === id);
333
+ if (row.kind !== "local" && row.revoked_at === null)
334
+ out.push(id);
335
+ }
336
+ return out;
337
+ }
338
+ /** The narratives each token last launched (spawn, run or revive), not
339
+ * archived, keyed by token id. */
340
+ launched() {
341
+ const rows = this.db
342
+ .prepare(`SELECT id, title, status, agent_type, launched_by FROM narratives
343
+ WHERE launched_by IS NOT NULL AND (archived = 0 OR archived IS NULL)
344
+ ORDER BY created_at`)
345
+ .all();
346
+ const out = new Map();
347
+ for (const { launched_by, ...agent } of rows) {
348
+ const list = out.get(launched_by) ?? [];
349
+ list.push(agent);
350
+ out.set(launched_by, list);
351
+ }
352
+ return out;
353
+ }
354
+ /** One live row by id, or null. */
355
+ get(id) {
356
+ const row = this.db
357
+ .prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
358
+ WHERE t.id = ? AND t.revoked_at IS NULL`)
359
+ .get(id);
360
+ return row ? { ...row, unused: this.unused(row) } : null;
361
+ }
362
+ unused(row) {
363
+ return row.kind === "device" && row.origin === "minted" && row.last_used_at === null && !this.used.has(row.id);
364
+ }
365
+ lapsed(row, now) {
366
+ return this.unused(row) && now - row.created_at >= UNUSED_TOKEN_LAPSE_MS;
367
+ }
368
+ /** Revoke every minted device token that lapsed unused — the sweep behind
369
+ * `find`'s own check, so a QR nobody scanned does not linger as a live
370
+ * row. Returns how many it revoked. */
371
+ lapseUnused(now) {
372
+ const rows = this.db
373
+ .prepare(`SELECT id FROM tokens WHERE ${UNUSED_DEVICE} AND revoked_at IS NULL AND created_at <= ?`)
374
+ .all(now - UNUSED_TOKEN_LAPSE_MS);
375
+ const ids = rows.map((r) => r.id).filter((id) => !this.used.has(id));
376
+ this.revoke(ids, now);
377
+ return ids.length;
378
+ }
379
+ /** Record a token that existed as plaintext before this daemon hashed it —
380
+ * one that may have been written down somewhere (older daemons logged it). */
381
+ adopt(hash, kind, label, now) {
382
+ return this.write(hash, kind, label, now, "adopted");
383
+ }
384
+ write(hash, kind, label, now, origin) {
385
+ const id = randomUUID();
386
+ this.db
387
+ .prepare("INSERT INTO tokens (id, hash, kind, label, origin, created_at) VALUES (?, ?, ?, ?, ?, ?)")
388
+ .run(id, hash, kind, label, origin, now);
389
+ return { id, kind, label };
390
+ }
391
+ revoke(ids, now) {
392
+ const stmt = this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL");
393
+ this.transaction(() => {
394
+ for (const id of ids)
395
+ stmt.run(now, id);
396
+ });
397
+ }
398
+ transaction(fn) {
399
+ return this.db.transaction(fn)();
400
+ }
401
+ live(kind) {
402
+ return this.db
403
+ .prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = ? AND revoked_at IS NULL ORDER BY created_at`)
404
+ .all(kind).map((row) => ({ id: row.id, kind: row.kind, label: row.label, ...(this.unused(row) ? { unused: true } : {}) }));
405
+ }
406
+ }
407
+ /** Advice while an adopted token is still live, or null. Daemons before
408
+ * hashing printed their token into `daemon.log` at every start, and a
409
+ * `--token` sits in argv and its config, so an adopted token should be
410
+ * retired: one rotation, from the device in hand or the shell, then re-meld
411
+ * the others. (Re-melding first would be undone: a rotation revokes every
412
+ * other device row.) Keyed on `origin`, not the label, so renaming the
413
+ * device does not silence it. */
414
+ export function legacyTokenWarning(db) {
415
+ const live = db
416
+ // Not agent rows: an adopted bind secret dies at its narrative's next
417
+ // revive, and was never something a person could have written down.
418
+ .prepare("SELECT 1 FROM tokens WHERE origin = 'adopted' AND kind != 'agent' AND revoked_at IS NULL")
419
+ .get();
420
+ if (!live)
421
+ return null;
422
+ return ("a token that existed as plaintext before it was hashed is still valid " +
423
+ "(older brane versions printed theirs in daemon.log, and a --token sits in argv and its config). " +
424
+ 'Retire it with "Sign out other devices" in the app, or `brane token rotate`, then re-meld your devices.');
425
+ }
426
+ /** Read `.daemon_token`, the file older branes kept this machine's local
427
+ * token in, or null if it is absent or empty. Nothing presents it any more;
428
+ * it is read at daemon start (to retire what it holds, after which the
429
+ * daemon empties it) and by `brane doctor`. */
430
+ export function readTokenFile(path) {
431
+ try {
432
+ const token = readFileSync(path, "utf-8").trim();
433
+ return token || null;
434
+ }
435
+ catch {
436
+ return null;
437
+ }
438
+ }
439
+ /** What the account row's `hash` column starts with. The rest is a UUID,
440
+ * only to satisfy the column's UNIQUE. */
441
+ export const ACCOUNT_HASH_PREFIX = "no-token:";
442
+ /** Whether a `hash` column holds a token's SHA-256, which some presented
443
+ * token could match — as opposed to the account row's placeholder, which
444
+ * none can: `hashToken` only ever yields 64 hex digits. */
445
+ export function isTokenHash(hash) {
446
+ return /^[0-9a-f]{64}$/.test(hash);
447
+ }
448
+ /** How a token still sitting in `.daemon_token` stands, for `brane doctor`:
449
+ * `live` when it authenticates (a pre-hashing token adopted as a device
450
+ * row, until a rotation retires it), `dead` when it does not. The daemon
451
+ * empties the file at start, so this only arises where it could not. */
452
+ export function tokenFileStanding(db, token) {
453
+ const row = db.prepare("SELECT 1 FROM tokens WHERE hash = ? AND revoked_at IS NULL").get(hashToken(token));
454
+ return row ? "live" : "dead";
455
+ }
456
+ /**
457
+ * Bring the token state to "hashes only, and none for this machine" at
458
+ * daemon start. Idempotent: on an already-migrated install it finds the
459
+ * account row and changes nothing.
460
+ *
461
+ * **This machine holds no token.** Its CLI and MCP bridges reach the daemon
462
+ * over `daemon.sock` and present nothing (`authenticateRequest`); what they
463
+ * act as is the one live `local` row, the account row, whose `hash` is a
464
+ * placeholder no token can match (`ACCOUNT_HASH_PREFIX`). Older branes kept
465
+ * a local token in `.daemon_token`, which worked from any address the IP
466
+ * filter admits, so a copy of the file was this machine's shell from
467
+ * elsewhere. Every `local` row a token can open is revoked here, and the
468
+ * file is then emptied, so no token, live or dead, stays on disk. Its hash
469
+ * stays a revoked row, so an older brane started later cannot re-adopt it.
470
+ * The file itself is not removed; `brane doctor` reports the empty leftover
471
+ * for the operator to remove. What any earlier `local` row had minted,
472
+ * dialed in or launched moves to the account row, so a device `brane meld`
473
+ * added last week is still "added by this machine", and the agents `brane
474
+ * spawn` started are still listed under it.
475
+ *
476
+ * Before hashing, one plaintext token lived in both `meta.daemon_token` and
477
+ * `.daemon_token`, and every melded device held it. That token is adopted as
478
+ * a *device* row, so those devices keep working, and the `meta` row is
479
+ * dropped. Its plaintext leaves the file with everything else, so from then
480
+ * on it exists only on the devices that hold it, and dies with the first
481
+ * rotation like any other device token. A `--token` passed on the command line is adopted
482
+ * the same way, labelled as configured. A token whose hash is already known
483
+ * — live or revoked — is never adopted again, so a revoked token cannot come
484
+ * back through a stale file or a stale `--token`.
485
+ */
486
+ /** The label of a device row `brane meld --remote` sent as a hash. */
487
+ export const REMOTE_MELD_LABEL = "melded remotely";
488
+ /** The device-token hashes in `BRANE_DEVICE_TOKEN_SHA256`: comma-separated
489
+ * lowercase hex SHA-256s. Anything else is dropped, so a malformed secret
490
+ * adopts nothing rather than an unusable row. */
491
+ export function parseTokenHashes(value) {
492
+ return [...new Set((value ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => /^[0-9a-f]{64}$/.test(s)))];
493
+ }
494
+ export function bootstrapTokens(db, opts) {
495
+ const store = new DbTokenStore(db);
496
+ const mint = opts.mint ?? (() => randomUUID());
497
+ const now = opts.now ?? Date.now();
498
+ const adopted = [];
499
+ const adopt = (plain, label) => {
500
+ if (!plain)
501
+ return;
502
+ const hash = hashToken(plain);
503
+ if (store.known(hash))
504
+ return;
505
+ store.adopt(hash, "device", label, now);
506
+ adopted.push(label);
507
+ };
508
+ const fileToken = readTokenFile(opts.tokenFile);
509
+ const metaRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_token'").get();
510
+ // A plain DELETE leaves the old value in the freed page and in WAL frames
511
+ // until something overwrites them. secure_delete zeroes the page, and the
512
+ // checkpoint below folds the WAL back in and truncates it.
513
+ const secureDelete = db.pragma("secure_delete", { simple: true });
514
+ if (metaRow)
515
+ db.pragma("secure_delete = ON");
516
+ db.transaction(() => {
517
+ // A file token that is a `local` row is known, so it is not adopted
518
+ // here; it is retired below.
519
+ adopt(fileToken, LEGACY_TOKEN_LABEL);
520
+ adopt(metaRow?.value, LEGACY_TOKEN_LABEL);
521
+ adopt(opts.configured, "configured with --token");
522
+ for (const hash of opts.configuredHashes ?? []) {
523
+ if (store.known(hash))
524
+ continue;
525
+ // Minted, not adopted: like any QR, it lapses if nobody scans it.
526
+ store.insert(hash, "device", REMOTE_MELD_LABEL, now);
527
+ adopted.push(REMOTE_MELD_LABEL);
528
+ }
529
+ // The plaintext this replaces. Its hash is now a row, so nothing is lost
530
+ // that any device needs.
531
+ db.prepare("DELETE FROM meta WHERE key = 'daemon_token'").run();
532
+ })();
533
+ if (metaRow) {
534
+ db.pragma(`secure_delete = ${secureDelete ? "ON" : "OFF"}`);
535
+ try {
536
+ db.pragma("wal_checkpoint(TRUNCATE)");
537
+ }
538
+ catch { }
539
+ }
540
+ // One live `local` row, and no token opens it.
541
+ const retiredLocal = db.transaction(() => {
542
+ const locals = db
543
+ .prepare("SELECT id, hash FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC")
544
+ .all();
545
+ const withToken = locals.filter((r) => isTokenHash(r.hash)).map((r) => r.id);
546
+ const account = locals.find((r) => !isTokenHash(r.hash))?.id ??
547
+ store.insert(`${ACCOUNT_HASH_PREFIX}${mint()}`, "local", "this machine", now).id;
548
+ store.revoke(withToken, now);
549
+ // What any earlier `local` row minted, dialed in or launched is the
550
+ // account's: the rows just retired, and ones revoked before — by a shell
551
+ // rotation on an older brane, which never moved what it had launched, or
552
+ // by an older daemon started in between, which replaces the account row
553
+ // with a token-opened one of its own.
554
+ const earlier = db.prepare("SELECT id FROM tokens WHERE kind = 'local' AND id != ?").all(account);
555
+ const minted = db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?");
556
+ const launched = db.prepare("UPDATE narratives SET launched_by = ? WHERE launched_by = ?");
557
+ for (const { id } of earlier) {
558
+ minted.run(account, id);
559
+ launched.run(account, id);
560
+ }
561
+ return withToken.length;
562
+ })();
563
+ // Whatever the file held is a row now, so nothing is lost with it: a
564
+ // revoked local token, or a pre-hashing token its devices still hold.
565
+ // Emptied only after the rows are written, and never fatal: a file that
566
+ // cannot be emptied is reported, by the caller and by `brane doctor`.
567
+ let tokenFile = "absent";
568
+ if (fileToken) {
569
+ try {
570
+ writeFileSync(opts.tokenFile, "", { mode: 0o600 });
571
+ chmodSync(opts.tokenFile, 0o600);
572
+ tokenFile = "emptied";
573
+ }
574
+ catch (err) {
575
+ tokenFile = { error: err.message };
576
+ }
577
+ }
578
+ // Sessions launched before agent tokens hold a plaintext bind secret in
579
+ // their env; adopting each as an agent row keeps them working.
580
+ adoptBindTokens(db, now, opts.liveSessions ?? new Set());
581
+ return { adopted, retiredLocal, tokenFile };
582
+ }
583
+ //# sourceMappingURL=tokens.js.map