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,349 @@
1
+ // CertManager — keeps the daemon's TLS cert alive without operator action.
2
+ //
3
+ // The big picture: if Tailscale is running, the daemon should always be
4
+ // serving on HTTPS with a valid cert. The operator should never have to run
5
+ // `brane certify` to keep things working. This module owns that promise.
6
+ //
7
+ // Responsibilities:
8
+ // - On startup, ensure a valid cert exists at the canonical path,
9
+ // provisioning one if needed (first-run or expired).
10
+ // - Periodically re-check the cert and renew when it enters the renewal
11
+ // window (last ~30 days of a 90-day Let's Encrypt issuance).
12
+ // - Watch the cert file for external changes (e.g. operator ran
13
+ // `brane certify` by hand) and hot-reload without restart.
14
+ // - Surface state so the daemon can expose it on /api/status, broadcast
15
+ // it to the web UI, and print it in `brane daemon status`.
16
+ //
17
+ // What this module deliberately does NOT do:
18
+ // - Bind HTTPS servers (that stays in daemon/server.ts).
19
+ // - Broadcast over WebSockets directly (caller registers a state-change
20
+ // callback that does the broadcasting).
21
+ // - Persist state (transient, rederivable from the cert file on disk).
22
+ import { readFileSync, watch } from "node:fs";
23
+ import { dirname } from "node:path";
24
+ import { provisionTlsCert, inspectCert, } from "../tls.js";
25
+ import { getTailscaleHost } from "../tailscale.js";
26
+ /** Renew when fewer than this many days remain on the cert. */
27
+ const RENEW_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
28
+ /** Periodic check cadence. Renewal itself is gated by the threshold. */
29
+ const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
30
+ /** Backoff schedule after a renewal failure. Capped at the last entry. */
31
+ const BACKOFF_SCHEDULE_MS = [
32
+ 1 * 60 * 60 * 1000, // 1h
33
+ 2 * 60 * 60 * 1000, // 2h
34
+ 4 * 60 * 60 * 1000, // 4h
35
+ 8 * 60 * 60 * 1000, // 8h
36
+ 24 * 60 * 60 * 1000, // 24h
37
+ ];
38
+ /** fs.watch debounce: coalesce the multiple events a single rename emits. */
39
+ const WATCH_DEBOUNCE_MS = 200;
40
+ /**
41
+ * Owns the daemon's TLS cert lifecycle. The daemon creates one instance,
42
+ * calls `ensureCert()` before deciding whether to bind HTTPS, registers
43
+ * `setSecureContext` via `onReload`, and calls `start()` once the servers
44
+ * are listening to begin the renewal/watch loop. `stop()` on shutdown.
45
+ */
46
+ export class CertManager {
47
+ opts;
48
+ // ^ archiveDir was removed: brane has no use for prior cert pairs (no
49
+ // pinning clients, no offline trust state), and unbounded archival would
50
+ // grow ~4 entries/year forever.
51
+ status = {
52
+ state: "disabled",
53
+ expiresAt: null,
54
+ lastErrorReason: null,
55
+ lastErrorMessage: null,
56
+ nextAttemptAt: null,
57
+ host: null,
58
+ };
59
+ pollTimer = null;
60
+ watcher = null;
61
+ watchDebounce = null;
62
+ renewInflight = null;
63
+ consecutiveFailures = 0;
64
+ stopped = false;
65
+ constructor(opts) {
66
+ this.opts = {
67
+ certPath: opts.certPath,
68
+ keyPath: opts.keyPath,
69
+ onReload: opts.onReload,
70
+ onStateChange: opts.onStateChange,
71
+ log: opts.log ?? (() => { }),
72
+ resolveHost: opts.resolveHost ?? getTailscaleHost,
73
+ pollIntervalMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
74
+ };
75
+ }
76
+ /**
77
+ * Synchronously look at what's on disk and (if needed/possible) provision
78
+ * a cert before the daemon binds. Returns true iff the daemon has a valid
79
+ * cert it can use to bind HTTPS. Safe to call from daemon startup — does
80
+ * not start the renewal loop (call `start()` after binding for that).
81
+ */
82
+ async ensureCert() {
83
+ const host = this.opts.resolveHost();
84
+ this.status.host = host ?? null;
85
+ const existing = inspectCert(this.opts.certPath);
86
+ const now = Date.now();
87
+ // Mirror tick()'s check: a cert whose CN doesn't match the current
88
+ // Tailscale host is unusable for HTTPS even if the dates are fine
89
+ // (tailnet rename, node rename). Without this, ensureCert would say
90
+ // "valid", startServer would bind HTTPS, and every client would see a
91
+ // TLS error until the hourly tick noticed and re-provisioned.
92
+ const subjectMismatch = existing && host && !subjectIs(existing.subject, host);
93
+ // Case A: valid cert exists for the right host. Use it; renewal loop
94
+ // will handle expiry later.
95
+ if (existing && existing.notAfter.getTime() > now && !subjectMismatch) {
96
+ this.status.expiresAt = existing.notAfter;
97
+ this.status.state = "valid";
98
+ this.emitStateChange();
99
+ return true;
100
+ }
101
+ // Case B: cert is missing/expired/wrong-subject and no Tailscale.
102
+ // Nothing we can do — daemon will serve HTTP only.
103
+ if (!host) {
104
+ this.status.state = "disabled";
105
+ this.status.expiresAt = existing?.notAfter ?? null;
106
+ this.emitStateChange();
107
+ return false;
108
+ }
109
+ // Case C: cert is missing/expired/wrong-subject and Tailscale is
110
+ // available. Try to provision a fresh one.
111
+ this.opts.log("info", subjectMismatch && existing
112
+ ? `TLS cert subject ${existing.subject} does not match ${host} — provisioning fresh cert`
113
+ : existing
114
+ ? `TLS cert expired ${existing.notAfter.toISOString()} — provisioning fresh cert for ${host}`
115
+ : `No TLS cert found — provisioning for ${host}`);
116
+ const ok = await this.tryProvision(host);
117
+ return ok;
118
+ }
119
+ /**
120
+ * Begin the renewal poll + fs.watch loop. Idempotent.
121
+ *
122
+ * The expected ordering at daemon startup is:
123
+ * 1. await mgr.ensureCert() (provision if missing)
124
+ * 2. bind HTTPS server with the cert
125
+ * 3. mgr.start() (renewal + watch)
126
+ * 4. mgr.stop() during shutdown
127
+ */
128
+ start() {
129
+ if (this.pollTimer || this.stopped)
130
+ return;
131
+ this.pollTimer = setInterval(() => {
132
+ void this.tick();
133
+ }, this.opts.pollIntervalMs);
134
+ // Don't keep the event loop alive on the renewal timer alone.
135
+ this.pollTimer.unref?.();
136
+ this.attachWatcher();
137
+ }
138
+ /** Shut down the renewal loop and fs.watch. Idempotent. */
139
+ stop() {
140
+ this.stopped = true;
141
+ if (this.pollTimer) {
142
+ clearInterval(this.pollTimer);
143
+ this.pollTimer = null;
144
+ }
145
+ if (this.watchDebounce) {
146
+ clearTimeout(this.watchDebounce);
147
+ this.watchDebounce = null;
148
+ }
149
+ if (this.watcher) {
150
+ try {
151
+ this.watcher.close();
152
+ }
153
+ catch { }
154
+ this.watcher = null;
155
+ }
156
+ }
157
+ /** Current public status. Caller may serialize this to JSON. */
158
+ getStatus() {
159
+ return { ...this.status };
160
+ }
161
+ /**
162
+ * Force a renewal check now (skips backoff). Returns true if a renewal
163
+ * was attempted and succeeded. Useful for testing and for an eventual
164
+ * `brane certify` that just nudges the daemon instead of running the
165
+ * provisioning itself.
166
+ */
167
+ async renewNow() {
168
+ const host = this.opts.resolveHost();
169
+ if (!host)
170
+ return false;
171
+ return this.tryProvision(host);
172
+ }
173
+ // ----- internals -----
174
+ async tick() {
175
+ if (this.stopped)
176
+ return;
177
+ if (this.renewInflight)
178
+ return;
179
+ const host = this.opts.resolveHost();
180
+ if (!host) {
181
+ // Tailscale went away mid-life. Don't fail loudly — the daemon may
182
+ // still be useful over HTTP. Update host but keep current state.
183
+ if (this.status.host !== null) {
184
+ this.status.host = null;
185
+ this.emitStateChange();
186
+ }
187
+ return;
188
+ }
189
+ if (this.status.host !== host) {
190
+ // Host changed (tailnet rename, etc.) — force a renewal.
191
+ this.status.host = host;
192
+ this.opts.log("info", `Tailscale host changed to ${host} — renewing cert`);
193
+ await this.tryProvision(host);
194
+ return;
195
+ }
196
+ // Respect backoff: if a previous attempt failed and the cooldown
197
+ // hasn't passed, do nothing this tick.
198
+ if (this.status.nextAttemptAt && this.status.nextAttemptAt.getTime() > Date.now()) {
199
+ return;
200
+ }
201
+ const current = inspectCert(this.opts.certPath);
202
+ const now = Date.now();
203
+ const expiresAt = current?.notAfter ?? null;
204
+ const expiresSoon = !current || current.notAfter.getTime() - now < RENEW_THRESHOLD_MS;
205
+ const subjectMismatch = current && this.status.host && !subjectIs(current.subject, this.status.host);
206
+ if (!expiresSoon && !subjectMismatch) {
207
+ // Steady state — nothing to do. Make sure status reflects reality.
208
+ if (this.status.state !== "valid" || this.status.expiresAt?.getTime() !== expiresAt?.getTime()) {
209
+ this.status.state = "valid";
210
+ this.status.expiresAt = expiresAt;
211
+ this.status.lastErrorReason = null;
212
+ this.status.lastErrorMessage = null;
213
+ this.status.nextAttemptAt = null;
214
+ this.emitStateChange();
215
+ }
216
+ return;
217
+ }
218
+ await this.tryProvision(host);
219
+ }
220
+ async tryProvision(host) {
221
+ if (this.renewInflight) {
222
+ await this.renewInflight;
223
+ return this.status.state === "valid";
224
+ }
225
+ this.status.state = "renewing";
226
+ this.emitStateChange();
227
+ let resolveInflight;
228
+ this.renewInflight = new Promise((r) => { resolveInflight = r; });
229
+ try {
230
+ this.opts.log("info", `Provisioning TLS certificate for ${host}...`);
231
+ const result = await provisionTlsCert({
232
+ host,
233
+ certPath: this.opts.certPath,
234
+ keyPath: this.opts.keyPath,
235
+ });
236
+ if (result.ok) {
237
+ this.consecutiveFailures = 0;
238
+ this.status.state = "valid";
239
+ this.status.expiresAt = result.notAfter;
240
+ this.status.lastErrorReason = null;
241
+ this.status.lastErrorMessage = null;
242
+ this.status.nextAttemptAt = null;
243
+ this.opts.log("info", ` Certificate written to ${result.certPath}`);
244
+ this.opts.log("info", ` Key written to ${result.keyPath}`);
245
+ this.opts.log("info", ` Expires: ${result.notAfter.toISOString()}`);
246
+ this.reloadFromDisk();
247
+ this.emitStateChange();
248
+ return true;
249
+ }
250
+ // Failure path: record state, schedule next attempt.
251
+ this.consecutiveFailures++;
252
+ const backoffMs = BACKOFF_SCHEDULE_MS[Math.min(this.consecutiveFailures - 1, BACKOFF_SCHEDULE_MS.length - 1)];
253
+ this.status.state = "failed";
254
+ this.status.lastErrorReason = result.reason;
255
+ this.status.lastErrorMessage = result.message;
256
+ this.status.nextAttemptAt = new Date(Date.now() + backoffMs);
257
+ // Don't clobber expiresAt — the old cert may still be live & valid.
258
+ const existing = inspectCert(this.opts.certPath);
259
+ this.status.expiresAt = existing?.notAfter ?? null;
260
+ this.opts.log("warn", `TLS provisioning failed (${result.reason}): ${result.message}. Next attempt at ${this.status.nextAttemptAt.toISOString()}`);
261
+ if (result.stderr) {
262
+ for (const line of result.stderr.split(/\r?\n/).filter(Boolean)) {
263
+ this.opts.log("warn", ` ${line}`);
264
+ }
265
+ }
266
+ if (result.reason === "https_not_enabled") {
267
+ this.opts.log("warn", "Enable HTTPS for your tailnet at https://login.tailscale.com/admin/dns");
268
+ }
269
+ this.emitStateChange();
270
+ return false;
271
+ }
272
+ finally {
273
+ resolveInflight();
274
+ this.renewInflight = null;
275
+ }
276
+ }
277
+ attachWatcher() {
278
+ if (this.watcher)
279
+ return;
280
+ // Watch the directory rather than the file — atomic rename removes the
281
+ // original inode, which detaches a file-level watcher on most platforms.
282
+ try {
283
+ this.watcher = watch(dirname(this.opts.certPath), (_event, filename) => {
284
+ if (filename !== "tls.crt" && filename !== "tls.key")
285
+ return;
286
+ if (this.watchDebounce)
287
+ clearTimeout(this.watchDebounce);
288
+ const t = setTimeout(() => {
289
+ this.watchDebounce = null;
290
+ this.handleExternalChange();
291
+ }, WATCH_DEBOUNCE_MS);
292
+ // Don't keep the event loop alive on a pending debounce.
293
+ // Without this, node:test's worker process on Linux never exits
294
+ // after the tests pass: the fs.watch callback fires once during
295
+ // mgr.stop()'s teardown, creates a new setTimeout, and the
296
+ // process hangs waiting for it.
297
+ t.unref?.();
298
+ this.watchDebounce = t;
299
+ });
300
+ // Same reason — the inotify handle on Linux keeps the event loop
301
+ // alive even after we've called .close() (close drains async).
302
+ // Unref so the test process can exit cleanly.
303
+ this.watcher.unref?.();
304
+ }
305
+ catch (err) {
306
+ this.opts.log("warn", `Failed to attach cert watcher: ${err.message}`);
307
+ }
308
+ }
309
+ handleExternalChange() {
310
+ if (this.stopped)
311
+ return;
312
+ const inspected = inspectCert(this.opts.certPath);
313
+ if (!inspected)
314
+ return;
315
+ const sameExpiry = this.status.expiresAt &&
316
+ inspected.notAfter.getTime() === this.status.expiresAt.getTime();
317
+ if (sameExpiry)
318
+ return; // no actual change
319
+ this.status.state = "valid";
320
+ this.status.expiresAt = inspected.notAfter;
321
+ this.status.lastErrorReason = null;
322
+ this.status.lastErrorMessage = null;
323
+ this.status.nextAttemptAt = null;
324
+ this.consecutiveFailures = 0;
325
+ this.opts.log("info", `TLS cert reloaded from disk (expires ${inspected.notAfter.toISOString()})`);
326
+ this.reloadFromDisk();
327
+ this.emitStateChange();
328
+ }
329
+ reloadFromDisk() {
330
+ try {
331
+ const cert = readFileSync(this.opts.certPath);
332
+ const key = readFileSync(this.opts.keyPath);
333
+ this.opts.onReload(cert, key);
334
+ }
335
+ catch (err) {
336
+ this.opts.log("error", `Failed to hot-reload cert: ${err.message}`);
337
+ }
338
+ }
339
+ emitStateChange() {
340
+ this.opts.onStateChange?.(this.getStatus());
341
+ }
342
+ }
343
+ function subjectIs(subject, host) {
344
+ const cnLine = subject.split(/\r?\n/).find((l) => l.startsWith("CN="));
345
+ if (!cnLine)
346
+ return false;
347
+ return cnLine.slice(3).trim().toLowerCase() === host.toLowerCase();
348
+ }
349
+ //# sourceMappingURL=cert-manager.js.map
@@ -0,0 +1,262 @@
1
+ import { openDatabase, getPackageVersion } from "../db/schema.js";
2
+ import { request as httpRequest } from "node:http";
3
+ import { existsSync } from "node:fs";
4
+ import { getTailscaleHost } from "../tailscale.js";
5
+ import { ensureDaemonRunning } from "../commands/daemon.js";
6
+ /** Pick the origin a *local* client should use to reach the daemon.
7
+ *
8
+ * Without TLS this is just loopback on the primary port. With TLS it can't
9
+ * be: the cert is issued for the Tailscale hostname and is never valid for
10
+ * `127.0.0.1`, so loopback has to go through the daemon's plain-HTTP
11
+ * fallback listener (private IPs only, hence safe). The Tailscale hostname
12
+ * is the last resort for the narrow window where TLS is up but the fallback
13
+ * port hasn't been recorded yet. */
14
+ export function buildDaemonBaseUrl(addr) {
15
+ if (!addr.tls)
16
+ return `http://127.0.0.1:${addr.port}`;
17
+ if (addr.httpPort)
18
+ return `http://127.0.0.1:${addr.httpPort}`;
19
+ if (addr.tailscaleHost)
20
+ return `https://${addr.tailscaleHost}:${addr.port}`;
21
+ throw new Error("Daemon is running with TLS but has no HTTP fallback port recorded, and " +
22
+ "no Tailscale hostname was found to validate its certificate against.");
23
+ }
24
+ /** Read the daemon's address out of the meta table. `opts.tailscaleHost`
25
+ * injects the hostname instead of shelling out to `tailscale status` — used
26
+ * by tests to keep this deterministic; when the key is present (even as
27
+ * `undefined`) the live lookup is skipped. */
28
+ export function readDaemonAddress(db, port, opts = {}) {
29
+ const metaValue = (key) => db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value;
30
+ const tls = metaValue("daemon_tls") === "true";
31
+ const tailscaleHost = "tailscaleHost" in opts ? opts.tailscaleHost : getTailscaleHost();
32
+ return {
33
+ port,
34
+ tls,
35
+ httpPort: tls ? metaValue("daemon_http_port") : undefined,
36
+ tailscaleHost: tls ? tailscaleHost : undefined,
37
+ };
38
+ }
39
+ /** What this process presents to the daemon. Inside an agent's session the
40
+ * CLI and MCP bridge act as that agent, with the token its launch minted
41
+ * for it, so what they do is attributed to, and revocable with, that
42
+ * narrative. Anywhere else it is nothing (null): this machine holds no
43
+ * token, and a request with none acts as this Unix account when it arrives
44
+ * over the daemon's socket, the one place only this account can reach. No
45
+ * file is read: `.daemon_token`, where older branes kept a local token, is
46
+ * retired (`bootstrapTokens`, src/db/tokens.ts). */
47
+ export function resolveCredential(env = process.env) {
48
+ return env.BRANE_NARRATIVE_SECRET || null;
49
+ }
50
+ /** Resolve a reachable daemon, starting one if none is running.
51
+ *
52
+ * `ensureDaemonRunning` returns as soon as the primary port is recorded, but
53
+ * the HTTP fallback port lands a beat later — so on a TLS daemon we give that
54
+ * row a moment to appear before falling back to the Tailscale hostname. */
55
+ export async function resolveDaemonEndpoint() {
56
+ const address = await ensureDaemonRunning();
57
+ for (let attempt = 0;; attempt++) {
58
+ const db = openDatabase();
59
+ let addr;
60
+ let token;
61
+ let socketPath;
62
+ try {
63
+ addr = readDaemonAddress(db, address.port);
64
+ token = resolveCredential();
65
+ socketPath = readDaemonSocket(db);
66
+ }
67
+ finally {
68
+ db.close();
69
+ }
70
+ if (addr.tls && !addr.httpPort && attempt < 10) {
71
+ await new Promise((r) => setTimeout(r, 100));
72
+ continue;
73
+ }
74
+ return {
75
+ baseUrl: buildDaemonBaseUrl(addr),
76
+ token,
77
+ port: address.port,
78
+ version: address.version,
79
+ ...(socketPath ? { socketPath } : {}),
80
+ };
81
+ }
82
+ }
83
+ /** The socket a running daemon advertised, if its file is there. */
84
+ export function readDaemonSocket(db) {
85
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'daemon_socket'").get();
86
+ return row && existsSync(row.value) ? row.value : undefined;
87
+ }
88
+ /** Send one request to the daemon: over its Unix socket when it has one,
89
+ * else (or when the socket cannot be connected to, so nothing was sent)
90
+ * over TCP to `baseUrl`. */
91
+ export async function sendToDaemon(endpoint, path, init) {
92
+ if (endpoint.socketPath) {
93
+ const viaSocket = await new Promise((resolve, reject) => {
94
+ const req = httpRequest({ socketPath: endpoint.socketPath, path, method: init.method, headers: init.headers }, (res) => {
95
+ const status = res.statusCode ?? 0;
96
+ let consumed = false;
97
+ resolve({
98
+ ok: status >= 200 && status < 300,
99
+ status,
100
+ body: res,
101
+ async text() {
102
+ if (consumed)
103
+ return "";
104
+ consumed = true;
105
+ let out = "";
106
+ res.setEncoding("utf8");
107
+ for await (const chunk of res)
108
+ out += chunk;
109
+ return out;
110
+ },
111
+ });
112
+ });
113
+ // Until the socket connects, nothing of this request has been sent, so
114
+ // any failure (gone, refused, no permission, not a socket) is safe to
115
+ // retry over TCP. After it, the daemon may have acted: report it.
116
+ let connected = false;
117
+ req.on("socket", (sock) => {
118
+ if (!sock.connecting)
119
+ connected = true;
120
+ else
121
+ sock.once("connect", () => { connected = true; });
122
+ });
123
+ req.on("error", (err) => {
124
+ if (!connected)
125
+ resolve(null);
126
+ else
127
+ reject(err);
128
+ });
129
+ if (init.body !== undefined)
130
+ req.write(init.body);
131
+ req.end();
132
+ });
133
+ if (viaSocket)
134
+ return viaSocket;
135
+ }
136
+ // Without a token, TCP would only answer 401: say what actually failed.
137
+ if (endpoint.token === null) {
138
+ throw new Error(endpoint.socketPath
139
+ ? `could not connect to the daemon's socket (${endpoint.socketPath}); \`brane daemon restart\` puts it back`
140
+ : "the daemon is running without its socket (daemon.sock), which is the only way this shell reaches it; " +
141
+ "`brane daemon logs` says why it could not listen there, and `brane doctor` checks it");
142
+ }
143
+ const res = await fetch(`${endpoint.baseUrl}${path}`, init);
144
+ return {
145
+ ok: res.ok,
146
+ status: res.status,
147
+ text: () => res.text(),
148
+ body: res.body,
149
+ };
150
+ }
151
+ /** What a 401 means for a request that presented no token over the socket. */
152
+ const TOKENLESS_REFUSED = "the daemon would not let a request without a token act as this account; " +
153
+ "it does so only while ~/.brane is owned by you and closed to group and other (chmod 700 ~/.brane), " +
154
+ "and `brane daemon logs` says why it refused";
155
+ function authorization(endpoint) {
156
+ return endpoint.token === null ? {} : { Authorization: `Bearer ${endpoint.token}` };
157
+ }
158
+ /** Warn when the daemon we're delegating to was built from different sources
159
+ * than this CLI. It matters more for delegated work than for read-only
160
+ * status: a request field this CLI knows about (say `extra_args`) is simply
161
+ * ignored by an older daemon, with no error to notice. */
162
+ export function warnIfVersionMismatch(endpoint) {
163
+ const myVersion = getPackageVersion();
164
+ if (endpoint.version && endpoint.version !== myVersion) {
165
+ process.stderr.write(`Warning: daemon is v${endpoint.version}, this CLI is v${myVersion}. ` +
166
+ `Run \`brane daemon restart\` to pick up this build.\n`);
167
+ }
168
+ }
169
+ /** Call a daemon JSON API endpoint. Throws with the daemon's own `error`
170
+ * string when it reports one, so CLI output matches what the web app shows. */
171
+ export async function daemonRequest(endpoint, path, init = {}) {
172
+ const res = await sendToDaemon(endpoint, path, {
173
+ method: init.method ?? "GET",
174
+ headers: {
175
+ ...authorization(endpoint),
176
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
177
+ },
178
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
179
+ });
180
+ const text = await res.text();
181
+ let data;
182
+ try {
183
+ data = text ? JSON.parse(text) : {};
184
+ }
185
+ catch {
186
+ data = {};
187
+ }
188
+ if (!res.ok) {
189
+ const message = data && typeof data === "object" && typeof data.error === "string"
190
+ ? data.error
191
+ : `${init.method ?? "GET"} ${path} failed (${res.status})`;
192
+ // Inside an agent's session the CLI presented that agent's token; a 401
193
+ // then almost always means it was revoked or superseded by a revive.
194
+ if (res.status === 401 && process.env.BRANE_NARRATIVE_SECRET && endpoint.token === process.env.BRANE_NARRATIVE_SECRET) {
195
+ throw new Error(`${message}: this session's agent token (BRANE_NARRATIVE_SECRET) was refused, ` +
196
+ "probably revoked; the operator can revive the session");
197
+ }
198
+ if (res.status === 401 && endpoint.token === null)
199
+ throw new Error(`${message}: ${TOKENLESS_REFUSED}`);
200
+ throw new Error(message);
201
+ }
202
+ return data;
203
+ }
204
+ /** Call a daemon endpoint that answers with newline-delimited JSON, handing
205
+ * each line to `onLine` as it arrives — for work that reports progress
206
+ * (`POST /api/absorb`). A non-2xx status is an ordinary JSON error and is
207
+ * thrown the way `daemonRequest` throws it; the stream itself is only ever
208
+ * opened once the daemon has accepted the request. Resolves when the daemon
209
+ * ends the response. A partial trailing line (the connection dropped
210
+ * mid-record) is discarded rather than parsed. */
211
+ export async function daemonStream(endpoint, path, init, onLine) {
212
+ const res = await sendToDaemon(endpoint, path, {
213
+ method: init.method ?? "POST",
214
+ headers: {
215
+ ...authorization(endpoint),
216
+ Accept: "application/x-ndjson",
217
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
218
+ },
219
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
220
+ });
221
+ if (!res.ok) {
222
+ const text = await res.text();
223
+ let message = `${init.method ?? "POST"} ${path} failed (${res.status})`;
224
+ try {
225
+ const data = JSON.parse(text);
226
+ if (typeof data.error === "string")
227
+ message = data.error;
228
+ }
229
+ catch {
230
+ // not JSON; keep the status line
231
+ }
232
+ if (res.status === 401 && endpoint.token === null)
233
+ message = `${message}: ${TOKENLESS_REFUSED}`;
234
+ throw new Error(message);
235
+ }
236
+ if (!res.body)
237
+ return;
238
+ const decoder = new TextDecoder();
239
+ let buffered = "";
240
+ for await (const chunk of res.body) {
241
+ buffered += decoder.decode(chunk, { stream: true });
242
+ let nl;
243
+ while ((nl = buffered.indexOf("\n")) !== -1) {
244
+ const line = buffered.slice(0, nl).trim();
245
+ buffered = buffered.slice(nl + 1);
246
+ if (line)
247
+ onLine(JSON.parse(line));
248
+ }
249
+ }
250
+ // The daemon terminates every record with a newline, so a non-empty
251
+ // remainder is a record the connection cut short.
252
+ const tail = (buffered + decoder.decode()).trim();
253
+ if (tail) {
254
+ try {
255
+ onLine(JSON.parse(tail));
256
+ }
257
+ catch {
258
+ // partial trailing record; discard
259
+ }
260
+ }
261
+ }
262
+ //# sourceMappingURL=client.js.map