brane 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The four routes the presence gate holds, and its cancel route, as
3
+ * functions of the gate, the token ring and store, and who is asking — out
4
+ * of the server's closure so the suite can drive them
5
+ * (`src/test/presence-gate.test.ts`). The server keeps what is about its
6
+ * sockets: logging, and closing what a revoke signed out.
7
+ *
8
+ * Each one checks what it would refuse anyway, then asks, then acts — and
9
+ * the act re-checks, since rows can change while a prompt is up.
10
+ */
11
+ import { gateStanding, revokeSignsOutDevice } from "./presence-gate.js";
12
+ import { mintRemoteToken } from "./token-ring.js";
13
+ import { mintDeviceTokenRoute, parseMintBody, planRevoke, revokeTokenRoute } from "./token-routes.js";
14
+ /** What an agent is told for each gated act: it is the operator's to do. */
15
+ const AGENT_REFUSALS = {
16
+ meld: "An agent cannot add a device (spawn without --meld). The operator melds from a shell outside an agent session, or from the app's Meld sheet",
17
+ "meld-remote": "An agent cannot add a device to a remote daemon; the operator runs `brane meld --remote` from a shell outside an agent session",
18
+ "revoke-device": "An agent cannot sign out a device; the operator does, from a shell outside an agent session or from the app",
19
+ rotate: "An agent's token is replaced by reviving its narrative, not rotated",
20
+ };
21
+ /** Null when `caller` may go on — a device, or a prompt approved — else the
22
+ * refusal to send: an agent's at once, the shell's once the prompt says no. */
23
+ export async function askPresence(gate, act, caller, signal) {
24
+ const standing = gateStanding(caller.record);
25
+ if (standing === "pass")
26
+ return null;
27
+ if (standing === "refuse") {
28
+ gate.refuse(act, caller.by, "an agent's token");
29
+ return { status: 403, body: { error: AGENT_REFUSALS[act], presence: "agent" } };
30
+ }
31
+ const outcome = await gate.check(act, caller.by, signal);
32
+ if (outcome.ok)
33
+ return null;
34
+ return {
35
+ status: outcome.status,
36
+ body: { error: outcome.error, presence: outcome.reason },
37
+ ...(outcome.retryAfterMs !== undefined ? { retryAfterMs: outcome.retryAfterMs } : {}),
38
+ };
39
+ }
40
+ /** `POST /api/tokens`, gated. A body the mint would refuse is refused before
41
+ * anyone is asked. */
42
+ export async function gatedMintRoute(deps, caller, rawBody, signal, now = Date.now) {
43
+ const parsed = parseMintBody(rawBody, now());
44
+ if ("error" in parsed)
45
+ return { status: 400, body: parsed };
46
+ const refused = await askPresence(deps.gate, "meld", caller, signal);
47
+ if (refused)
48
+ return refused;
49
+ return mintDeviceTokenRoute(deps.ring, rawBody, now(), caller.record);
50
+ }
51
+ /** `POST /api/tokens/remote`, gated: a device token for a daemon on another
52
+ * machine, and its hash, for `brane meld --remote`. Nothing is stored here,
53
+ * so the 200 is the only copy of either. */
54
+ export async function gatedRemoteMintRoute(deps, caller, signal) {
55
+ const refused = await askPresence(deps.gate, "meld-remote", caller, signal);
56
+ if (refused)
57
+ return refused;
58
+ return { status: 200, body: (deps.mint ?? mintRemoteToken)() };
59
+ }
60
+ /** `POST /api/tokens/:id/revoke`, gated when it signs out a device; stopping
61
+ * an agent is not. Refusals the revoke would give come back unasked, and
62
+ * the revoke runs its own checks again after the prompt. */
63
+ export async function gatedRevokeRoute(deps, caller, id, also, signal, now = Date.now) {
64
+ const plan = planRevoke(deps.store, caller.record, id, also);
65
+ if ("status" in plan)
66
+ return plan;
67
+ if (revokeSignsOutDevice(deps.store, plan.ids)) {
68
+ const refused = await askPresence(deps.gate, "revoke-device", caller, signal);
69
+ if (refused)
70
+ return refused;
71
+ }
72
+ const revoked = revokeTokenRoute(deps.store, deps.ring, caller.record, id, now(), also);
73
+ return { status: revoked.status, body: revoked.body, revoked };
74
+ }
75
+ /** `POST /api/token/rotate`, gated. An agent's token is bound to its
76
+ * narrative and replaced by reviving it, so agents get 403 unasked, as on
77
+ * every gated act. The
78
+ * prompt comes before `beginRotation`: a second begin replaces a pending
79
+ * rotation, so a refused request must never reach it, or a loop could void
80
+ * the operator's own rotation without passing a prompt.
81
+ *
82
+ * A device begins a rotation and completes it by presenting the token this
83
+ * returns. This account (the shell, over `daemon.sock`) holds no token to
84
+ * swap, so for it the devices are signed out here, in this one request,
85
+ * and the answer is `{ signed_out }` with no token in it. */
86
+ export async function gatedRotateRoute(deps, caller, signal) {
87
+ const refused = await askPresence(deps.gate, "rotate", caller, signal);
88
+ if (refused)
89
+ return refused;
90
+ if (caller.record.kind === "local") {
91
+ const signedOut = deps.ring.signOutDevices(caller.record);
92
+ return { status: 200, body: { signed_out: signedOut.devices, replaced: signedOut.replaced }, signedOut };
93
+ }
94
+ const rotation = deps.ring.beginRotation(caller.record);
95
+ return {
96
+ status: 200,
97
+ body: { token: rotation.token, expires_at: rotation.expiresAt, replaced: rotation.replaced },
98
+ rotation,
99
+ };
100
+ }
101
+ /** `POST /api/presence/cancel?id=`: close the prompt that is up. Only a
102
+ * device may: it is how the operator, away from the machine, says "that one
103
+ * is not mine". Anything on this machine that could cancel could cancel
104
+ * every prompt as it appeared, and with the cooldown keep `brane token
105
+ * rotate` from ever completing; the asker withdraws by disconnecting. */
106
+ export function cancelPresenceRoute(gate, record, id) {
107
+ if (record.kind !== "device") {
108
+ return { status: 403, body: { error: "Only a signed-in device can cancel a presence prompt" } };
109
+ }
110
+ return { status: 200, body: { canceled: gate.cancel(id) } };
111
+ }
112
+ //# sourceMappingURL=gated-routes.js.map
@@ -0,0 +1,103 @@
1
+ // KeepAwake — holds an idle-sleep power assertion while the daemon runs.
2
+ //
3
+ // On macOS the daemon spawns a single hidden `caffeinate -i` child whose
4
+ // lifetime mirrors the daemon's. The child holds a
5
+ // `PreventUserIdleSystemSleep` IOKit assertion, equivalent to running
6
+ // `caffeinate -i` in a terminal, but without occupying a tmux session.
7
+ // Display sleep is unaffected — the screen still dims normally.
8
+ //
9
+ // On other platforms this is a no-op. The status surface lets callers tell
10
+ // "we are deliberately not holding an assertion" (unsupported) apart from
11
+ // "we tried and failed" (unavailable).
12
+ import { spawn } from "node:child_process";
13
+ const RESPAWN_BACKOFF_MS = 5000;
14
+ export class KeepAwake {
15
+ log;
16
+ child = null;
17
+ wantRunning = false;
18
+ respawnTimer = null;
19
+ lastError;
20
+ supported;
21
+ constructor(log = () => { }) {
22
+ this.log = log;
23
+ this.supported = process.platform === "darwin";
24
+ }
25
+ start() {
26
+ if (!this.supported)
27
+ return;
28
+ if (this.wantRunning)
29
+ return;
30
+ this.wantRunning = true;
31
+ this.spawnChild();
32
+ }
33
+ stop() {
34
+ this.wantRunning = false;
35
+ if (this.respawnTimer) {
36
+ clearTimeout(this.respawnTimer);
37
+ this.respawnTimer = null;
38
+ }
39
+ if (this.child && this.child.exitCode === null) {
40
+ try {
41
+ this.child.kill("SIGTERM");
42
+ }
43
+ catch { }
44
+ }
45
+ this.child = null;
46
+ }
47
+ getStatus() {
48
+ if (!this.supported)
49
+ return { state: "unsupported", pid: null };
50
+ if (this.child && this.child.exitCode === null && this.child.pid) {
51
+ return { state: "held", pid: this.child.pid };
52
+ }
53
+ const status = { state: this.wantRunning ? "unavailable" : "released", pid: null };
54
+ if (this.lastError)
55
+ status.last_error = this.lastError;
56
+ return status;
57
+ }
58
+ spawnChild() {
59
+ try {
60
+ const child = spawn("caffeinate", ["-i"], {
61
+ stdio: "ignore",
62
+ detached: false,
63
+ });
64
+ this.child = child;
65
+ child.once("error", (err) => {
66
+ this.lastError = err.message;
67
+ this.log(`caffeinate failed to spawn: ${err.message}`);
68
+ this.child = null;
69
+ this.scheduleRespawn();
70
+ });
71
+ child.once("exit", (code, signal) => {
72
+ if (this.child === child)
73
+ this.child = null;
74
+ if (this.wantRunning) {
75
+ this.lastError = `caffeinate exited (code=${code} signal=${signal})`;
76
+ this.log(this.lastError);
77
+ this.scheduleRespawn();
78
+ }
79
+ });
80
+ this.lastError = undefined;
81
+ this.log(`keep-awake: caffeinate pid=${child.pid} (PreventUserIdleSystemSleep)`);
82
+ }
83
+ catch (err) {
84
+ this.lastError = err.message;
85
+ this.log(`caffeinate spawn threw: ${this.lastError}`);
86
+ this.scheduleRespawn();
87
+ }
88
+ }
89
+ scheduleRespawn() {
90
+ if (!this.wantRunning)
91
+ return;
92
+ if (this.respawnTimer)
93
+ return;
94
+ this.respawnTimer = setTimeout(() => {
95
+ this.respawnTimer = null;
96
+ if (this.wantRunning)
97
+ this.spawnChild();
98
+ }, RESPAWN_BACKOFF_MS);
99
+ if (typeof this.respawnTimer.unref === "function")
100
+ this.respawnTimer.unref();
101
+ }
102
+ }
103
+ //# sourceMappingURL=keep-awake.js.map
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The daemon's Unix-domain socket, `daemon.sock` beside the database.
3
+ *
4
+ * It is how a process on this machine reaches the daemon with the kernel
5
+ * vouching for who it is: connecting means resolving the path, which takes
6
+ * search permission on `~/.brane`, and that directory is 0700 — so only this
7
+ * Unix account (and root) gets in. Loopback TCP cannot say as much: any
8
+ * account on the machine can dial 127.0.0.1. See SECURITY.md, "Who the
9
+ * operator is".
10
+ *
11
+ * A connection over the socket gains loopback standing (`isLoopbackRequest`),
12
+ * and a request on it that presents no token acts as this account — the
13
+ * live `local` row — for as long as `accountDirTrust` holds.
14
+ */
15
+ import { connect } from "node:net";
16
+ import { chmodSync, lstatSync, statSync, unlinkSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { UNIX_SOCKET_SIGHTING } from "../db/tokens.js";
19
+ /** `daemon.sock` beside a database. */
20
+ export function getSocketPath(db) {
21
+ return join(dirname(db.name), "daemon.sock");
22
+ }
23
+ /** Whether the directory holding the socket still makes "reached the
24
+ * socket" mean "is this Unix account": owned by the daemon's own uid, with
25
+ * no group or other permission bits. Both chmods that set this up swallow
26
+ * their errors, and anyone may loosen it later, so it is checked when it is
27
+ * relied on, not once at startup. Follows a symlinked directory, as
28
+ * resolving the socket's path does. */
29
+ export function accountDirTrust(dir) {
30
+ const uid = process.getuid?.();
31
+ if (uid === undefined)
32
+ return { ok: false, reason: "this platform has no Unix uids" };
33
+ let st;
34
+ try {
35
+ st = statSync(dir);
36
+ }
37
+ catch (err) {
38
+ return { ok: false, reason: `${dir}: ${err.code ?? String(err)}` };
39
+ }
40
+ if (st.uid !== uid)
41
+ return { ok: false, reason: `${dir} is owned by uid ${st.uid}, not this daemon's ${uid}` };
42
+ if ((st.mode & 0o077) !== 0) {
43
+ return { ok: false, reason: `${dir} is mode ${(st.mode & 0o777).toString(8)}; other accounts could reach the socket (want 700)` };
44
+ }
45
+ return { ok: true };
46
+ }
47
+ /** The longest path `bind(2)` takes for a Unix socket: `sun_path` is 104
48
+ * bytes on macOS (108 on Linux), terminator included. */
49
+ export const MAX_SOCKET_PATH = 103;
50
+ /** Connections accepted on the Unix socket. `isLoopbackRequest` consults it,
51
+ * since such a socket has no remote address to judge by. */
52
+ const unixConnections = new WeakSet();
53
+ export function markUnixConnection(socket) {
54
+ unixConnections.add(socket);
55
+ }
56
+ export function isUnixConnection(socket) {
57
+ return unixConnections.has(socket);
58
+ }
59
+ /** Where a connection came from, for token sightings and log lines:
60
+ * `daemon.sock` for the Unix socket, which has no address of its own, else
61
+ * the remote address, or null when even that is gone. */
62
+ export function connectionLabel(socket) {
63
+ return isUnixConnection(socket) ? UNIX_SOCKET_SIGHTING : (socket.remoteAddress ?? null);
64
+ }
65
+ /**
66
+ * Make `path` free to listen on. A clean shutdown leaves nothing behind
67
+ * (closing the server unlinks it); a crash leaves the socket file, which
68
+ * `listen()` would refuse. Only a socket whose connect is refused
69
+ * (ECONNREFUSED) is removed: one that accepts a connection belongs to a live
70
+ * daemon, and any other error is no proof either way, so both are left alone.
71
+ * Callers hold the daemon claim, so that would be another brane dir's
72
+ * daemon, or not a daemon at all.
73
+ *
74
+ * Anything at `path` that is not a socket is never touched.
75
+ *
76
+ * Resolves `true` when `path` is free, `false` when it is not and stays so.
77
+ */
78
+ export async function clearStaleSocket(path) {
79
+ let isSocket;
80
+ try {
81
+ isSocket = lstatSync(path).isSocket();
82
+ }
83
+ catch (err) {
84
+ return err.code === "ENOENT"; // nothing there
85
+ }
86
+ if (!isSocket)
87
+ return false;
88
+ // Only a refusal proves nothing is listening. Any other failure — no
89
+ // permission, no file descriptors, a live listener's full backlog — proves
90
+ // nothing, and the path is left alone.
91
+ const refused = await new Promise((resolve) => {
92
+ const probe = connect(path);
93
+ probe.once("connect", () => {
94
+ probe.destroy();
95
+ resolve(false);
96
+ });
97
+ probe.once("error", (err) => resolve(err.code === "ECONNREFUSED"));
98
+ });
99
+ if (!refused)
100
+ return false;
101
+ unlinkSync(path);
102
+ return true;
103
+ }
104
+ /**
105
+ * Start `server` on the socket beside `db` and advertise it as
106
+ * `meta.daemon_socket`. Never throws: a path too long for `bind(2)`, a path
107
+ * that is in use or not a socket, or a failed `listen()` is logged and
108
+ * answered `false`. Agents and devices still reach the daemon over TCP with
109
+ * their tokens; this account's shell, which holds none, cannot. Marks each accepted
110
+ * connection, so `isLoopbackRequest` and `connectionLabel` know it.
111
+ */
112
+ export async function listenOnLocalSocket(server, db, log = (line) => console.warn(line)) {
113
+ const path = getSocketPath(db);
114
+ if (Buffer.byteLength(path) > MAX_SOCKET_PATH) {
115
+ log(`[socket] ${path} is too long for a Unix socket (${Buffer.byteLength(path)} bytes, limit ${MAX_SOCKET_PATH})`);
116
+ return false;
117
+ }
118
+ server.on("connection", (socket) => markUnixConnection(socket));
119
+ try {
120
+ if (!(await clearStaleSocket(path))) {
121
+ log(`[socket] ${path} is in use or not a socket; left alone`);
122
+ return false;
123
+ }
124
+ await new Promise((resolve, reject) => {
125
+ server.once("error", reject);
126
+ server.listen(path, () => {
127
+ server.off("error", reject);
128
+ resolve();
129
+ });
130
+ });
131
+ }
132
+ catch (err) {
133
+ log(`[socket] could not listen on ${path}: ${err.message}`);
134
+ return false;
135
+ }
136
+ // The directory is what keeps other accounts out; on Linux the socket's
137
+ // own mode is checked too.
138
+ try {
139
+ chmodSync(path, 0o600);
140
+ }
141
+ catch { }
142
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_socket', ?)").run(path);
143
+ return true;
144
+ }
145
+ //# sourceMappingURL=local-socket.js.map
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The presence gate: the few acts a process on this machine may not do on
3
+ * its own say-so wait here for the operator to pass the OS's own prompt
4
+ * (`src/presence.ts`). SECURITY.md, "Who the operator is": a process running
5
+ * as this account is not the operator, and acts that reach off this machine
6
+ * or cut the operator off are gated by the presence check.
7
+ *
8
+ * Gated acts, for this machine's shell (no token, over `daemon.sock`):
9
+ *
10
+ * - `meld`: minting a device token (`POST /api/tokens`), which extends reach
11
+ * off this machine;
12
+ * - `meld-remote`: minting a device token for a daemon on another machine
13
+ * (`POST /api/tokens/remote`, `brane meld --remote`). This daemon stores
14
+ * nothing and could not stop a process that made its own token; what the
15
+ * gate holds is the sanctioned path, which then asks what `brane meld`
16
+ * asks;
17
+ * - `revoke-device`: revoking a device token, which cuts the operator off a
18
+ * device — an agent loop could otherwise sign out their phone;
19
+ * - `rotate`: signing out every other device (`POST /api/token/rotate`).
20
+ *
21
+ * A device token is the operator's delegate, minted by an operator act, and
22
+ * is never prompted: the app's Meld sheet, Revoke and "Sign out other
23
+ * devices" work as they always have. An agent's token is refused all three,
24
+ * unasked (`gateStanding`). Who is asking is read from the token's kind,
25
+ * never from the address — a device on this machine's browser is still a
26
+ * device, and an agent is an agent over any transport.
27
+ *
28
+ * Less obvious than it looks:
29
+ *
30
+ * - **The prompt's words are fixed per act.** Nothing from a request reaches
31
+ * them, not even a device's label or user agent, which whoever minted it
32
+ * chose: an agent that could write the prompt could make it read like the
33
+ * one the operator expects ("Unlock 1Password").
34
+ * - **One prompt at a time, and no queue.** A second gated request while one
35
+ * is up is refused at once (409). After a prompt that was not approved,
36
+ * further requests are refused for `cooldownMs` (429), so a loop cannot
37
+ * keep a dialog on screen until it is approved out of habit. A withdrawn
38
+ * prompt counts: give up and ask again is exactly that loop.
39
+ * - **The asker can withdraw.** The route aborts the prompt when its client
40
+ * goes away (Ctrl-C on `brane meld`), so no dialog outlives its request.
41
+ * - **A device can cancel; nothing can approve.** A remote approve would be
42
+ * a device minting a token, which the Meld sheet already does in the open.
43
+ * Only a device may cancel: anything on this machine that could cancel
44
+ * could cancel every prompt as it appeared, and with the cooldown keep
45
+ * `brane token rotate` from ever completing. The asker withdraws by
46
+ * disconnecting instead.
47
+ */
48
+ /** What the OS prompt says. macOS shows it as "osascript is trying to <it>",
49
+ * hence the lowercase verb phrase. */
50
+ export const PRESENCE_PROMPTS = {
51
+ meld: "add a device to brane",
52
+ "meld-remote": "add a device to brane on another machine",
53
+ "revoke-device": "sign out a device from brane",
54
+ rotate: "sign out every other device from brane",
55
+ };
56
+ /** How a caller holding `record` stands before a gated act: a device goes
57
+ * on, an agent is refused unasked, and anything else (this machine's shell)
58
+ * is asked. An agent that follows the rules therefore never puts a dialog
59
+ * in front of the operator — one that read like the operator's own
60
+ * `brane meld` would be approved out of habit. */
61
+ export function gateStanding(record) {
62
+ return record.kind === "device" ? "pass" : record.kind === "agent" ? "refuse" : "ask";
63
+ }
64
+ /** Whether a revoke of `id` (plus `also`) signs out a device, which is the
65
+ * gated part of revoking; stopping an agent is not. Unknown ids are not
66
+ * gated, so a request the route will refuse (404) never raises a prompt. */
67
+ export function revokeSignsOutDevice(store, ids) {
68
+ return ids.some((id) => store.get(id)?.kind === "device");
69
+ }
70
+ export const PRESENCE_TIMEOUT_MS = 45_000;
71
+ export const PRESENCE_COOLDOWN_MS = 15_000;
72
+ export class PresenceGate {
73
+ opts;
74
+ pending = null;
75
+ cooldownUntil = 0;
76
+ timeoutMs;
77
+ cooldownMs;
78
+ now;
79
+ newId;
80
+ constructor(opts) {
81
+ this.opts = opts;
82
+ this.timeoutMs = opts.timeoutMs ?? PRESENCE_TIMEOUT_MS;
83
+ this.cooldownMs = opts.cooldownMs ?? PRESENCE_COOLDOWN_MS;
84
+ this.now = opts.now ?? Date.now;
85
+ let n = 0;
86
+ this.newId = opts.newId ?? (() => `presence-${++n}`);
87
+ }
88
+ /** The prompt that is up, if any, without its handle. */
89
+ current() {
90
+ if (!this.pending)
91
+ return null;
92
+ const { abort: _a, remoteCancel: _r, ...pending } = this.pending;
93
+ return pending;
94
+ }
95
+ /** Ask the operator to approve `act` for `by`. Resolves once they answer,
96
+ * the prompt times out, it is canceled, or `signal` (the request's own
97
+ * lifetime) aborts. Never throws. */
98
+ async check(act, by, signal) {
99
+ const prompt = PRESENCE_PROMPTS[act];
100
+ const now = this.now();
101
+ if (this.pending) {
102
+ const up = this.pending;
103
+ this.log(act, by, "refused: another prompt is up");
104
+ // Name the prompt that is up and who raised it: its dialog may read
105
+ // exactly like the one this caller expected, and approving it would
106
+ // approve someone else's act.
107
+ return {
108
+ ok: false,
109
+ status: 409,
110
+ reason: "busy",
111
+ error: `Another presence prompt is up ("${up.prompt}", asked by ${up.by}). ` +
112
+ "If you did not start it, decline it at the machine or cancel it from a signed-in device, then try again",
113
+ };
114
+ }
115
+ if (now < this.cooldownUntil) {
116
+ const retryAfterMs = this.cooldownUntil - now;
117
+ this.log(act, by, "refused: cooling down after a prompt that was not approved");
118
+ return {
119
+ ok: false,
120
+ status: 429,
121
+ reason: "cooldown",
122
+ retryAfterMs,
123
+ error: `A presence prompt was just declined, withdrawn or left unanswered; try again in ${Math.ceil(retryAfterMs / 1000)} s`,
124
+ };
125
+ }
126
+ if (signal?.aborted)
127
+ return { ok: false, status: 403, reason: "aborted", error: "The request was withdrawn" };
128
+ const abort = new AbortController();
129
+ const onAbort = () => abort.abort();
130
+ signal?.addEventListener("abort", onAbort, { once: true });
131
+ const pending = {
132
+ id: this.newId(),
133
+ act,
134
+ prompt,
135
+ by,
136
+ startedAt: now,
137
+ expiresAt: now + this.timeoutMs,
138
+ abort,
139
+ remoteCancel: false,
140
+ };
141
+ this.pending = pending;
142
+ this.opts.onChange?.(this.current());
143
+ let result;
144
+ try {
145
+ result = await this.opts.confirm(prompt, { timeoutMs: this.timeoutMs, signal: abort.signal });
146
+ }
147
+ catch (err) {
148
+ result = { ok: false, method: null, reason: "failed", detail: err instanceof Error ? err.message : String(err) };
149
+ }
150
+ finally {
151
+ signal?.removeEventListener("abort", onAbort);
152
+ this.pending = null;
153
+ this.opts.onChange?.(null);
154
+ }
155
+ if (result.ok) {
156
+ this.log(act, by, `approved (${result.method})`);
157
+ return { ok: true };
158
+ }
159
+ const reason = pending.remoteCancel ? "remote-cancel" : result.reason;
160
+ // After every prompt that was shown and not approved — a withdrawn one
161
+ // too, or a loop could give up and ask again with no pause between
162
+ // dialogs. Only `unavailable` showed nothing.
163
+ if (reason !== "unavailable")
164
+ this.cooldownUntil = this.now() + this.cooldownMs;
165
+ this.log(act, by, `${reason}${result.detail ? `: ${result.detail}` : ""}`);
166
+ return { ok: false, status: 403, reason, error: refusalMessage(reason, result.detail) };
167
+ }
168
+ /** Record an attempt refused without a prompt — an agent's token asking
169
+ * for an operator's act — so it leaves the same trace as one that was
170
+ * asked. An agent trying to add a device or sign out the operator's phone
171
+ * is the attempt most worth seeing. */
172
+ refuse(act, by, why) {
173
+ this.log(act, by, `refused: ${why}`);
174
+ }
175
+ /** Close the prompt that is up. With `id`, only that prompt — so a stale
176
+ * Cancel tap cannot close a newer one. Returns whether one was closed.
177
+ * Who may call it is the route's to decide (`cancelPresenceRoute`). */
178
+ cancel(id) {
179
+ const p = this.pending;
180
+ if (!p || (id !== undefined && id !== p.id))
181
+ return false;
182
+ p.remoteCancel = true;
183
+ p.abort.abort();
184
+ return true;
185
+ }
186
+ log(act, by, outcome) {
187
+ this.opts.log?.(`[presence] ${act} for ${by}: ${outcome}`);
188
+ }
189
+ }
190
+ function refusalMessage(reason, detail) {
191
+ switch (reason) {
192
+ case "canceled":
193
+ return "The presence prompt was declined at this machine";
194
+ case "remote-cancel":
195
+ return "The presence prompt was canceled from a signed-in device";
196
+ case "timeout":
197
+ return "Nobody answered the presence prompt in time";
198
+ case "aborted":
199
+ return "The request was withdrawn";
200
+ case "unavailable":
201
+ return `This machine cannot show a presence prompt${detail ? ` (${detail})` : ""}; this needs the operator at a machine that can, or a signed-in device`;
202
+ default:
203
+ return `The presence check failed${detail ? `: ${detail}` : ""}`;
204
+ }
205
+ }
206
+ //# sourceMappingURL=presence-gate.js.map