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,646 @@
1
+ /**
2
+ * The Devices list: where each token was first presented from, the list
3
+ * itself, and revoking one device. The store tests run on a real (test)
4
+ * database because the sighting is a column the migration ladder adds.
5
+ */
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createTestDb } from "./helpers.js";
9
+ import { DbTokenStore, adoptBindTokens, legacyTokenWarning, mintAgentToken, normalizeRemoteAddr, recordLauncher, verifyAgentToken } from "../db/tokens.js";
10
+ import { TokenRing, UNUSED_TOKEN_LAPSE_MS, WS_CLOSE_TOKEN_ROTATED, hashToken } from "../daemon/token-ring.js";
11
+ import { closeSocketsOf, closesOnRevoke, listTokensRoute, mintDeviceTokenRoute, revokeTokenRoute, socketOwners } from "../daemon/token-routes.js";
12
+ import { resolveMcpBind } from "../daemon/server.js";
13
+ import { buildDaemonEnv } from "../commands/daemon.js";
14
+ import { INHERITED_AGENT_ENV, unsetAgentEnvPrefix } from "../agent-env.js";
15
+ import { newSessionLine, newTmuxSession, tmuxQuote } from "../tmux.js";
16
+ import { execFileSync } from "node:child_process";
17
+ import { mkdtempSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { describeUserAgent } from "../user-agent.js";
21
+ import { formatTokenList } from "../commands/token.js";
22
+ const T0 = Date.UTC(2026, 9, 3, 12);
23
+ const IPHONE_SAFARI = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1";
24
+ const MAC_CHROME = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
25
+ function setup() {
26
+ const db = createTestDb();
27
+ const store = new DbTokenStore(db);
28
+ const queue = [];
29
+ const ring = new TokenRing(store, () => queue.shift() ?? "unexpected-mint");
30
+ const local = store.insert(hashToken("local"), "local", "this machine", T0);
31
+ const phone = store.insert(hashToken("phone"), "device", "phone", T0 + 1);
32
+ const laptop = store.insert(hashToken("laptop"), "device", "laptop", T0 + 2);
33
+ const row = (id) => db.prepare("SELECT user_agent, remote_addr, revoked_at FROM tokens WHERE id = ?").get(id);
34
+ return { db, store, ring, queue, local, phone, laptop, row };
35
+ }
36
+ describe("a token's first sighting", () => {
37
+ it("is recorded on first presentation and never overwritten", () => {
38
+ const { ring, phone, row } = setup();
39
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, remoteAddr: "::ffff:100.101.1.2" });
40
+ assert.deepEqual({ ...row(phone.id), revoked_at: undefined }, { user_agent: IPHONE_SAFARI, remote_addr: "100.101.1.2", revoked_at: undefined });
41
+ // Someone else presenting the same token later does not rename the row.
42
+ ring.authenticate("phone", T0 + 2, { userAgent: MAC_CHROME, remoteAddr: "192.168.1.9" });
43
+ assert.equal(row(phone.id).user_agent, IPHONE_SAFARI);
44
+ assert.equal(row(phone.id).remote_addr, "100.101.1.2");
45
+ });
46
+ it("survives a restart: a new store does not overwrite what an old one wrote", () => {
47
+ const { db, phone, row } = setup();
48
+ new DbTokenStore(db).find(hashToken("phone"), T0 + 1, { userAgent: IPHONE_SAFARI });
49
+ new DbTokenStore(db).find(hashToken("phone"), T0 + 2, { userAgent: MAC_CHROME });
50
+ assert.equal(row(phone.id).user_agent, IPHONE_SAFARI);
51
+ });
52
+ it("is written by a later request when the first one carried nothing", () => {
53
+ const { ring, phone, row } = setup();
54
+ ring.authenticate("phone", T0 + 1, {});
55
+ ring.authenticate("phone", T0 + 2, { userAgent: IPHONE_SAFARI });
56
+ assert.equal(row(phone.id).user_agent, IPHONE_SAFARI);
57
+ });
58
+ it("strips control characters, so a header cannot forge a line in the list", () => {
59
+ const { ring, phone, row } = setup();
60
+ ring.authenticate("phone", T0 + 1, { userAgent: "evil\nlocal this machine" });
61
+ assert.equal(row(phone.id).user_agent, "evil local this machine");
62
+ });
63
+ it("is carried by a rotation's replacement, which is the requester's device", () => {
64
+ const { ring, queue, phone, store } = setup();
65
+ queue.push("phone-2");
66
+ ring.beginRotation(phone, T0 + 1);
67
+ const auth = ring.authenticate("phone-2", T0 + 2, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
68
+ assert.ok(auth.ok);
69
+ const listed = store.get(auth.record.id);
70
+ assert.equal(listed?.user_agent, IPHONE_SAFARI);
71
+ assert.equal(listed?.remote_addr, "100.101.1.2");
72
+ });
73
+ it("normalizes a v4-mapped address, and leaves real IPv6 alone", () => {
74
+ assert.equal(normalizeRemoteAddr("::ffff:127.0.0.1"), "127.0.0.1");
75
+ assert.equal(normalizeRemoteAddr("fd7a:115c:a1e0::1"), "fd7a:115c:a1e0::1");
76
+ assert.equal(normalizeRemoteAddr(undefined), null);
77
+ });
78
+ });
79
+ describe("GET /api/tokens", () => {
80
+ it("lists every live token, marks the requester, and names the device", () => {
81
+ const { ring, store, local, phone, laptop } = setup();
82
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI });
83
+ const { body } = listTokensRoute(store, phone, T0 + 2);
84
+ assert.deepEqual(body.tokens.map((t) => t.id), [local.id, phone.id, laptop.id]);
85
+ const me = body.tokens.find((t) => t.id === phone.id);
86
+ assert.equal(me.current, true);
87
+ assert.equal(me.device, "Safari on iPhone");
88
+ assert.equal(body.tokens.find((t) => t.id === laptop.id).current, false);
89
+ });
90
+ it("shows an unscanned QR with its deadline, and drops it once it has lapsed", () => {
91
+ const { store, phone } = setup();
92
+ const qr = store.insert(hashToken("qr"), "device", "melded", T0);
93
+ const before = listTokensRoute(store, phone, T0 + 1).body.tokens.find((t) => t.id === qr.id);
94
+ assert.equal(before?.unused, true);
95
+ assert.equal(before?.expires_at, T0 + UNUSED_TOKEN_LAPSE_MS);
96
+ const after = listTokensRoute(store, phone, T0 + UNUSED_TOKEN_LAPSE_MS).body.tokens;
97
+ assert.equal(after.some((t) => t.id === qr.id), false);
98
+ });
99
+ it("leaves revoked rows out", () => {
100
+ const { store, phone, laptop } = setup();
101
+ store.revoke([laptop.id], T0 + 1);
102
+ assert.equal(listTokensRoute(store, phone, T0 + 2).body.tokens.some((t) => t.id === laptop.id), false);
103
+ });
104
+ });
105
+ describe("POST /api/tokens/:id/revoke", () => {
106
+ it("revokes another device, which then stops authenticating", () => {
107
+ const { ring, store, phone, laptop, row } = setup();
108
+ const res = revokeTokenRoute(store, ring, phone, laptop.id, T0 + 1);
109
+ assert.equal(res.status, 200);
110
+ assert.equal(res.status === 200 && res.body.kind, "device", "the log names the kind it revoked");
111
+ assert.equal(row(laptop.id).revoked_at, T0 + 1);
112
+ assert.equal(ring.authenticate("laptop", T0 + 2).ok, false);
113
+ assert.equal(ring.authenticate("phone", T0 + 2).ok, true, "the requester is untouched");
114
+ });
115
+ it("works from this account too (the CLI)", () => {
116
+ const { ring, store, local, laptop } = setup();
117
+ assert.equal(revokeTokenRoute(store, ring, local, laptop.id, T0 + 1).status, 200);
118
+ });
119
+ it("refuses this machine's own row, the requester's own, and an id that is not live", () => {
120
+ const { ring, store, local, phone, laptop, row } = setup();
121
+ assert.equal(revokeTokenRoute(store, ring, phone, local.id, T0 + 1).status, 400);
122
+ assert.equal(revokeTokenRoute(store, ring, phone, phone.id, T0 + 1).status, 409);
123
+ assert.equal(revokeTokenRoute(store, ring, phone, "no-such-id", T0 + 1).status, 404);
124
+ store.revoke([laptop.id], T0 + 1);
125
+ assert.equal(revokeTokenRoute(store, ring, phone, laptop.id, T0 + 2).status, 404, "already revoked");
126
+ assert.equal(row(local.id).revoked_at, null);
127
+ assert.equal(row(phone.id).revoked_at, null);
128
+ });
129
+ it("abandons a rotation the revoked device had begun, so it cannot write itself back in", () => {
130
+ const { ring, store, queue, phone, laptop } = setup();
131
+ queue.push("laptop-2");
132
+ ring.beginRotation(laptop, T0 + 1);
133
+ const res = revokeTokenRoute(store, ring, phone, laptop.id, T0 + 2);
134
+ assert.ok(res.status === 200);
135
+ assert.equal(res.body.abandonedRotation, true);
136
+ // The pending token is dead: confirming it would otherwise have revoked
137
+ // the phone and installed the laptop afresh.
138
+ assert.equal(ring.authenticate("laptop-2", T0 + 3).ok, false);
139
+ assert.equal(ring.authenticate("phone", T0 + 3).ok, true);
140
+ });
141
+ it("leaves someone else's pending rotation alone", () => {
142
+ const { ring, store, queue, phone, laptop } = setup();
143
+ queue.push("phone-2");
144
+ ring.beginRotation(phone, T0 + 1);
145
+ const res = revokeTokenRoute(store, ring, phone, laptop.id, T0 + 2);
146
+ assert.ok(res.status === 200);
147
+ assert.equal(res.body.abandonedRotation, false);
148
+ assert.equal(ring.authenticate("phone-2", T0 + 3).ok, true);
149
+ });
150
+ });
151
+ describe("closeSocketsOf", () => {
152
+ it("closes exactly the revoked row's sockets, with the signed-out code", () => {
153
+ const closed = [];
154
+ const sock = (name) => ({ close: (code) => closed.push([name, code]) });
155
+ const a = sock("laptop-tab-1");
156
+ const b = sock("laptop-tab-2");
157
+ const c = sock("phone");
158
+ const sockets = new Map([[a, "laptop"], [b, "laptop"], [c, "phone"]]);
159
+ assert.equal(closeSocketsOf(sockets, ["laptop"]), 2);
160
+ assert.deepEqual(closed, [["laptop-tab-1", WS_CLOSE_TOKEN_ROTATED], ["laptop-tab-2", WS_CLOSE_TOKEN_ROTATED]]);
161
+ assert.deepEqual([...sockets.values()], ["phone"]);
162
+ });
163
+ });
164
+ describe("describeUserAgent", () => {
165
+ const cases = [
166
+ [IPHONE_SAFARI, "Safari on iPhone"],
167
+ [MAC_CHROME, "Chrome on macOS"],
168
+ ["Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0 Mobile/15E148 Safari/604.1", "Chrome on iPhone"],
169
+ ["Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", "Web app on iPhone"],
170
+ ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0", "Firefox on macOS"],
171
+ ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0", "Edge on Windows"],
172
+ ["Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36", "Chrome on Android"],
173
+ ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", "Safari on macOS"],
174
+ ["node", "node"],
175
+ ["curl/8.7.1", "curl"],
176
+ ["", null],
177
+ [null, null],
178
+ ];
179
+ for (const [ua, want] of cases) {
180
+ it(`${want ?? "nothing"} for ${JSON.stringify(ua?.slice(0, 40) ?? ua)}`, () => {
181
+ assert.equal(describeUserAgent(ua), want);
182
+ });
183
+ }
184
+ });
185
+ describe("which app a device runs", () => {
186
+ const MAIN = "https://main.brane.organelle.dev";
187
+ const APEX = "https://brane.organelle.dev";
188
+ const origins = (db, id) => db.prepare("SELECT app_origin, last_app_origin FROM tokens WHERE id = ?").get(id);
189
+ it("is recorded from the first request that carries an Origin, and the first is never overwritten", () => {
190
+ const { ring, phone, db } = setup();
191
+ // A same-origin GET carries no Origin: the device is named, the app not yet.
192
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
193
+ assert.deepEqual(origins(db, phone.id), { app_origin: null, last_app_origin: null });
194
+ // The control socket's upgrade carries one. Inside the once-a-minute
195
+ // throttle of the last-seen stamp, so the first write must carry both.
196
+ ring.authenticate("phone", T0 + 2, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN });
197
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
198
+ // The same token from another copy of the app: the first stands, the latest moves.
199
+ ring.authenticate("phone", T0 + 2 + 60_000, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: APEX });
200
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: APEX });
201
+ // A request with no Origin after that leaves the latest alone.
202
+ ring.authenticate("phone", T0 + 2 + 120_000, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
203
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: APEX });
204
+ });
205
+ it("is listed, and shown as a host", () => {
206
+ const { ring, store, local, phone, db } = setup();
207
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN });
208
+ const listed = listTokensRoute(store, local, T0 + 2).body.tokens.find((t) => t.id === phone.id);
209
+ assert.equal(listed.app_origin, MAIN);
210
+ assert.match(formatTokenList([listed]), /via main\.brane\.organelle\.dev/);
211
+ // A daemon's own bundle is an origin like any other, port included.
212
+ ring.authenticate("laptop", T0 + 1, { userAgent: MAC_CHROME, remoteAddr: "100.101.1.3", origin: "https://mac.tail1234.ts.net:47443" });
213
+ assert.match(formatTokenList(listTokensRoute(store, local, T0 + 2).body.tokens), /via mac\.tail1234\.ts\.net:47443/);
214
+ assert.equal(origins(db, local.id).app_origin, null);
215
+ });
216
+ it("is stored as text a list can show: control characters become spaces, and it is capped", () => {
217
+ const { ring, phone, db } = setup();
218
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, origin: "https://evil.example\r\nX-Forged: row" + "a".repeat(300) });
219
+ const { app_origin } = origins(db, phone.id);
220
+ assert.ok(app_origin && !/[\r\n]/.test(app_origin));
221
+ assert.ok(app_origin.length <= 256);
222
+ });
223
+ it("treats the opaque origin `null` as no origin, so it never names a device", () => {
224
+ // A sandboxed iframe or a file:// page sends the literal `Origin: null`.
225
+ // Stored, it would read "via null" for good, since the first origin is
226
+ // never overwritten; so it must not take the slot a real one would fill.
227
+ const { ring, phone, db } = setup();
228
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, origin: "null" });
229
+ assert.deepEqual(origins(db, phone.id), { app_origin: null, last_app_origin: null });
230
+ ring.authenticate("phone", T0 + 2, { userAgent: IPHONE_SAFARI, origin: MAIN });
231
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
232
+ ring.authenticate("phone", T0 + 2 + 60_000, { userAgent: IPHONE_SAFARI, origin: "null" });
233
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
234
+ });
235
+ it("a rotation's replacement takes the origin of the request that confirms it", () => {
236
+ const { store } = setup();
237
+ const replacement = store.insert(hashToken("fresh"), "device", "phone", T0 + 3, {
238
+ used: true,
239
+ seen: { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN },
240
+ });
241
+ assert.equal(store.list(T0 + 4).find((t) => t.id === replacement.id)?.app_origin, MAIN);
242
+ });
243
+ });
244
+ describe("formatTokenList", () => {
245
+ it("puts the id first, and marks this machine, the requester and an unscanned QR", () => {
246
+ const text = formatTokenList([
247
+ { id: "L", kind: "local", label: "this machine", origin: "minted", created_at: T0, last_used_at: T0, device: "node", remote_addr: "127.0.0.1", current: true, unused: false, expires_at: null },
248
+ { id: "Q", kind: "device", label: "melded", origin: "minted", created_at: T0, last_used_at: null, device: null, remote_addr: null, current: false, unused: true, expires_at: T0 + UNUSED_TOKEN_LAPSE_MS },
249
+ ]);
250
+ assert.match(text, /^L {2}this machine \(CLI\) \[this request\]/m);
251
+ assert.match(text, /^Q {2}unknown device \[unscanned, lapses 2026-10-03 12:10Z\]/m);
252
+ assert.match(text, /never used/);
253
+ });
254
+ });
255
+ describe("which sockets a revoke closes", () => {
256
+ // The regression: a device token can open /ws/mcp, which reaches every MCP
257
+ // tool, and the first version of this list left that socket open after the
258
+ // token was revoked (claude-yxany623:6).
259
+ it("closes a device token's MCP socket along with its control and terminal sockets", () => {
260
+ const device = { id: "d", kind: "device", label: "phone" };
261
+ assert.equal(closesOnRevoke("mcp", device), true);
262
+ assert.equal(closesOnRevoke("control", device), true);
263
+ assert.equal(closesOnRevoke("terminal", device), true);
264
+ });
265
+ it("leaves local MCP bridges alone, but not this account's viewer sockets", () => {
266
+ const local = { id: "l", kind: "local", label: "this machine" };
267
+ assert.equal(closesOnRevoke("mcp", local), false);
268
+ assert.equal(closesOnRevoke("control", local), true);
269
+ assert.equal(closesOnRevoke("terminal", local), true);
270
+ });
271
+ });
272
+ describe("who added a device", () => {
273
+ it("records the minting token, and names it in the list", () => {
274
+ const { db, ring, store, queue, local, phone } = setup();
275
+ ring.authenticate("phone", T0 + 10, { userAgent: IPHONE_SAFARI });
276
+ queue.push("laptop-2", "tablet");
277
+ const byPhone = mintDeviceTokenRoute(ring, "", T0 + 20, phone);
278
+ const byLocal = mintDeviceTokenRoute(ring, "", T0 + 30, local);
279
+ assert.ok(byPhone.status === 200 && byLocal.status === 200);
280
+ const listed = listTokensRoute(store, phone, T0 + 40).body.tokens;
281
+ assert.equal(listed.find((t) => t.id === byPhone.body.id)?.minted_by_device, "Safari on iPhone");
282
+ assert.equal(listed.find((t) => t.id === byLocal.body.id)?.minted_by_device, "this machine");
283
+ assert.equal(listed.find((t) => t.id === phone.id)?.minted_by_device, null, "not recorded");
284
+ // Still named after the minter is revoked: the join does not require a live row.
285
+ store.revoke([phone.id], T0 + 50);
286
+ assert.equal(listTokensRoute(store, local, T0 + 60).body.tokens.find((t) => t.id === byPhone.body.id)?.minted_by_device, "Safari on iPhone");
287
+ assert.equal(db.prepare("SELECT minted_by FROM tokens WHERE id = ?").get(byPhone.body.id).minted_by, phone.id);
288
+ });
289
+ });
290
+ describe("a token presented from somewhere else", () => {
291
+ it("keeps the first sighting, records the latest, and flags the second address", () => {
292
+ const { ring, store, phone } = setup();
293
+ ring.authenticate("phone", T0 + 10, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
294
+ assert.equal(store.get(phone.id)?.elsewhere_at, null, "one address is not a copy");
295
+ ring.authenticate("phone", T0 + 20, { userAgent: MAC_CHROME, remoteAddr: "100.99.7.7" });
296
+ const row = store.get(phone.id);
297
+ assert.equal(row.user_agent, IPHONE_SAFARI, "the first sighting still names the row");
298
+ assert.equal(row.remote_addr, "100.101.1.2");
299
+ assert.equal(row.last_remote_addr, "100.99.7.7");
300
+ assert.equal(row.last_user_agent, MAC_CHROME);
301
+ assert.equal(row.elsewhere_at, T0 + 20, "flagged at once, not on the next minute's stamp");
302
+ const listed = listTokensRoute(store, phone, T0 + 30).body.tokens.find((t) => t.id === phone.id);
303
+ assert.equal(listed.device, "Safari on iPhone");
304
+ assert.equal(listed.last_device, "Chrome on macOS");
305
+ });
306
+ it("counts every loopback spelling as one place", () => {
307
+ const { ring, store, phone } = setup();
308
+ ring.authenticate("phone", T0 + 10, { remoteAddr: "::1" });
309
+ ring.authenticate("phone", T0 + 20, { remoteAddr: "127.0.0.1" });
310
+ ring.authenticate("phone", T0 + 120_000, { remoteAddr: "::ffff:127.0.0.1" });
311
+ assert.equal(store.get(phone.id)?.elsewhere_at, null, "the bridge and the CLI on one machine are not a copy");
312
+ ring.authenticate("phone", T0 + 240_000, { remoteAddr: "100.99.7.7" });
313
+ assert.equal(store.get(phone.id)?.elsewhere_at, T0 + 240_000, "another machine still is");
314
+ });
315
+ it("stays flagged once the real device is back on its own address", () => {
316
+ const { ring, store, phone } = setup();
317
+ ring.authenticate("phone", T0 + 10, { remoteAddr: "100.101.1.2" });
318
+ ring.authenticate("phone", T0 + 20, { remoteAddr: "100.99.7.7" });
319
+ ring.authenticate("phone", T0 + 120_000, { remoteAddr: "100.101.1.2" });
320
+ const row = store.get(phone.id);
321
+ assert.equal(row.elsewhere_at, T0 + 20);
322
+ assert.equal(row.last_remote_addr, "100.101.1.2");
323
+ });
324
+ });
325
+ describe("connected", () => {
326
+ it("marks a token with a socket open now", () => {
327
+ const { store, phone, laptop } = setup();
328
+ const listed = listTokensRoute(store, phone, T0 + 10, new Set([laptop.id])).body.tokens;
329
+ assert.equal(listed.find((t) => t.id === laptop.id)?.connected, true);
330
+ assert.equal(listed.find((t) => t.id === phone.id)?.connected, false);
331
+ });
332
+ });
333
+ describe("cascading revoke", () => {
334
+ // phone added laptop, laptop added tablet; local added watch.
335
+ function tree() {
336
+ const t = setup();
337
+ const laptop2 = t.store.insert(hashToken("laptop2"), "device", "laptop2", T0 + 3, { mintedBy: t.phone.id });
338
+ const tablet = t.store.insert(hashToken("tablet"), "device", "tablet", T0 + 4, { mintedBy: laptop2.id });
339
+ const watch = t.store.insert(hashToken("watch"), "device", "watch", T0 + 5, { mintedBy: t.local.id });
340
+ return { ...t, laptop2, tablet, watch };
341
+ }
342
+ it("finds every device a device added, down the chain", () => {
343
+ const { store, phone, laptop2, tablet } = tree();
344
+ assert.deepEqual(new Set(store.descendants(phone.id)), new Set([laptop2.id, tablet.id]));
345
+ });
346
+ it("walks through a revoked link, but never returns a revoked row", () => {
347
+ const { store, phone, laptop2, tablet } = tree();
348
+ store.revoke([laptop2.id], T0 + 10);
349
+ assert.deepEqual(store.descendants(phone.id), [tablet.id]);
350
+ });
351
+ it("never reaches the requester or anything the requester added", () => {
352
+ const { store, phone, laptop2 } = tree();
353
+ // Revoking the phone from the laptop it added: the laptop, and the tablet
354
+ // the laptop added, stay.
355
+ assert.deepEqual(store.descendants(phone.id, laptop2.id), []);
356
+ });
357
+ it("still cascades when the requester is an ancestor (this machine melded the phone)", () => {
358
+ const t = setup();
359
+ const phone2 = t.store.insert(hashToken("phone2"), "device", "phone2", T0 + 3, { mintedBy: t.local.id });
360
+ const laptop3 = t.store.insert(hashToken("laptop3"), "device", "laptop3", T0 + 4, { mintedBy: phone2.id });
361
+ const other = t.store.insert(hashToken("other"), "device", "other", T0 + 5, { mintedBy: t.local.id });
362
+ assert.deepEqual(t.store.descendants(phone2.id, t.local.id), [laptop3.id]);
363
+ assert.equal(t.store.descendants(phone2.id, t.local.id).includes(other.id), false, "a sibling is not a descendant");
364
+ });
365
+ it("revokes the device and the ones asked for, in one write", () => {
366
+ const { ring, store, local, phone, laptop2, tablet, row } = tree();
367
+ const res = revokeTokenRoute(store, ring, local, phone.id, T0 + 20, [laptop2.id, tablet.id]);
368
+ assert.ok(res.status === 200);
369
+ assert.deepEqual(res.body.ids, [phone.id, laptop2.id, tablet.id]);
370
+ for (const id of res.body.ids)
371
+ assert.equal(row(id).revoked_at, T0 + 20);
372
+ });
373
+ it("revokes only the checked ones", () => {
374
+ const { ring, store, local, phone, laptop2, tablet, row } = tree();
375
+ assert.equal(revokeTokenRoute(store, ring, local, phone.id, T0 + 20, [tablet.id]).status, 200);
376
+ assert.equal(row(laptop2.id).revoked_at, null, "unchecked: the laptop you trust stays");
377
+ assert.equal(row(tablet.id).revoked_at, T0 + 20);
378
+ });
379
+ it("refuses an id that is not a device this one added, and revokes nothing", () => {
380
+ const { ring, store, local, phone, laptop, watch, row } = tree();
381
+ const res = revokeTokenRoute(store, ring, local, phone.id, T0 + 20, [laptop.id, watch.id]);
382
+ assert.equal(res.status, 400);
383
+ assert.equal(row(phone.id).revoked_at, null);
384
+ assert.equal(row(laptop.id).revoked_at, null);
385
+ });
386
+ it("lists each row's descendants, relative to the requester", () => {
387
+ const { store, local, phone, laptop2, tablet } = tree();
388
+ const fromLocal = listTokensRoute(store, local, T0 + 30).body.tokens;
389
+ assert.deepEqual(new Set(fromLocal.find((t) => t.id === phone.id).descendants), new Set([laptop2.id, tablet.id]));
390
+ assert.deepEqual(fromLocal.find((t) => t.id === local.id).descendants, [], "the local row is never revoked here");
391
+ const fromLaptop = listTokensRoute(store, laptop2, T0 + 30).body.tokens;
392
+ assert.deepEqual(fromLaptop.find((t) => t.id === phone.id).descendants, []);
393
+ });
394
+ });
395
+ describe("agents a device started", () => {
396
+ it("are listed under the token whose request last launched them", () => {
397
+ const { db, store, phone, laptop } = setup();
398
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-aaaaaaaa', 'b', 'claude', ?, 'active')").run(T0);
399
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-bbbbbbbb', 'b', 'claude', ?, 'active')").run(T0);
400
+ recordLauncher(db, "claude-aaaaaaaa", phone.id);
401
+ recordLauncher(db, "claude-bbbbbbbb", phone.id);
402
+ // A revive from the laptop makes it the laptop's.
403
+ recordLauncher(db, "claude-bbbbbbbb", laptop.id);
404
+ const listed = listTokensRoute(store, phone, T0 + 10).body.tokens;
405
+ assert.deepEqual(listed.find((t) => t.id === phone.id).agents.map((a) => a.id), ["claude-aaaaaaaa"]);
406
+ assert.deepEqual(listed.find((t) => t.id === laptop.id).agents.map((a) => a.id), ["claude-bbbbbbbb"]);
407
+ });
408
+ });
409
+ describe("agent tokens", () => {
410
+ function agentSetup() {
411
+ const t = setup();
412
+ t.db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-agent001', 'b', 'claude', ?, 'active')").run(T0);
413
+ const secret = mintAgentToken(t.db, "claude-agent001", T0 + 1);
414
+ const auth = t.ring.authenticate(secret, T0 + 2);
415
+ assert.ok(auth.ok);
416
+ return { ...t, secret, agent: auth.record };
417
+ }
418
+ it("authenticate as an agent bound to its narrative", () => {
419
+ const { agent } = agentSetup();
420
+ assert.equal(agent.kind, "agent");
421
+ assert.equal(agent.ownerNid, "claude-agent001");
422
+ });
423
+ it("are listed, and revoking one closes its bridge", () => {
424
+ const { store, ring, phone, agent, row } = agentSetup();
425
+ const listed = listTokensRoute(store, phone, T0 + 3).body.tokens.find((t) => t.id === agent.id);
426
+ assert.equal(listed?.kind, "agent");
427
+ assert.equal(closesOnRevoke("mcp", agent), true, "an agent's MCP socket is its bridge");
428
+ assert.equal(revokeTokenRoute(store, ring, phone, agent.id, T0 + 4).status, 200);
429
+ assert.equal(row(agent.id).revoked_at, T0 + 4);
430
+ });
431
+ it("are among the tokens revoking the device that launched them can take", () => {
432
+ const { db, store, local, phone, agent } = agentSetup();
433
+ recordLauncher(db, "claude-agent001", phone.id);
434
+ assert.deepEqual(store.descendants(phone.id, local.id), [agent.id]);
435
+ });
436
+ it("never trigger the plaintext-token warning when adopted", () => {
437
+ const db = createTestDb();
438
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status, bind_token) VALUES ('claude-old00001', 'b', 'claude', 0, 'active', 'plain')").run();
439
+ adoptBindTokens(db);
440
+ assert.equal(legacyTokenWarning(db), null);
441
+ });
442
+ it("are not swept by the unused-token lapse", () => {
443
+ const { store, agent, row } = agentSetup();
444
+ store.lapseUnused(T0 + 365 * 24 * 3600_000);
445
+ assert.equal(row(agent.id).revoked_at, null);
446
+ });
447
+ });
448
+ describe("review fixes (claude-jrjh1e8w:13)", () => {
449
+ it("an MCP socket belongs to its dialer and to its narrative's agent token", () => {
450
+ const device = { id: "dev", kind: "device", label: "phone" };
451
+ const local = { id: "loc", kind: "local", label: "this machine" };
452
+ const agent = { id: "agt", kind: "agent", label: "claude-x", ownerNid: "claude-x" };
453
+ assert.deepEqual(socketOwners("mcp", device, "agt"), ["dev", "agt"]);
454
+ assert.deepEqual(socketOwners("mcp", local, "agt"), ["agt"]);
455
+ assert.deepEqual(socketOwners("mcp", agent, "agt"), ["agt"]);
456
+ assert.deepEqual(socketOwners("control", device), ["dev"]);
457
+ // And revoking either owner closes it.
458
+ for (const revoked of ["dev", "agt"]) {
459
+ const closed = [];
460
+ const ws = { close: () => closed.push(revoked) };
461
+ assert.equal(closeSocketsOf(new Map([[ws, socketOwners("mcp", device, "agt")]]), [revoked]), 1);
462
+ }
463
+ });
464
+ it("an external records the token that dialed it, so it is in that token's cascade", () => {
465
+ const { db, store, local, phone } = setup();
466
+ const bind = resolveMcpBind(db, "b", "external-placeholder", undefined, phone.id);
467
+ assert.ok(bind.minted && bind.secret);
468
+ const agentRow = store.list(T0).find((t) => t.kind === "agent" && t.owner_nid === bind.nid);
469
+ assert.equal(agentRow.minted_by, phone.id);
470
+ assert.ok(store.descendants(phone.id, local.id).includes(agentRow.id));
471
+ });
472
+ it("a rotation retires externals the retired devices dialed in, not spawned agents, and keeps the requester's", () => {
473
+ const { db, ring, queue, store, phone, laptop, row } = setup();
474
+ const ext = resolveMcpBind(db, "b", "external-p1", undefined, laptop.id);
475
+ const mine = resolveMcpBind(db, "b", "external-p2", undefined, phone.id);
476
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-spawned1', 'b', 'claude', ?, 'active')").run(T0);
477
+ mintAgentToken(db, "claude-spawned1", T0);
478
+ recordLauncher(db, "claude-spawned1", laptop.id);
479
+ const idOf = (nid) => store.list(T0).find((t) => t.kind === "agent" && t.owner_nid === nid).id;
480
+ const [extId, mineId, spawnedId] = [idOf(ext.nid), idOf(mine.nid), idOf("claude-spawned1")];
481
+ queue.push("phone-2");
482
+ ring.beginRotation(phone, T0 + 10);
483
+ const auth = ring.authenticate("phone-2", T0 + 11);
484
+ assert.ok(auth.ok && auth.retired);
485
+ assert.ok(auth.retired.ids.includes(extId), "the laptop's external is out, and its socket closes");
486
+ assert.equal(row(extId).revoked_at, T0 + 11);
487
+ assert.equal(row(spawnedId).revoked_at, null, "a spawned agent is not a device's to lose");
488
+ assert.equal(row(mineId).revoked_at, null, "the requester's own external stays");
489
+ assert.equal(store.get(mineId)?.minted_by, auth.record.id, "re-parented onto the replacement");
490
+ });
491
+ it("this account signing every device out takes the externals they dialed in, not its own or spawned agents", () => {
492
+ const { db, ring, store, local, phone, laptop, row } = setup();
493
+ const theirs = resolveMcpBind(db, "b", "external-s1", undefined, laptop.id);
494
+ const mine = resolveMcpBind(db, "b", "external-s2", undefined, local.id);
495
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status) VALUES ('claude-spawned2', 'b', 'claude', ?, 'active')").run(T0);
496
+ mintAgentToken(db, "claude-spawned2", T0);
497
+ recordLauncher(db, "claude-spawned2", phone.id);
498
+ const idOf = (nid) => store.list(T0).find((t) => t.kind === "agent" && t.owner_nid === nid).id;
499
+ const [theirsId, mineId, spawnedId] = [idOf(theirs.nid), idOf(mine.nid), idOf("claude-spawned2")];
500
+ // Both devices have been presented, so each is one someone is signed in on.
501
+ for (const t of ["phone", "laptop"])
502
+ ring.authenticate(t, T0 + 3);
503
+ const out = ring.signOutDevices(local, T0 + 10);
504
+ assert.equal(out.devices, 2);
505
+ assert.deepEqual([...out.ids].sort(), [phone.id, laptop.id, theirsId].sort());
506
+ assert.equal(row(theirsId).revoked_at, T0 + 10);
507
+ assert.equal(row(mineId).revoked_at, null, "an external this account dialed in stays");
508
+ assert.equal(row(spawnedId).revoked_at, null, "a spawned agent is not a device's to lose");
509
+ assert.equal(row(local.id).revoked_at, null);
510
+ });
511
+ it("counts the devices that were signed in, not a QR nobody scanned, and revokes both", () => {
512
+ const { ring, store, local, row } = setup();
513
+ // setup()'s phone and laptop have been presented; this one never is.
514
+ ring.authenticate("phone", T0 + 3);
515
+ ring.authenticate("laptop", T0 + 3);
516
+ const qr = store.insert(hashToken("unscanned"), "device", "melded 2026-10-05", T0 + 4);
517
+ const out = ring.signOutDevices(local, T0 + 5);
518
+ assert.equal(out.devices, 2);
519
+ assert.equal(out.ids.length, 3);
520
+ assert.equal(row(qr.id).revoked_at, T0 + 5);
521
+ });
522
+ it("adopts only running sessions' secrets", () => {
523
+ const db = createTestDb();
524
+ for (const id of ["claude-running1", "claude-dead0001"]) {
525
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, created_at, status, bind_token) VALUES (?, 'b', 'claude', 0, 'active', ?)").run(id, `secret-${id}`);
526
+ }
527
+ assert.equal(adoptBindTokens(db, T0, new Set(["claude-running1"])), 1);
528
+ assert.ok(verifyAgentToken(db, "claude-running1", "secret-claude-running1"));
529
+ assert.equal(verifyAgentToken(db, "claude-dead0001", "secret-claude-dead0001"), false, "a dead session's secret is no credential");
530
+ });
531
+ it("a restarted daemon carries no agent's identity", () => {
532
+ const saved = { id: process.env.BRANE_NARRATIVE_ID, secret: process.env.BRANE_NARRATIVE_SECRET };
533
+ process.env.BRANE_NARRATIVE_ID = "claude-someone";
534
+ process.env.BRANE_NARRATIVE_SECRET = "their-token";
535
+ try {
536
+ const env = buildDaemonEnv();
537
+ assert.equal(env.BRANE_NARRATIVE_ID, undefined);
538
+ assert.equal(env.BRANE_NARRATIVE_SECRET, undefined);
539
+ }
540
+ finally {
541
+ if (saved.id === undefined)
542
+ delete process.env.BRANE_NARRATIVE_ID;
543
+ else
544
+ process.env.BRANE_NARRATIVE_ID = saved.id;
545
+ if (saved.secret === undefined)
546
+ delete process.env.BRANE_NARRATIVE_SECRET;
547
+ else
548
+ process.env.BRANE_NARRATIVE_SECRET = saved.secret;
549
+ }
550
+ });
551
+ it("a restarted daemon carries no harness session either", () => {
552
+ const names = ["CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDECODE", "CLAUDE_CODE_USE_BEDROCK"];
553
+ const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
554
+ process.env.CLAUDE_CODE_SESSION_ID = "26f151d7-the-restarting-session";
555
+ process.env.CLAUDE_CODE_MESSAGING_TOKEN = "its-messaging-token";
556
+ process.env.CLAUDECODE = "1";
557
+ process.env.CLAUDE_CODE_USE_BEDROCK = "1";
558
+ try {
559
+ const env = buildDaemonEnv();
560
+ assert.equal(env.CLAUDE_CODE_SESSION_ID, undefined, "a crush agent would stamp narrations with it");
561
+ assert.equal(env.CLAUDE_CODE_MESSAGING_TOKEN, undefined);
562
+ assert.equal(env.CLAUDECODE, undefined);
563
+ assert.equal(env.CLAUDE_CODE_USE_BEDROCK, "1", "operator configuration passes through");
564
+ }
565
+ finally {
566
+ for (const n of names)
567
+ if (saved[n] === undefined)
568
+ delete process.env[n];
569
+ else
570
+ process.env[n] = saved[n];
571
+ }
572
+ });
573
+ it("utility sessions start with every inherited agent variable unset", () => {
574
+ const prefix = unsetAgentEnvPrefix();
575
+ for (const name of INHERITED_AGENT_ENV)
576
+ assert.ok(prefix.includes(`-u ${name}`), name);
577
+ assert.ok(prefix.startsWith("env "));
578
+ });
579
+ it("a launch command reaches tmux quoted, never as an argument", () => {
580
+ const line = newSessionLine({
581
+ name: "claude-x",
582
+ cwd: "/Users/me/my fold",
583
+ size: { cols: 80, rows: 24 },
584
+ command: "BRANE_NARRATIVE_SECRET=abc claude --prompt 'it''s $HOME ~ #x;'",
585
+ });
586
+ assert.equal(line, "new-session -d -s 'claude-x' -c '/Users/me/my fold' -x 80 -y 24 " +
587
+ "'BRANE_NARRATIVE_SECRET=abc claude --prompt '\\''it'\\'''\\''s $HOME ~ #x;'\\'''");
588
+ assert.equal(tmuxQuote("a'b"), "'a'\\''b'");
589
+ });
590
+ });
591
+ describe("re-check fixes (claude-jrjh1e8w:22)", () => {
592
+ it("a revoke takes the externals its device dialed in, with no ?also=", () => {
593
+ const { db, ring, store, local, laptop, row } = setup();
594
+ const ext = resolveMcpBind(db, "b", "external-q1", undefined, laptop.id);
595
+ const extId = store.list(T0).find((t) => t.owner_nid === ext.nid).id;
596
+ assert.deepEqual(listTokensRoute(store, local, T0).body.tokens.find((t) => t.id === laptop.id).descendants, [], "not offered as a choice");
597
+ const res = revokeTokenRoute(store, ring, local, laptop.id, T0 + 5);
598
+ assert.ok(res.status === 200);
599
+ assert.ok(res.body.ids.includes(extId));
600
+ assert.equal(row(extId).revoked_at, T0 + 5);
601
+ });
602
+ it("a rotation retires an external whose dialer was revoked earlier on its own", () => {
603
+ const { db, ring, queue, store, phone, laptop, row } = setup();
604
+ const ext = resolveMcpBind(db, "b", "external-q2", undefined, laptop.id);
605
+ const extId = store.list(T0).find((t) => t.owner_nid === ext.nid).id;
606
+ store.revoke([laptop.id], T0 + 1); // as an older daemon would have, leaving the external
607
+ queue.push("phone-2");
608
+ ring.beginRotation(phone, T0 + 2);
609
+ const auth = ring.authenticate("phone-2", T0 + 3);
610
+ assert.ok(auth.ok && auth.retired?.ids.includes(extId));
611
+ assert.equal(row(extId).revoked_at, T0 + 3);
612
+ });
613
+ });
614
+ describe("newTmuxSession against real tmux", () => {
615
+ const tmux = (() => {
616
+ try {
617
+ return execFileSync("sh", ["-c", "command -v tmux"], { encoding: "utf8" }).trim() || null;
618
+ }
619
+ catch {
620
+ return null;
621
+ }
622
+ })();
623
+ it("starts a server when none is running, and keeps the command out of every argv", { skip: !tmux && "no tmux" }, () => {
624
+ const dir = mkdtempSync(join(tmpdir(), "brane-tmux-"));
625
+ const socket = join(dir, "s");
626
+ // Never the default server: every call names this private socket (which
627
+ // wins over an inherited $TMUX), short enough for a Unix socket path.
628
+ assert.ok(socket.startsWith(dir) && Buffer.byteLength(socket) < 104, socket);
629
+ const run = (...args) => execFileSync(tmux, ["-S", socket, ...args], { encoding: "utf8" });
630
+ try {
631
+ newTmuxSession({ name: "probe", cwd: dir, command: "BRANE_NARRATIVE_SECRET=not-in-argv sleep 30", socket });
632
+ assert.match(run("list-sessions", "-F", "#{session_name}"), /^probe$/m);
633
+ const pid = run("display-message", "-p", "-t", "probe", "#{pid}").trim();
634
+ const argv = execFileSync("ps", ["-o", "command=", "-p", pid], { encoding: "utf8" });
635
+ assert.doesNotMatch(argv, /not-in-argv/, "the server's argv carries no secret");
636
+ assert.throws(() => newTmuxSession({ name: "probe", cwd: dir, command: "sleep 1", socket }), "a duplicate name still fails");
637
+ }
638
+ finally {
639
+ try {
640
+ run("kill-server");
641
+ }
642
+ catch { }
643
+ }
644
+ });
645
+ });
646
+ //# sourceMappingURL=token-devices.test.js.map