brane 0.1.1 → 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 (348) 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 +79 -27
  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/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
package/dist/tls.js ADDED
@@ -0,0 +1,148 @@
1
+ // TLS certificate management for the brane daemon.
2
+ //
3
+ // The daemon serves over HTTPS using Tailscale-issued Let's Encrypt certs.
4
+ // `provisionTlsCert` shells out to `tailscale cert` (the same command the
5
+ // `brane certify` CLI uses) and lands a fresh cert/key pair on disk.
6
+ // `parseCertExpiry` reads a cert file and returns its `notAfter` so the
7
+ // CertManager can decide when to renew.
8
+ //
9
+ // Both functions are pure plumbing — no schedule, no state, no logging.
10
+ // The renewal/scheduling/hot-reload policy lives in src/daemon/cert-manager.ts.
11
+ import { execFile } from "node:child_process";
12
+ import { X509Certificate } from "node:crypto";
13
+ import { readFileSync, renameSync } from "node:fs";
14
+ /**
15
+ * Provision a TLS cert+key pair for `host` via `tailscale cert`.
16
+ *
17
+ * Writes to temporary `.new` files, validates the result (parses, future
18
+ * `notAfter`, subject CN matches `host`), then atomically renames them into
19
+ * place. Partial failures leave the existing cert intact.
20
+ */
21
+ export function provisionTlsCert(opts) {
22
+ const { host, certPath, keyPath, timeoutMs = 30000 } = opts;
23
+ const tmpCert = certPath + ".new";
24
+ const tmpKey = keyPath + ".new";
25
+ return new Promise((resolve) => {
26
+ execFile("tailscale", ["cert", "--cert-file", tmpCert, "--key-file", tmpKey, host], { timeout: timeoutMs }, (err, _stdout, stderr) => {
27
+ if (err) {
28
+ resolve({
29
+ ok: false,
30
+ reason: classifyTailscaleError(err, stderr),
31
+ message: err.message,
32
+ stderr,
33
+ });
34
+ return;
35
+ }
36
+ // tailscale cert exited 0 — validate before swapping in.
37
+ let parsed;
38
+ try {
39
+ parsed = new X509Certificate(readFileSync(tmpCert));
40
+ }
41
+ catch (parseErr) {
42
+ resolve({
43
+ ok: false,
44
+ reason: "invalid_output",
45
+ message: `Provisioned cert at ${tmpCert} did not parse: ${parseErr.message}`,
46
+ });
47
+ return;
48
+ }
49
+ const notAfter = parseValidTo(parsed.validTo);
50
+ if (!notAfter || notAfter.getTime() <= Date.now()) {
51
+ resolve({
52
+ ok: false,
53
+ reason: "invalid_output",
54
+ message: `Provisioned cert has invalid or past notAfter: ${parsed.validTo}`,
55
+ });
56
+ return;
57
+ }
58
+ // Subject should match the requested host. tailscale puts the host
59
+ // in the CN; we accept any cert whose subject CN equals host, which
60
+ // is what tailscale always produces.
61
+ if (!subjectMatchesHost(parsed, host)) {
62
+ resolve({
63
+ ok: false,
64
+ reason: "invalid_output",
65
+ message: `Provisioned cert subject ${parsed.subject} does not match host ${host}`,
66
+ });
67
+ return;
68
+ }
69
+ try {
70
+ renameSync(tmpCert, certPath);
71
+ renameSync(tmpKey, keyPath);
72
+ }
73
+ catch (renameErr) {
74
+ resolve({
75
+ ok: false,
76
+ reason: "unknown",
77
+ message: `Failed to install provisioned cert: ${renameErr.message}`,
78
+ });
79
+ return;
80
+ }
81
+ resolve({ ok: true, certPath, keyPath, notAfter });
82
+ });
83
+ });
84
+ }
85
+ /** Read a cert file and return its X.509 `notAfter` date. */
86
+ export function parseCertExpiry(certPath) {
87
+ try {
88
+ const cert = new X509Certificate(readFileSync(certPath));
89
+ return parseValidTo(cert.validTo);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /** Read a cert file and return both expiry and subject in one shot. */
96
+ export function inspectCert(certPath) {
97
+ try {
98
+ const cert = new X509Certificate(readFileSync(certPath));
99
+ const notAfter = parseValidTo(cert.validTo);
100
+ if (!notAfter)
101
+ return null;
102
+ return { notAfter, subject: cert.subject };
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Parse the X509Certificate#validTo string into a Date.
110
+ *
111
+ * Node added `X509Certificate#validToDate` in v23.0.0; on Node 20 (our LTS
112
+ * baseline) only the string form is available. The string is OpenSSL's
113
+ * ASN1_TIME format, e.g. "Jun 21 14:41:05 2026 GMT" — a format `new Date()`
114
+ * parses reliably across V8 versions on every platform we ship to.
115
+ */
116
+ function parseValidTo(validTo) {
117
+ if (!validTo)
118
+ return null;
119
+ const d = new Date(validTo);
120
+ return Number.isNaN(d.getTime()) ? null : d;
121
+ }
122
+ function classifyTailscaleError(err, stderr) {
123
+ if (err.code === "ENOENT")
124
+ return "tailscale_missing";
125
+ if (err.signal === "SIGTERM" || err.killed)
126
+ return "timeout";
127
+ const haystack = (stderr ?? "").toLowerCase();
128
+ // Tailscale messaging is stable enough to pattern-match. Examples we've
129
+ // seen in the wild:
130
+ // "HTTPS must be enabled..." (admin console toggle off)
131
+ // "HTTPS is not enabled for this tailnet"
132
+ // "no such host" / "is not in your tailnet" (wrong host)
133
+ if (haystack.includes("https") && (haystack.includes("not enabled") || haystack.includes("must be enabled"))) {
134
+ return "https_not_enabled";
135
+ }
136
+ if (haystack.includes("no such host") || haystack.includes("not in your tailnet")) {
137
+ return "bad_host";
138
+ }
139
+ return "unknown";
140
+ }
141
+ function subjectMatchesHost(cert, host) {
142
+ // X509Certificate.subject is multi-line "CN=foo\nO=bar". Pull the CN.
143
+ const cnLine = cert.subject.split(/\r?\n/).find((l) => l.startsWith("CN="));
144
+ if (!cnLine)
145
+ return false;
146
+ return cnLine.slice(3).trim().toLowerCase() === host.toLowerCase();
147
+ }
148
+ //# sourceMappingURL=tls.js.map
package/dist/tmux.js ADDED
@@ -0,0 +1,202 @@
1
+ import { accessSync, constants, existsSync, mkdirSync, chmodSync, unlinkSync, createWriteStream, createReadStream, } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { homedir, platform, arch } from "node:os";
5
+ import { get as httpsGet } from "node:https";
6
+ import { createHash } from "node:crypto";
7
+ let cachedPath;
8
+ const TMUX_VERSION = "3.6a";
9
+ const BRANE_BIN = join(homedir(), ".brane", "bin");
10
+ const BUNDLED_TMUX = join(BRANE_BIN, "tmux");
11
+ // Map Node.js platform/arch to tmux-builds release-asset naming.
12
+ const PLATFORM_MAP = { darwin: "macos", linux: "linux" };
13
+ const ARCH_MAP = { arm64: "arm64", x64: "x86_64" };
14
+ // Pinned SHA-256 digests for tmux-builds v3.6a release assets, keyed by
15
+ // `${os}-${cpu}`. Sourced from the GitHub release API's asset `digest`
16
+ // field. If an upstream asset is ever re-uploaded or substituted, the
17
+ // hash check below fails closed and we refuse to extract/run it.
18
+ const SHA256 = {
19
+ "linux-arm64": "bb5afd9d646df54a7d7c66e198aa22c7d293c7453534f1670f7c540534db8b5e",
20
+ "linux-x86_64": "c0a772a5e6ca8f129b0111d10029a52e02bcbc8352d5a8c0d3de8466a1e59c2e",
21
+ "macos-arm64": "12b5b9f8696e1286897d946649c0a80d0169dd76e018d34476a1fbd34de89a0f",
22
+ "macos-x86_64": "b9b12eaeba43acf5671acf3857d947525440b544185a8db34ea557199a090251",
23
+ };
24
+ /** Scan PATH for an executable — pure in-process, no subprocess spawn. */
25
+ function findInPath(name) {
26
+ for (const dir of (process.env.PATH ?? "").split(":")) {
27
+ if (!dir)
28
+ continue;
29
+ const candidate = join(dir, name);
30
+ try {
31
+ accessSync(candidate, constants.X_OK);
32
+ return candidate;
33
+ }
34
+ catch { }
35
+ }
36
+ return undefined;
37
+ }
38
+ /** Resolve the tmux binary: system PATH first, then ~/.brane/bin/, then error. */
39
+ export function resolveTmux() {
40
+ if (cachedPath)
41
+ return cachedPath;
42
+ // 1. System PATH (in-process scan, no subprocess)
43
+ const systemTmux = findInPath("tmux");
44
+ if (systemTmux) {
45
+ cachedPath = systemTmux;
46
+ return cachedPath;
47
+ }
48
+ // 2. Binary fetched lazily by ensureTmux() into ~/.brane/bin/
49
+ try {
50
+ accessSync(BUNDLED_TMUX, constants.X_OK);
51
+ cachedPath = BUNDLED_TMUX;
52
+ return cachedPath;
53
+ }
54
+ catch { }
55
+ throw new Error("tmux not found. Install it with: brew install tmux (macOS) or apt install tmux (Linux)");
56
+ }
57
+ let ensurePromise;
58
+ /**
59
+ * Ensure a usable tmux exists, downloading a verified static binary into
60
+ * ~/.brane/bin/ if neither a system tmux nor a previously-fetched one is
61
+ * present. Best-effort and idempotent: single-flighted, never throws, and
62
+ * never blocks the caller's success path — if it cannot provide tmux,
63
+ * resolveTmux() will surface the actionable "install tmux" error when a
64
+ * tmux-dependent operation is actually attempted.
65
+ */
66
+ export function ensureTmux() {
67
+ if (!ensurePromise)
68
+ ensurePromise = doEnsureTmux();
69
+ return ensurePromise;
70
+ }
71
+ async function doEnsureTmux() {
72
+ // Already satisfiable — system tmux or a prior fetch.
73
+ if (findInPath("tmux"))
74
+ return;
75
+ try {
76
+ accessSync(BUNDLED_TMUX, constants.X_OK);
77
+ return;
78
+ }
79
+ catch { }
80
+ const os = PLATFORM_MAP[platform()];
81
+ const cpu = ARCH_MAP[arch()];
82
+ if (!os || !cpu) {
83
+ console.error(`brane: no prebuilt tmux for ${platform()}-${arch()}; install tmux manually`);
84
+ return;
85
+ }
86
+ const key = `${os}-${cpu}`;
87
+ const expected = SHA256[key];
88
+ const url = `https://github.com/tmux/tmux-builds/releases/download/v${TMUX_VERSION}/tmux-${TMUX_VERSION}-${key}.tar.gz`;
89
+ const tarball = join(BRANE_BIN, `tmux-${TMUX_VERSION}.tar.gz`);
90
+ try {
91
+ mkdirSync(BRANE_BIN, { recursive: true });
92
+ console.error("brane: tmux not found, downloading static binary…");
93
+ // Download (follows redirects — GitHub releases redirect to a CDN).
94
+ await download(url, tarball);
95
+ // Verify integrity before trusting the archive. Fail closed: on any
96
+ // mismatch (or missing pin) delete the file and refuse to extract or
97
+ // run an unverified binary.
98
+ const actual = await sha256File(tarball);
99
+ if (!expected || actual !== expected) {
100
+ try {
101
+ unlinkSync(tarball);
102
+ }
103
+ catch { }
104
+ console.error(`brane: tmux archive failed SHA-256 verification (expected ${expected ?? "n/a"}, got ${actual}); refusing to install. Install manually: brew install tmux`);
105
+ return;
106
+ }
107
+ // Extract — the tarball contains a bare "tmux" at its root.
108
+ const { execFileSync } = await import("node:child_process");
109
+ execFileSync("tar", ["xzf", tarball, "-C", BRANE_BIN, "tmux"], {
110
+ stdio: "ignore",
111
+ });
112
+ try {
113
+ unlinkSync(tarball);
114
+ }
115
+ catch { }
116
+ if (existsSync(BUNDLED_TMUX)) {
117
+ chmodSync(BUNDLED_TMUX, 0o755);
118
+ console.error(`brane: tmux ${TMUX_VERSION} installed to ${BUNDLED_TMUX}`);
119
+ }
120
+ else {
121
+ console.error("brane: failed to extract tmux binary from archive");
122
+ }
123
+ }
124
+ catch (err) {
125
+ try {
126
+ unlinkSync(tarball);
127
+ }
128
+ catch { }
129
+ console.error(`brane: failed to download tmux (${err.message}). Install manually: brew install tmux`);
130
+ }
131
+ }
132
+ /** Compute the SHA-256 hex digest of a file by streaming it. */
133
+ function sha256File(path) {
134
+ return new Promise((resolve, reject) => {
135
+ const hash = createHash("sha256");
136
+ const stream = createReadStream(path);
137
+ stream.on("data", (chunk) => hash.update(chunk));
138
+ stream.on("end", () => resolve(hash.digest("hex")));
139
+ stream.on("error", reject);
140
+ });
141
+ }
142
+ /** Download a URL to a file, following up to 5 redirects. */
143
+ function download(url, dest, redirects = 0) {
144
+ if (redirects > 5)
145
+ return Promise.reject(new Error("Too many redirects"));
146
+ return new Promise((resolve, reject) => {
147
+ const file = createWriteStream(dest);
148
+ httpsGet(url, (res) => {
149
+ const status = res.statusCode ?? 0;
150
+ if (status >= 300 && status < 400 && res.headers.location) {
151
+ file.close();
152
+ download(res.headers.location, dest, redirects + 1).then(resolve, reject);
153
+ return;
154
+ }
155
+ if (status !== 200) {
156
+ file.close();
157
+ reject(new Error(`HTTP ${status}`));
158
+ return;
159
+ }
160
+ res.pipe(file);
161
+ file.on("finish", () => file.close(() => resolve()));
162
+ file.on("error", reject);
163
+ }).on("error", (err) => {
164
+ file.close();
165
+ reject(err);
166
+ });
167
+ });
168
+ }
169
+ /** A tmux command-language word: single-quoted, nothing inside is special
170
+ * (`$`, `~`, `#`, `;`), and a `'` is closed, escaped and reopened. */
171
+ export function tmuxQuote(word) {
172
+ return `'${word.replace(/'/g, `'\\''`)}'`;
173
+ }
174
+ /** The `new-session` line `newTmuxSession` sends, as tmux will parse it. */
175
+ export function newSessionLine(opts) {
176
+ const parts = ["new-session", "-d", "-s", tmuxQuote(opts.name), "-c", tmuxQuote(opts.cwd)];
177
+ if (opts.size)
178
+ parts.push("-x", String(opts.size.cols), "-y", String(opts.size.rows));
179
+ parts.push(tmuxQuote(opts.command));
180
+ return parts.join(" ");
181
+ }
182
+ /** Start a detached tmux session running `command`, without the command
183
+ * ever appearing in a process's arguments. A launch command carries the
184
+ * session's agent token (`BRANE_NARRATIVE_SECRET=…`), and arguments are
185
+ * readable by every user on the machine through `ps` — for the client's
186
+ * brief life, and for the tmux *server's* whole life when this call is the
187
+ * one that starts it, since the server is forked from that client and keeps
188
+ * its argv. So the command goes to `tmux source-file -` on stdin, and what
189
+ * `ps` shows is `tmux source-file -`. Throws if tmux refuses (a duplicate
190
+ * session name, say). */
191
+ export function newTmuxSession(opts) {
192
+ // `source-file` alone needs a server already running, and tmux exits with
193
+ // its last session, so the first launch after a reboot would fail
194
+ // (claude-jrjh1e8w:22). `start-server` starts one if needed — with this
195
+ // argv, which carries no secret — and is a no-op otherwise.
196
+ const socketArgs = opts.socket ? ["-S", opts.socket] : [];
197
+ execFileSync(resolveTmux(), [...socketArgs, "start-server", ";", "source-file", "-"], {
198
+ input: newSessionLine(opts) + "\n",
199
+ stdio: ["pipe", "ignore", "pipe"],
200
+ });
201
+ }
202
+ //# sourceMappingURL=tmux.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A short "Safari on iPhone" for a User-Agent header, for the Devices list.
3
+ * It is a label for a human telling rows apart, not a detection anyone should
4
+ * branch on: User-Agents lie (an iPad asks for desktop sites as a Mac), and
5
+ * that is fine here — the row also shows where and when it was seen.
6
+ *
7
+ * Order matters: every Chromium browser also says "Chrome" and "Safari", and
8
+ * every iOS browser is WebKit underneath and says "Safari" or nothing.
9
+ */
10
+ const BROWSERS = [
11
+ [/\bEdg(?:e|A|iOS)?\//, "Edge"],
12
+ [/\bOPR\/|\bOPiOS\//, "Opera"],
13
+ [/\bFirefox\/|\bFxiOS\//, "Firefox"],
14
+ [/\bCriOS\//, "Chrome"],
15
+ [/\bChrome\//, "Chrome"],
16
+ [/\bVersion\/[\d.]+.*\bSafari\//, "Safari"],
17
+ ];
18
+ const PLATFORMS = [
19
+ [/\biPhone\b/, "iPhone"],
20
+ [/\biPad\b/, "iPad"],
21
+ [/\bAndroid\b/, "Android"],
22
+ [/\bCrOS\b/, "ChromeOS"],
23
+ [/\bMacintosh\b|\bMac OS X\b/, "macOS"],
24
+ [/\bWindows\b/, "Windows"],
25
+ [/\bLinux\b/, "Linux"],
26
+ ];
27
+ export function describeUserAgent(ua) {
28
+ if (!ua)
29
+ return null;
30
+ const browser = BROWSERS.find(([re]) => re.test(ua))?.[1];
31
+ const platform = PLATFORMS.find(([re]) => re.test(ua))?.[1];
32
+ if (browser && platform)
33
+ return `${browser} on ${platform}`;
34
+ // An app saved to an iPhone's home screen drops the Safari token and keeps
35
+ // only WebKit's.
36
+ if (platform && /\bAppleWebKit\//.test(ua))
37
+ return `Web app on ${platform}`;
38
+ if (browser)
39
+ return browser;
40
+ if (platform)
41
+ return platform;
42
+ // Not a browser: `node`, `curl/8.7.1`. Its product name is the best label.
43
+ const product = /^([A-Za-z][\w.-]*)/.exec(ua)?.[1];
44
+ return product && product !== "Mozilla" ? product : null;
45
+ }
46
+ //# sourceMappingURL=user-agent.js.map
@@ -0,0 +1,141 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Volume is mounted at /home/brane (the entire home directory).
5
+ # Migrate from old layout where volume was mounted at /home/brane/.brane
6
+ # and contained brane.db at the volume root.
7
+ if [ -f /home/brane/brane.db ] && [ ! -d /home/brane/.brane/db ]; then
8
+ echo "Migrating volume layout: moving contents into .brane/ subdirectory..."
9
+ mkdir -p /home/brane/.brane
10
+ # Move everything except .brane itself into .brane/
11
+ for f in /home/brane/*; do
12
+ [ "$(basename "$f")" = ".brane" ] && continue
13
+ mv "$f" /home/brane/.brane/
14
+ done
15
+ for f in /home/brane/.[!.]* /home/brane/..?*; do
16
+ [ -e "$f" ] || continue
17
+ [ "$(basename "$f")" = ".brane" ] && continue
18
+ mv "$f" /home/brane/.brane/
19
+ done
20
+ echo "Migration complete."
21
+ fi
22
+
23
+ # Ensure the brane user owns the home directory
24
+ chown -R brane:brane /home/brane
25
+
26
+ # Copy tmux binary into the volume if missing (the image layer copy is
27
+ # hidden when the persistent volume mounts over /home/brane)
28
+ if [ ! -f /home/brane/.brane/bin/tmux ] && [ -f /root/.brane/bin/tmux ]; then
29
+ mkdir -p /home/brane/.brane/bin
30
+ cp /root/.brane/bin/tmux /home/brane/.brane/bin/tmux
31
+ chown brane:brane /home/brane/.brane/bin/tmux
32
+ fi
33
+
34
+ # Tailscale state lives inside the brane data volume (single mount on Fly)
35
+ TS_STATE=/home/brane/.brane/tailscale
36
+ mkdir -p "$TS_STATE"
37
+
38
+ # Start tailscaled as root (needs privileges for networking)
39
+ tailscaled --tun=userspace-networking --statedir="$TS_STATE" &
40
+
41
+ # Wait for the tailscaled socket
42
+ for i in $(seq 1 10); do
43
+ [ -S /var/run/tailscale/tailscaled.sock ] && break
44
+ sleep 1
45
+ done
46
+
47
+ # Authenticate with Tailscale (as root).
48
+ # Build hostname: append Fly region if available (e.g., brane-5a04ec-nrt)
49
+ TS_HOSTNAME="${TS_HOSTNAME:-brane}"
50
+ if [ -n "$FLY_REGION" ]; then
51
+ TS_HOSTNAME="${TS_HOSTNAME}-${FLY_REGION}"
52
+ fi
53
+ # The node is tag-owned, so its key never expires. Every `up` names the tags:
54
+ # one without them asks control to drop them, which needs a login nobody here
55
+ # can complete. --timeout makes any such wait fail instead of hang.
56
+ TS_TAGS="${TS_TAGS:-tag:brane}"
57
+ TS_UP=(tailscale up --reset --hostname="$TS_HOSTNAME" --advertise-tags="$TS_TAGS")
58
+
59
+ # Fly's OIDC token for this machine (`sub` is <org>:<app>:<machine>), which
60
+ # Tailscale's workload identity federation trades for a node key.
61
+ fly_oidc_token() {
62
+ local out
63
+ out=$(curl -fsS --unix-socket /.fly/api -X POST http://localhost/v1/tokens/oidc \
64
+ -H 'Content-Type: application/json' --data "{\"aud\":\"$1\"}") || return 1
65
+ case "$out" in
66
+ \{*) printf '%s' "$out" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);process.stdout.write(j.token||j.jwt||j.id_token||"")})' ;;
67
+ *) printf '%s' "$out" | tr -d '"\r\n' ;;
68
+ esac
69
+ }
70
+
71
+ # The state on the volume first (every restart after the first boot), then a
72
+ # credential: TS_CLIENT_ID (workload identity: no secret on Fly) or TS_AUTHKEY
73
+ # (an auth key or OAuth client secret allowed to apply $TS_TAGS).
74
+ if "${TS_UP[@]}" --timeout=30s; then
75
+ :
76
+ elif [ -n "$TS_CLIENT_ID" ]; then
77
+ ID_TOKEN=$(fly_oidc_token "${TS_AUDIENCE:-api.tailscale.com/${TS_CLIENT_ID}}") || ID_TOKEN=
78
+ if [ -z "$ID_TOKEN" ]; then
79
+ echo "entrypoint: could not get an OIDC token from Fly for Tailscale" >&2
80
+ exit 1
81
+ fi
82
+ "${TS_UP[@]}" --timeout=60s --client-id="${TS_CLIENT_ID}?ephemeral=false&preauthorized=true" --id-token="$ID_TOKEN"
83
+ elif [ -n "$TS_AUTHKEY" ]; then
84
+ "${TS_UP[@]}" --timeout=60s --auth-key="$TS_AUTHKEY"
85
+ else
86
+ echo "entrypoint: not on the tailnet, and neither TS_CLIENT_ID nor TS_AUTHKEY is set" >&2
87
+ exit 1
88
+ fi
89
+
90
+ # Allow the brane user to manage Tailscale (needed for cert provisioning)
91
+ tailscale set --operator=brane || true
92
+
93
+ # Provision TLS cert from Tailscale (non-fatal if it fails)
94
+ gosu brane brane certify 2>/dev/null || true
95
+
96
+ # Ensure workspace directory exists on the volume
97
+ mkdir -p /home/brane/workspace
98
+ chown brane:brane /home/brane/workspace
99
+
100
+ # Ensure .mcp.json exists so agents have brane MCP tools from the start
101
+ MCP_JSON=/home/brane/workspace/.mcp.json
102
+ if [ ! -f "$MCP_JSON" ]; then
103
+ cat > "$MCP_JSON" <<'MCPEOF'
104
+ {"mcpServers":{"brane":{"command":"node","args":["/usr/local/bin/brane","mcp"]}}}
105
+ MCPEOF
106
+ chown brane:brane "$MCP_JSON"
107
+ fi
108
+
109
+ # Clear stale daemon meta from the persisted volume — PIDs from a previous
110
+ # container life are never valid in a new container, but may collide with
111
+ # a live PID here (e.g., tailscaled) and trip the "already running" check.
112
+ gosu brane sqlite3 /home/brane/.brane/brane.db \
113
+ "DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','daemon_http_port','daemon_socket');" \
114
+ 2>/dev/null || true
115
+
116
+ # Register the default workspace as a fold (idempotent) once the daemon is up.
117
+ # `brane fold` is a daemon client: run before the daemon, it would start one
118
+ # of its own, and the exec below would start a second on the same database.
119
+ # The daemon records its port only after its socket is bound, and the rows
120
+ # above are gone, so a port here means the daemon below is ready.
121
+ (
122
+ for i in $(seq 1 120); do
123
+ port=$(gosu brane sqlite3 /home/brane/.brane/brane.db \
124
+ "SELECT value FROM meta WHERE key = 'daemon_port';" 2>/dev/null || true)
125
+ if [ -n "$port" ]; then
126
+ gosu brane brane fold /home/brane/workspace >/dev/null ||
127
+ echo "entrypoint: could not register /home/brane/workspace as a fold" >&2
128
+ exit 0
129
+ fi
130
+ sleep 1
131
+ done
132
+ echo "entrypoint: no daemon after 120s; /home/brane/workspace is not registered as a fold" >&2
133
+ ) &
134
+
135
+ # Drop privileges and run the daemon as the brane user.
136
+ # --bind 0.0.0.0: tailscaled --tun=userspace-networking doesn't put the
137
+ # 100.x.y.z address on a kernel interface, so a specific bind to it fails
138
+ # with EADDRNOTAVAIL. Binding broadly is safe here — Fly's only reachable
139
+ # networks are Tailscale and the private 6PN, both of which the userspace
140
+ # IP filter already restricts.
141
+ exec gosu brane brane daemon start --foreground --bind 0.0.0.0 "$@"
package/docs/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # docs/
2
+
3
+ Design notes for brane's subsystems, one file each. They are indexed, with a line on what each covers and which code it goes with, in [AGENTS.md's Further Reading](../AGENTS.md#further-reading). The rules that apply to every change are in [AGENTS.md](../AGENTS.md) itself.