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,35 @@
1
+ /**
2
+ * Who a `human_signal` is from, decided by the token that sent it, never by
3
+ * the request. Both ways in — `POST /api/narratives/:id/signal` and the
4
+ * control socket's `signal` message — used to take `source` from the body,
5
+ * so any token holder could write the operator's kind of event under any
6
+ * label, and an agent's token could post "web" signals into any narrative.
7
+ *
8
+ * A signal is the operator speaking. A device token is the operator's
9
+ * delegate (the app), and this account over `daemon.sock` is the operator's
10
+ * shell; an agent token is neither. An agent that wants to speak into
11
+ * another narrative narrates into it, which the daemon records as an
12
+ * `agent_signal` stamped with the agent's own narrative.
13
+ */
14
+ /** The `source` stamped on a signal sent with each kind of token. "web" is
15
+ * what the app always sent, so existing bubbles and new ones read alike. */
16
+ export const SIGNAL_SOURCES = { device: "web", local: "local" };
17
+ export const AGENT_SIGNAL_REFUSAL = "An agent's token cannot send a human_signal; narrate into the narrative instead (it arrives as an agent_signal)";
18
+ /** The source to stamp, or the refusal to send. */
19
+ export function signalStanding(record) {
20
+ if (record.kind === "agent")
21
+ return { refused: AGENT_SIGNAL_REFUSAL };
22
+ return { source: SIGNAL_SOURCES[record.kind] };
23
+ }
24
+ /** The fields a `human_signal` is appended with, given the token's standing
25
+ * and the request (an HTTP body, or a control-socket message). `content`
26
+ * and `re` come from the request; `source` never does, whatever it says. */
27
+ export function signalEventFields(standing, request, payloads) {
28
+ return {
29
+ content: request.content,
30
+ source: standing.source,
31
+ re: request.re,
32
+ ...(payloads ? { payloads } : {}),
33
+ };
34
+ }
35
+ //# sourceMappingURL=signal-source.js.map
@@ -0,0 +1,58 @@
1
+ // Tailscale re-probe policy for the daemon's listeners.
2
+ //
3
+ // `startServer` asks Tailscale for its addresses exactly once, at startup, and
4
+ // binds to whatever comes back. That is the right first move and the wrong
5
+ // last one: a `tailscale status` that times out — the extension wedged, the
6
+ // app still launching after a reboot — leaves the daemon bound to loopback
7
+ // only, and it stays that way for as long as it runs. Observed on 2026-08-30:
8
+ // three consecutive daemon starts logged "No Tailscale detected" while the
9
+ // Tailscale extension was hung, and the phone stayed locked out through the
10
+ // recovery until the operator happened to restart the daemon again
11
+ // (claude-9ya34xr8:13, :23).
12
+ //
13
+ // The policy here is window-free and pure so `src/test/tailscale-rebind.test.ts`
14
+ // can pin it: how long to wait before asking again, and which of the addresses
15
+ // an answer carries still need a listener.
16
+ /** First retry after a probe that found nothing. */
17
+ export const TAILSCALE_PROBE_INITIAL_MS = 5_000;
18
+ /** Ceiling for the not-found backoff. */
19
+ export const TAILSCALE_PROBE_MAX_MS = 60_000;
20
+ /** Cadence once Tailscale has been seen — catches address changes and a
21
+ * restart that came back with new IPs. */
22
+ export const TAILSCALE_PROBE_STEADY_MS = 60_000;
23
+ /**
24
+ * Delay before the next probe.
25
+ *
26
+ * Not found: exponential backoff from `TAILSCALE_PROBE_INITIAL_MS`, doubling
27
+ * up to `TAILSCALE_PROBE_MAX_MS`. A Tailscale that is starting up is usually
28
+ * ready within seconds; one that is wedged is not, and there is no point
29
+ * hammering a hung CLI.
30
+ *
31
+ * Found: `TAILSCALE_PROBE_STEADY_MS`, regardless of what came before.
32
+ */
33
+ export function nextProbeDelay(previous, found) {
34
+ if (found)
35
+ return TAILSCALE_PROBE_STEADY_MS;
36
+ if (previous === null || previous <= 0)
37
+ return TAILSCALE_PROBE_INITIAL_MS;
38
+ return Math.min(previous * 2, TAILSCALE_PROBE_MAX_MS);
39
+ }
40
+ /**
41
+ * Addresses a probe returned that the daemon has not bound yet, in the order
42
+ * Tailscale reported them, without duplicates or blanks. An address that is
43
+ * already bound is never returned — rebinding it would throw EADDRINUSE
44
+ * against our own listener.
45
+ */
46
+ export function addressesToBind(bound, probed) {
47
+ const have = new Set(bound);
48
+ const out = [];
49
+ for (const raw of probed) {
50
+ const addr = raw.trim();
51
+ if (!addr || have.has(addr))
52
+ continue;
53
+ have.add(addr);
54
+ out.push(addr);
55
+ }
56
+ return out;
57
+ }
58
+ //# sourceMappingURL=tailscale-rebind.js.map
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The daemon's bearer tokens, and how they change hands.
3
+ *
4
+ * **Only hashes are stored.** Every token is a random UUID the daemon hands
5
+ * out exactly once — to the device that asked, or to the agent session it
6
+ * launches — and keeps nothing but its SHA-256 in the `tokens` table
7
+ * (`src/db/tokens.ts`). That table therefore
8
+ * authenticates nothing by itself, and the daemon cannot print a token back:
9
+ * a device that needs one gets a freshly minted one (`POST /api/tokens`,
10
+ * which is what `brane meld` calls). This is a claim about the table, not
11
+ * about `brane.db`: events and captured tool calls are stored verbatim
12
+ * (`prepareCaptureText`, src/db/tool-uses.ts), so a token an agent printed or
13
+ * quoted is in the database in the clear until a rotation retires it. The tokens are 122 random bits, so a plain
14
+ * SHA-256 is the right hash; a slow one only helps a guessable secret.
15
+ *
16
+ * A `device` token is held by a web client as a durable credential — a
17
+ * registry entry written from a meld QR. This machine's own CLI and MCP
18
+ * bridges hold none: they reach the daemon over `daemon.sock` and present
19
+ * nothing, and act as the one live `local` row, the account row, which no
20
+ * token opens (`bootstrapTokens`, src/db/tokens.ts).
21
+ *
22
+ * Rotation is what "sign out other devices" means here. The hard part is not
23
+ * the rotation but not stranding the device that asked for it. The obvious
24
+ * shape — rotate returns the new token and the old dies on the spot — has one
25
+ * failure the request layer's own history makes real: on a satellite link, if
26
+ * the *response* is lost, the daemon has rotated and nobody holds the new
27
+ * token, the requester included. And a `POST` is rightly never retried, so
28
+ * the client cannot paper over it.
29
+ *
30
+ * So the swap happens **on first use**. `beginRotation` mints a *pending*
31
+ * token for the requester and changes nothing durable; every live token still
32
+ * authenticates. The first request that presents the pending token is the
33
+ * proof it arrived, and only then is it written down: it becomes a row of the
34
+ * requester's kind, and the requester's old row and every other device row
35
+ * are revoked — `authenticate` reports which, and the daemon closes the
36
+ * sockets they opened. The account row is never among them, so the CLI and
37
+ * MCP bridges ride through a phone's rotation the way they always have. A
38
+ * pending token nobody ever uses lapses after
39
+ * `ROTATION_GRACE_MS` and nothing has changed: a lost response costs nothing,
40
+ * and a restart mid-handshake (this state is in-memory) lands on the safe
41
+ * side.
42
+ *
43
+ * What rotation guarantees is only this: once it completes, holders of the
44
+ * requester's old token, and of every other device token, are out. It cannot
45
+ * stop a token holder from calling the route first — a token already grants
46
+ * keystrokes into every tmux session, so that adds nothing — but it must
47
+ * never *help* them, which is why a pending token is issued exactly once
48
+ * (see `beginRotation`).
49
+ *
50
+ * The shell's "sign out every device" (`brane token rotate`) needs none of
51
+ * that: it holds no token to swap, so nothing of its own can be stranded by
52
+ * a lost response, and `signOutDevices` revokes on the spot.
53
+ *
54
+ * Rows are revoked, never deleted.
55
+ */
56
+ import { createHash, randomBytes } from "node:crypto";
57
+ /** How long a minted-but-unused token stays valid before the rotation is
58
+ * abandoned. Long enough for a slow link's round trip plus a reconnect;
59
+ * short enough that a token stolen at the worst moment is not good for long
60
+ * if the requester vanishes. */
61
+ export const ROTATION_GRACE_MS = 60_000;
62
+ /** How long a minted device token may sit unused before it lapses. A meld QR
63
+ * is scanned on the spot; one that never was — every opening of the app's
64
+ * Meld sheet mints one — would otherwise stay a full-power credential for
65
+ * good, wherever the QR was photographed, and a phantom row in any devices
66
+ * list. First use makes a token permanent, as before. Only minted device
67
+ * tokens lapse: the account row and adopted ones are already in use. */
68
+ /** Label for the token brane used before hashing, adopted as a device row so
69
+ * every device that already holds it keeps working. Only a label: what marks
70
+ * an adopted row is its `origin`, which no rename can change. */
71
+ export const LEGACY_TOKEN_LABEL = "melded before hashing";
72
+ /** What a rotation's replacement row is labelled when the requester's own
73
+ * label was `LEGACY_TOKEN_LABEL`: the new token was minted, not melded
74
+ * before hashing, and a label that says otherwise is wrong. */
75
+ export const ROTATED_LEGACY_LABEL = "kept when other devices were signed out";
76
+ export const UNUSED_TOKEN_LAPSE_MS = 10 * 60_000;
77
+ export { WS_CLOSE_TOKEN_ROTATED } from "../shared/token-rotation.js";
78
+ export function hashToken(token) {
79
+ return createHash("sha256").update(token, "utf8").digest("hex");
80
+ }
81
+ /** A device token for a daemon on another machine (`brane meld --remote`),
82
+ * and its hash. Nothing here stores either: only the hash goes to that
83
+ * machine (`BRANE_DEVICE_TOKEN_SHA256`), which its daemon adopts at
84
+ * startup, and the token goes no further than the QR. */
85
+ export function mintRemoteToken() {
86
+ const token = randomBytes(32).toString("base64url");
87
+ return { token, hash: hashToken(token) };
88
+ }
89
+ export class TokenRing {
90
+ store;
91
+ mint;
92
+ pending = null;
93
+ constructor(store, mint) {
94
+ this.store = store;
95
+ this.mint = mint;
96
+ }
97
+ /** The unexpired pending rotation, if any; expiry is checked lazily here
98
+ * rather than by a timer so the ring has no lifecycle of its own. */
99
+ pendingRotation(now = Date.now()) {
100
+ if (this.pending && this.pending.expiresAt <= now)
101
+ this.pending = null;
102
+ return this.pending;
103
+ }
104
+ /** Mint a new token and record only its hash — the caller hands the
105
+ * plaintext to whoever asked and keeps no copy. */
106
+ issue(kind, label, now = Date.now(), opts = {}) {
107
+ const token = this.mint();
108
+ return { token, record: this.store.insert(hashToken(token), kind, label, now, opts) };
109
+ }
110
+ /** Mint a pending token for `requester`. A rotation already pending is
111
+ * *replaced*, never re-issued: the only copy of a pending token must be
112
+ * the response that minted it. Handing the same token to a second caller
113
+ * would give it to exactly the party rotation exists to evict — anyone
114
+ * holding a token can call this route too, and during the window they
115
+ * would be handed the new one to use first. Replacing instead means a
116
+ * racing rotate invalidates the requester's token, whose confirming
117
+ * request then fails visibly (and `replaced` is logged) rather than
118
+ * succeeding with a guest aboard. Nothing is lost for the honest retry: a
119
+ * client that timed out has discarded the first response, so a fresh
120
+ * token is as good.
121
+ *
122
+ * Throws for a `local` requester. Completing a rotation writes a row of
123
+ * the requester's kind under the new token, so one begun for this account
124
+ * would put back a token that opens the `local` row — the copyable local
125
+ * token `bootstrapTokens` retires. That "no token opens this machine's
126
+ * row" must hold here, not only in the one route that calls this
127
+ * (`gatedRotateRoute`, which sends this account to `signOutDevices`). */
128
+ beginRotation(requester, now = Date.now()) {
129
+ if (requester.kind === "local") {
130
+ throw new Error("This account holds no token to rotate; signOutDevices is what signs devices out for it");
131
+ }
132
+ const replaced = this.pendingRotation(now) !== null;
133
+ const token = this.mint();
134
+ this.pending = { hash: hashToken(token), requester, expiresAt: now + ROTATION_GRACE_MS };
135
+ return { token, expiresAt: this.pending.expiresAt, replaced };
136
+ }
137
+ /** Abandon a pending rotation begun by `tokenId`. Revoking a device must
138
+ * also void a rotation it started: otherwise its pending token would
139
+ * confirm afterwards and write the revoked device back in as a fresh row,
140
+ * signing out everyone else on the way. Returns whether one was dropped. */
141
+ abandonRotationBy(tokenId) {
142
+ if (this.pending?.requester.id !== tokenId)
143
+ return false;
144
+ this.pending = null;
145
+ return true;
146
+ }
147
+ /** Sign out every device at once, for a requester that holds no token:
148
+ * this account, over the daemon's socket. Revokes every live device row
149
+ * and the externals they dialed in, and voids any pending rotation — its
150
+ * requester is a device, which has just been signed out, and its pending
151
+ * token must not write it back in. Returns the revoked ids, whose sockets
152
+ * the caller closes, and how many of them were devices someone was signed
153
+ * in on: an unscanned QR is revoked too, but it was nobody's device. */
154
+ signOutDevices(requester, now = Date.now()) {
155
+ const replaced = this.pendingRotation(now) !== null;
156
+ const result = this.store.transaction(() => {
157
+ const live = this.store.live("device");
158
+ const devices = live.map((d) => d.id);
159
+ this.store.revoke(devices, now);
160
+ const dependents = this.store.retireDependents?.(devices, requester.id, requester.id, now) ?? [];
161
+ return { ids: [...new Set([...devices, ...dependents])], devices: live.filter((d) => !d.unused).length };
162
+ });
163
+ this.pending = null;
164
+ return { ...result, replaced };
165
+ }
166
+ /** Authenticate a request that presented no token but arrived where only
167
+ * this Unix account can reach (the daemon's socket, its directory
168
+ * checked): it acts as the live `local` row. The caller decides the
169
+ * "where"; this only says whether there is a row to act as. */
170
+ authenticateAccount(now = Date.now(), seen) {
171
+ const record = this.store.account?.(now, seen) ?? null;
172
+ return record ? { ok: true, record } : { ok: false };
173
+ }
174
+ /** Check a presented token. A live row always authenticates; the pending
175
+ * token authenticates and, in doing so, is written down and retires the
176
+ * requester's old row and every other device row — the caller gets
177
+ * `retired` exactly once and is expected to act on it (close those rows'
178
+ * sockets) before answering the request. */
179
+ authenticate(presented, now = Date.now(), seen) {
180
+ if (presented == null || presented === "")
181
+ return { ok: false };
182
+ const hash = hashToken(presented);
183
+ const record = this.store.find(hash, now, seen);
184
+ if (record)
185
+ return { ok: true, record };
186
+ const live = this.pendingRotation(now);
187
+ if (live && hash === live.hash) {
188
+ const { requester } = live;
189
+ // One transaction, so a rotation either completes or never happened: a
190
+ // new token that works while nobody was signed out is the failure this
191
+ // must not have. If it throws, the pending token survives for a retry.
192
+ const result = this.store.transaction(() => {
193
+ // Written as used: this very request is its first use, and a device
194
+ // that rotates and then only holds a socket open must not lapse. Its
195
+ // sighting is this request's — the device that asked to rotate.
196
+ const label = requester.label === LEGACY_TOKEN_LABEL ? ROTATED_LEGACY_LABEL : requester.label;
197
+ const replacement = this.store.insert(hash, requester.kind, label, now, { used: true, seen });
198
+ const ids = new Set([requester.id]);
199
+ for (const d of this.store.live("device"))
200
+ if (d.id !== replacement.id)
201
+ ids.add(d.id);
202
+ this.store.revoke([...ids], now);
203
+ for (const x of this.store.retireDependents?.([...ids], requester.id, replacement.id, now) ?? [])
204
+ ids.add(x);
205
+ return { replacement, ids: [...ids] };
206
+ });
207
+ this.pending = null;
208
+ return { ok: true, record: result.replacement, retired: { ids: result.ids } };
209
+ }
210
+ return { ok: false };
211
+ }
212
+ }
213
+ /** A `TokenStore` in memory, for the suite and for anything that needs a
214
+ * ring without a database. */
215
+ export class MemoryTokenStore {
216
+ rows = new Map();
217
+ seq = 0;
218
+ /** Seed with plaintext tokens — only their hashes are kept. */
219
+ constructor(seed = []) {
220
+ for (const s of seed)
221
+ this.insert(hashToken(s.token), s.kind, s.label ?? s.kind);
222
+ }
223
+ find(hash) {
224
+ for (const r of this.rows.values()) {
225
+ if (r.hash === hash && r.revokedAt === null)
226
+ return { id: r.id, kind: r.kind, label: r.label };
227
+ }
228
+ return null;
229
+ }
230
+ insert(hash, kind, label) {
231
+ const id = `t${++this.seq}`;
232
+ this.rows.set(id, { id, kind, label, hash, revokedAt: null });
233
+ return { id, kind, label };
234
+ }
235
+ revoke(ids, now) {
236
+ for (const id of ids) {
237
+ const r = this.rows.get(id);
238
+ if (r && r.revokedAt === null)
239
+ r.revokedAt = now;
240
+ }
241
+ }
242
+ live(kind) {
243
+ return [...this.rows.values()]
244
+ .filter((r) => r.kind === kind && r.revokedAt === null)
245
+ .map(({ id, kind, label }) => ({ id, kind, label }));
246
+ }
247
+ account() {
248
+ return this.live("local")[0] ?? null;
249
+ }
250
+ transaction(fn) {
251
+ const snapshot = new Map([...this.rows].map(([id, r]) => [id, { ...r }]));
252
+ const seq = this.seq;
253
+ try {
254
+ return fn();
255
+ }
256
+ catch (err) {
257
+ this.rows = snapshot;
258
+ this.seq = seq;
259
+ throw err;
260
+ }
261
+ }
262
+ }
263
+ //# sourceMappingURL=token-ring.js.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The pieces of the token routes that hold their own logic, kept out of the
3
+ * server's closure so the suite can drive them (`src/test/token-routes.test.ts`,
4
+ * `src/test/token-devices.test.ts`): minting a device token, finishing a
5
+ * rotation, and the Devices list with its per-device revoke.
6
+ */
7
+ import { describeUserAgent } from "../user-agent.js";
8
+ import { UNUSED_TOKEN_LAPSE_MS, WS_CLOSE_TOKEN_ROTATED } from "./token-ring.js";
9
+ const MAX_LABEL_LENGTH = 200;
10
+ /** The label a `POST /api/tokens` body asks for, or why the body is refused.
11
+ * Separate from minting so the route can refuse a bad body before it asks
12
+ * the operator to approve anything (src/daemon/presence-gate.ts). */
13
+ export function parseMintBody(rawBody, now = Date.now()) {
14
+ let parsed = {};
15
+ if (rawBody && rawBody.trim()) {
16
+ try {
17
+ parsed = JSON.parse(rawBody);
18
+ }
19
+ catch {
20
+ return { error: "Body must be a JSON object" };
21
+ }
22
+ }
23
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
24
+ return { error: "Body must be a JSON object" };
25
+ }
26
+ const { label } = parsed;
27
+ // Control characters become spaces: a label is logged and will be shown in
28
+ // a devices list, and a newline in either is a forged line.
29
+ const clean = typeof label === "string" ? label.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim() : "";
30
+ return { label: clean ? clean.slice(0, MAX_LABEL_LENGTH) : `melded ${new Date(now).toISOString().slice(0, 10)}` };
31
+ }
32
+ /** `POST /api/tokens`: mint a device token from a request body. The daemon
33
+ * keeps only the hash, so the `token` in a 200 is the one and only copy, and
34
+ * `expiresAt` is when it lapses if nothing has used it by then. An
35
+ * empty body is fine (`brane meld` sends `{}` or nothing); a body that is not
36
+ * a JSON object is a 400. */
37
+ export function mintDeviceTokenRoute(ring, rawBody, now = Date.now(),
38
+ /** The requester's row: recorded on the new one, so the list can say which
39
+ * device added which, and nothing later has to guess. */
40
+ mintedBy) {
41
+ const parsed = parseMintBody(rawBody, now);
42
+ if ("error" in parsed)
43
+ return { status: 400, body: parsed };
44
+ const { token, record } = ring.issue("device", parsed.label, now, { mintedBy: mintedBy?.id });
45
+ return {
46
+ status: 200,
47
+ body: {
48
+ token,
49
+ id: record.id,
50
+ label: record.label,
51
+ expiresAt: now + UNUSED_TOKEN_LAPSE_MS,
52
+ // Relative too, for a client whose clock is not the daemon's.
53
+ validForMs: UNUSED_TOKEN_LAPSE_MS,
54
+ },
55
+ };
56
+ }
57
+ /** Finish a rotation, inside the request that first used the new token and
58
+ * before it is answered: close every viewer socket opened under a revoked
59
+ * row with 4401, and only those. */
60
+ export function retireTokens(opts) {
61
+ return { closed: closeSocketsOf(opts.viewerSockets, opts.retired.ids, "token rotated") };
62
+ }
63
+ /** Whether a socket opened under `record` on `channel` must be closed when
64
+ * that token is revoked or rotated away. Control and terminal sockets
65
+ * always. An MCP socket only under a device token: a device token can open
66
+ * `/ws/mcp`, which reaches every MCP tool, so a revoke that left it open
67
+ * would leave the revoked token working for as long as it stayed connected;
68
+ * an agent token's MCP socket is that agent's bridge, and closing it is what
69
+ * revoking an agent means.
70
+ * A local bridge is exempt — it dialed as this account, which is not what
71
+ * a device revoke or rotation retires. */
72
+ export function closesOnRevoke(channel, record) {
73
+ return channel !== "mcp" || record.kind !== "local";
74
+ }
75
+ /** Which rows own a socket. Usually one, the token that opened it. An MCP
76
+ * socket bound to a narrative also belongs to that narrative's agent token,
77
+ * whoever dialed it: a device's socket must close when the *device* is
78
+ * revoked (the dialer), and an external's socket dialed as this account
79
+ * must close when its *agent* is revoked (the narrative). Tagging only one
80
+ * of the two is how a revoked device kept its MCP socket open
81
+ * (claude-jrjh1e8w:13 #1). */
82
+ export function socketOwners(channel, record, narrativeAgentToken) {
83
+ const ids = closesOnRevoke(channel, record) ? [record.id] : [];
84
+ if (channel === "mcp" && narrativeAgentToken && !ids.includes(narrativeAgentToken))
85
+ ids.push(narrativeAgentToken);
86
+ return ids;
87
+ }
88
+ /** Close every viewer socket opened under one of `ids` with 4401, the code
89
+ * the app reads as "signed out" rather than a network blip, and forget it.
90
+ * Returns how many it closed. */
91
+ export function closeSocketsOf(viewerSockets, ids, reason = "token revoked") {
92
+ const revoked = new Set(ids);
93
+ let closed = 0;
94
+ for (const [ws, owners] of viewerSockets) {
95
+ const list = typeof owners === "string" ? [owners] : owners;
96
+ if (!list.some((id) => revoked.has(id)))
97
+ continue;
98
+ viewerSockets.delete(ws);
99
+ try {
100
+ ws.close(WS_CLOSE_TOKEN_ROTATED, reason);
101
+ }
102
+ catch { }
103
+ closed++;
104
+ }
105
+ return closed;
106
+ }
107
+ function withoutExternals(store, rootId, ids) {
108
+ const externals = new Set(store.externalsOf([rootId, ...ids]));
109
+ return ids.filter((id) => !externals.has(id));
110
+ }
111
+ /** `GET /api/tokens`: every live token, the local one included (shown, never
112
+ * revocable from here). */
113
+ export function listTokensRoute(store, requester, now = Date.now(), connected = new Set()) {
114
+ const launched = store.launched();
115
+ const tokens = store.list(now).map((t) => ({
116
+ ...t,
117
+ device: describeUserAgent(t.user_agent),
118
+ last_device: describeUserAgent(t.last_user_agent),
119
+ minted_by_device: t.minted_by === null
120
+ ? null
121
+ : t.minted_by_kind === "local"
122
+ ? "this machine"
123
+ : describeUserAgent(t.minted_by_user_agent) ?? "an unknown device",
124
+ current: t.id === requester.id,
125
+ connected: connected.has(t.id),
126
+ expires_at: t.unused ? t.created_at + UNUSED_TOKEN_LAPSE_MS : null,
127
+ // Externals are left out: they go with their dialer regardless, so
128
+ // offering them as a choice would be a checkbox that does nothing.
129
+ descendants: t.kind !== "local" && t.id !== requester.id ? withoutExternals(store, t.id, store.descendants(t.id, requester.id)) : [],
130
+ agents: launched.get(t.id) ?? [],
131
+ }));
132
+ return { status: 200, body: { tokens } };
133
+ }
134
+ /** What a revoke would do, or why it is refused, without doing it: the
135
+ * checks of `revokeTokenRoute`, which the route runs before asking the
136
+ * operator to approve a device's sign-out, so a request that would fail
137
+ * never raises a prompt. The revoke itself runs them again, since the rows
138
+ * can change while the prompt is up. */
139
+ export function planRevoke(store, requester, id, also = []) {
140
+ const row = store.get(id);
141
+ if (!row)
142
+ return { status: 404, body: { error: "No live token with that id" } };
143
+ if (row.kind === "local") {
144
+ return { status: 400, body: { error: "That row is this machine's own account, which holds no token and cannot be revoked" } };
145
+ }
146
+ if (row.id === requester.id) {
147
+ return { status: 409, body: { error: "That is this device; use \"Sign out other devices\" to keep it and sign out the rest" } };
148
+ }
149
+ const allowed = new Set(store.descendants(row.id, requester.id));
150
+ const extra = [...new Set(also)];
151
+ const stray = extra.filter((x) => !allowed.has(x));
152
+ if (stray.length) {
153
+ return { status: 400, body: { error: `Not a device this one added: ${stray.join(", ")}` } };
154
+ }
155
+ const chosen = [row.id, ...extra];
156
+ // Plus the externals any of them dialed in, always: they are those
157
+ // tokens' credentials in another form, not devices of their own.
158
+ const ids = [...chosen, ...store.externalsOf(chosen).filter((x) => !chosen.includes(x))];
159
+ return { row, extra, ids };
160
+ }
161
+ /** `POST /api/tokens/:id/revoke`: revoke one device token. Bodiless, so the
162
+ * app sends it without a preflight. Three refusals, each naming the route
163
+ * that does what the caller probably meant:
164
+ *
165
+ * - the account row (`local`): it is this machine's CLI and MCP bridges on
166
+ * `daemon.sock`, which hold no token, so there is nothing to revoke;
167
+ * - the requester's own token: a device revoking itself strands itself, and
168
+ * "Sign out other devices" is the version that keeps it in;
169
+ * - an id that is not live: 404, so a double-tap reads as already done.
170
+ *
171
+ * On success the row is revoked (never deleted) and any rotation that device
172
+ * had begun is abandoned — otherwise its pending token could confirm later
173
+ * and write the device back in. The caller closes the row's sockets. */
174
+ export function revokeTokenRoute(store, ring, requester, id, now = Date.now(),
175
+ /** Devices to revoke with it: each must be one this row added, directly or
176
+ * down a chain, and not the requester or anything it added. Checked here,
177
+ * so the list can never be used to revoke an unrelated device. */
178
+ also = []) {
179
+ const plan = planRevoke(store, requester, id, also);
180
+ if ("status" in plan)
181
+ return plan;
182
+ const { row, extra, ids } = plan;
183
+ // One write: the device and the ones it added go together, or none do.
184
+ store.revoke(ids, now);
185
+ let abandonedRotation = false;
186
+ for (const x of ids)
187
+ abandonedRotation = ring.abandonRotationBy(x) || abandonedRotation;
188
+ return { status: 200, body: { revoked: row.id, kind: row.kind, label: row.label, also: extra, ids, abandonedRotation } };
189
+ }
190
+ //# sourceMappingURL=token-routes.js.map