brane 0.0.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +81 -23
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# Token Rotation
|
|
2
|
+
|
|
3
|
+
"Sign out other devices." The daemon stores its tokens **as hashes only**,
|
|
4
|
+
in the `tokens` table (`src/db/tokens.ts`), so that table authenticates
|
|
5
|
+
nothing by itself and the daemon can never print a token back. (The rest of
|
|
6
|
+
`brane.db` is another matter: narrations and captured tool calls are kept
|
|
7
|
+
verbatim, so a token an agent printed or quoted is in there in the clear
|
|
8
|
+
until a rotation retires it. See SECURITY.md, "What hashing does not
|
|
9
|
+
cover".) There are two kinds. Each web client holds a **device** token as a durable credential
|
|
10
|
+
— a registry entry in `localStorage`, written from a meld QR — and `brane
|
|
11
|
+
meld` mints a fresh one for every QR (`POST /api/tokens`), as does the app's
|
|
12
|
+
Meld sheet each time it opens, so each device can be revoked alone. This
|
|
13
|
+
machine's CLI and MCP bridges hold **no token**: outside an agent session
|
|
14
|
+
they present nothing over `daemon.sock` (`resolveCredential`,
|
|
15
|
+
`src/daemon/client.ts`), and a request there with no token acts as the one
|
|
16
|
+
live **local** row, the account row (`TokenStore.account`,
|
|
17
|
+
`authenticateRequest`; see [The CLI is a daemon
|
|
18
|
+
client](daemon-api.md#the-cli-is-a-daemon-client)). No token opens that row
|
|
19
|
+
— see [No local token](#no-local-token). Inside an agent session they
|
|
20
|
+
present `BRANE_NARRATIVE_SECRET`. Each agent session holds an
|
|
21
|
+
**agent** token of its own — see [Agent tokens](#agent-tokens). Rows are
|
|
22
|
+
revoked, never deleted.
|
|
23
|
+
|
|
24
|
+
**An unscanned meld token lapses.** Every QR mints a full-power device
|
|
25
|
+
token, and since the app's Meld sheet mints one each time it opens, most are
|
|
26
|
+
never scanned. A minted device token that nobody presents within
|
|
27
|
+
`UNUSED_TOKEN_LAPSE_MS` (10 minutes, `src/daemon/token-ring.ts`) is refused,
|
|
28
|
+
and its row is revoked — by `DbTokenStore.find` when it is presented late,
|
|
29
|
+
and by a sweep (`lapseUnused`) the daemon runs at start and every minute, so
|
|
30
|
+
a QR that was photographed but never used stops working and never shows up
|
|
31
|
+
as a device. First use makes a token permanent, as before. Three things keep
|
|
32
|
+
a used token from lapsing by mistake:
|
|
33
|
+
|
|
34
|
+
- A rotation's replacement row is written already used (`insert(..., { used:
|
|
35
|
+
true })`): the request that completes the rotation is its first use, and a
|
|
36
|
+
device that then only holds a socket open makes no further requests.
|
|
37
|
+
- First use is also remembered in memory, since the `last_used_at` stamp is
|
|
38
|
+
best-effort; a lost stamp cannot lapse a token this daemon has seen.
|
|
39
|
+
- Only `device` rows with `origin = 'minted'` lapse. The account row and
|
|
40
|
+
adopted tokens are in use by construction.
|
|
41
|
+
|
|
42
|
+
`POST /api/tokens` returns `expiresAt` and `validForMs`; `brane meld` prints
|
|
43
|
+
the window, and the Meld sheet mints a replacement a minute before its code
|
|
44
|
+
would lapse, so the code on screen always works.
|
|
45
|
+
|
|
46
|
+
**Upgrading signs nobody out.** Before hashing, one plaintext token sat in
|
|
47
|
+
`meta.daemon_token` and `.daemon_token` and on every melded device. On the
|
|
48
|
+
first start of a hashing daemon, `bootstrapTokens` adopts it as a device row
|
|
49
|
+
("melded before hashing"), so those devices keep working; removes it from
|
|
50
|
+
`meta` (with `secure_delete` and a WAL checkpoint, so it does not linger in
|
|
51
|
+
freed pages); and empties the file. A token whose
|
|
52
|
+
hash is already known, live or revoked, is never adopted again, so a stale
|
|
53
|
+
file or a stale `--token` cannot bring a revoked token back. Pinned by
|
|
54
|
+
`src/test/token-bootstrap.test.ts`.
|
|
55
|
+
|
|
56
|
+
**An adopted token is marked, and the daemon nags until it is gone.** Every
|
|
57
|
+
row carries an `origin`: `minted` by this daemon, or `adopted` from a
|
|
58
|
+
plaintext that existed before (the old shared token, or a `--token`).
|
|
59
|
+
Adopted tokens may have been written down — older daemons printed theirs in
|
|
60
|
+
`daemon.log` at every start, and a `--token` sits in argv and in whatever
|
|
61
|
+
config passes it — so while one is live, the daemon warns at startup and
|
|
62
|
+
`brane daemon status` says how to retire it: one rotation. The mark is a
|
|
63
|
+
column, not the label, so renaming the device cannot silence it. The app
|
|
64
|
+
warns on adopted *device* rows; an adopted agent row (a running session's
|
|
65
|
+
pre-#561 bind secret) is noted as replaced on its next revive. When the old
|
|
66
|
+
shared token is the one that asks to rotate, its replacement is labelled
|
|
67
|
+
"kept when other devices were signed out": it was minted then, not melded
|
|
68
|
+
before hashing.
|
|
69
|
+
|
|
70
|
+
**The swap happens on first use of the new token, not on the request that
|
|
71
|
+
minted it.** `POST /api/token/rotate` returns a *pending* token for the
|
|
72
|
+
requester and changes nothing durable; every live token still
|
|
73
|
+
authenticates. The first request that presents the new one is the proof it
|
|
74
|
+
arrived, and only then is it written down as a row of the requester's kind
|
|
75
|
+
and the requester's old row and every other device row revoked, in one
|
|
76
|
+
transaction (a failed write changes nothing, and the pending token survives
|
|
77
|
+
for the client's retry). Then every control/terminal socket those rows opened is
|
|
78
|
+
closed, with WebSocket close code **4401**, so a
|
|
79
|
+
client can tell "signed out" from a network blip. This machine rides
|
|
80
|
+
through a device's rotation: the CLI and agents are not "other devices". A
|
|
81
|
+
pending token nobody uses lapses after 60 s and nothing changes.
|
|
82
|
+
|
|
83
|
+
**The shell's rotate is one request.** `brane token rotate` holds no token
|
|
84
|
+
to swap, so nothing of its own can be stranded by a lost response: once the
|
|
85
|
+
operator passes the [presence check](presence.md#the-gate),
|
|
86
|
+
`TokenRing.signOutDevices` revokes every device row and the externals they
|
|
87
|
+
dialed in, voids any rotation a device had pending (its token must not write
|
|
88
|
+
that device back in), and the route answers `{ signed_out, replaced }` with
|
|
89
|
+
no token in it. `signed_out` counts the devices someone was signed in on; a
|
|
90
|
+
QR nobody scanned is revoked too but not counted. `beginRotation` throws
|
|
91
|
+
for a `local` requester: completing one would write a token that opens the
|
|
92
|
+
`local` row, and that must not depend on which route called it. An answer in the two-step shape means an older daemon; the
|
|
93
|
+
CLI reports that instead of claiming a sign-out that did not happen.
|
|
94
|
+
|
|
95
|
+
| Piece | Where | Does |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `TokenRing` | `src/daemon/token-ring.ts` | Hash lookup over a `TokenStore`, the pending token, `authenticate()` that performs the swap; pinned by `src/test/token-ring.test.ts` |
|
|
98
|
+
| `DbTokenStore`, `bootstrapTokens()`, `legacyTokenWarning()` | `src/db/tokens.ts` | The `tokens` table, the one-time move from a plaintext token to hashes, and the warning while an adopted token is live; pinned by `src/test/token-bootstrap.test.ts` |
|
|
99
|
+
| `mintDeviceTokenRoute()`, `retireTokens()` | `src/daemon/token-routes.ts` | `POST /api/tokens`, and the end of a rotation: the socket sweep, run inside the request that first used the new token, before it is answered; pinned by `src/test/token-routes.test.ts` |
|
|
100
|
+
| `listTokensRoute()`, `revokeTokenRoute()`, `closeSocketsOf()` | `src/daemon/token-routes.ts` | The Devices list (`GET /api/tokens`) and per-device revoke (`POST /api/tokens/:id/revoke`); pinned by `src/test/token-devices.test.ts` |
|
|
101
|
+
| `DevicesLayer` | `expo/components/DevicesLayer.tsx` | The switcher's Devices layer: one row per token, Revoke with an in-place confirm, and "Sign out other devices" at its foot (its confirm layer is the switcher's, in `expo/app/index.tsx`) |
|
|
102
|
+
| `viewerSockets` | `src/daemon/server.ts` | Control + terminal sockets tagged with the token row they authenticated under. MCP bridges are deliberately absent |
|
|
103
|
+
| `WS_CLOSE_TOKEN_ROTATED` | `src/shared/token-rotation.ts` | The close code, shared with the app |
|
|
104
|
+
| `rotateActiveToken()` | `expo/app/_layout.tsx` | The requester's sequence: rotate → close own channel → `probeDaemon` under the new token → rewrite the registry entry → reconnect |
|
|
105
|
+
| `revoked` | `expo/lib/registry.ts` | The flag a refused entry gets. Never removed — the banner names what you were signed out of; a fresh meld of the same brane replaces the entry and clears it |
|
|
106
|
+
| `rotateDaemonToken()` | `src/commands/token.ts` | The CLI's version: one request, since the shell holds no token to swap; `src/test/token-rotate-cli.test.ts` pins the one-request shape, and that an older daemon's two-step answer is reported |
|
|
107
|
+
|
|
108
|
+
Five things are less obvious than they look:
|
|
109
|
+
|
|
110
|
+
**Why first use.** The naive shape — rotate returns the new token and the
|
|
111
|
+
old dies on the spot — has one failure the [request layer](request-layer.md)
|
|
112
|
+
makes real: on a 741 ms link, a lost *response* leaves the daemon rotated
|
|
113
|
+
and nobody, the requester included, holding the new token, and a `POST` is
|
|
114
|
+
rightly never retried. Under first-use, a lost response costs nothing, and a
|
|
115
|
+
daemon restart mid-handshake forgets the pending token and lands on the old
|
|
116
|
+
one — the safe side, with no new persistence.
|
|
117
|
+
|
|
118
|
+
**A pending token is issued exactly once.** The person rotation exists to
|
|
119
|
+
evict — whoever holds the old token — can call the route too. The first
|
|
120
|
+
draft answered a second call within the window with the *same* pending
|
|
121
|
+
token, for retry-safety, which handed an attacker racing the operator the
|
|
122
|
+
new credential to use first while the operator's own confirm still
|
|
123
|
+
succeeded. So a second `rotate` now *replaces* the pending token: the
|
|
124
|
+
requester's confirm fails with a 401 (the app restores its old channel and
|
|
125
|
+
says so) and the daemon logs `pending rotation REPLACED before use`. A
|
|
126
|
+
double-tap costs one visible retry; a race is visible instead of silent.
|
|
127
|
+
What the route still cannot do is stop an old-token holder from rotating
|
|
128
|
+
*first* — but the token already grants keystrokes into every tmux session,
|
|
129
|
+
so that is no new capability. The guarantee is only: once your rotation
|
|
130
|
+
completes, holders of the old token are out. When a leak is suspected to be
|
|
131
|
+
*live*, rotate from the shell: `brane token rotate` signs every device out
|
|
132
|
+
in the one request it makes, with no window. It waits for the operator
|
|
133
|
+
at the [presence check](presence.md#the-gate) first, before anything is
|
|
134
|
+
revoked.
|
|
135
|
+
|
|
136
|
+
**The requester must close its own old-token channel before confirming.**
|
|
137
|
+
Retirement sweeps every old-generation socket, and a control channel still
|
|
138
|
+
open at that moment would read its own 4401 as someone else's sign-out and
|
|
139
|
+
flag the entry it is about to rewrite. The order in `rotateActiveToken` is
|
|
140
|
+
the fix, and it is why the confirming request is an explicit `probeDaemon`
|
|
141
|
+
rather than "whatever request happens next".
|
|
142
|
+
|
|
143
|
+
**A 401 has two faces.** A device live at the moment of retirement gets the
|
|
144
|
+
4401 close and stops reconnecting. A device that loads *later* never sees
|
|
145
|
+
it — its upgrade is refused with a 401 the browser reports as a bare 1006 —
|
|
146
|
+
so the HTTP layer reports every 401 to `setUnauthorizedListener`, keyed on
|
|
147
|
+
the refused `{ host, token }` rather than "the active daemon", which is what
|
|
148
|
+
keeps a wrong token typed into the add-daemon form from signing out the one
|
|
149
|
+
you are connected to. `connectChannel` refuses to dial a `revoked` entry at
|
|
150
|
+
all: without that, the old 2 s reconnect loop would run forever and
|
|
151
|
+
silently, which was the pre-existing behavior on any 401.
|
|
152
|
+
|
|
153
|
+
**Sibling tabs are the same device.** They share `localStorage`, so one
|
|
154
|
+
tab's rotation is written where the others read it; a `storage` listener in
|
|
155
|
+
the root layout reconnects them on the new token instead of letting them
|
|
156
|
+
be refused. "Other devices" means other credential stores.
|
|
157
|
+
|
|
158
|
+
## Agent tokens
|
|
159
|
+
|
|
160
|
+
Every agent session authenticates with a token of its own, and the secret
|
|
161
|
+
in its env is that token: `BRANE_NARRATIVE_SECRET`. It used to be a
|
|
162
|
+
per-narrative bind secret, kept in plaintext in `narratives.bind_token` and
|
|
163
|
+
presented alongside the local token of the time; it is now an `agent` row in
|
|
164
|
+
`tokens`, hashed like any other, with `owner_nid` naming the one narrative
|
|
165
|
+
it may act as. Reusing the variable that already rode every harness's MCP
|
|
166
|
+
config (Claude's env, Goose's inline env, Codex's `env_vars`, Copilot's and
|
|
167
|
+
Cline's per-session files) meant no harness's plumbing had to change.
|
|
168
|
+
|
|
169
|
+
- **Minted per launch** (`mintAgentToken`, src/db/tokens.ts): spawn, revive,
|
|
170
|
+
an agent switch, and an external client's first dial. Each launch revokes
|
|
171
|
+
the narrative's previous token in the same write, so one is live per
|
|
172
|
+
narrative, and the daemon has the plaintext only at that moment — which
|
|
173
|
+
is why `welcome` carries a secret only for a narrative it just minted.
|
|
174
|
+
- **Presented as the bearer** by the MCP bridge and by the `brane` CLI when
|
|
175
|
+
`BRANE_NARRATIVE_SECRET` is set (`resolveDaemonEndpoint`), so what an
|
|
176
|
+
agent does is attributed to, and revocable with, its narrative. The hook
|
|
177
|
+
route checks it by hash too (`verifyNarrativeBind`).
|
|
178
|
+
- **Bound to its narrative.** On `/ws/mcp` an agent token may bind only as
|
|
179
|
+
its `owner_nid`; any other claim is refused with 403, never re-minted as
|
|
180
|
+
a fresh external, which would hand a revocable credential a way around
|
|
181
|
+
its own row. It cannot rotate (403): its replacement is a revive.
|
|
182
|
+
- **Narrower than a device, and only from here.** An agent token's routes
|
|
183
|
+
are a default-deny list (`AGENT_ROUTES`, `src/daemon/agent-scope.ts`):
|
|
184
|
+
reads, renderers, spawn/revive and its own `/ws/mcp`; not the
|
|
185
|
+
terminal or control sockets, signals, `POST /api/run` (a utility
|
|
186
|
+
session carries no agent token; nor may it revive or prompt one), the Devices list or
|
|
187
|
+
the operator's housekeeping. It works only over `daemon.sock` or loopback,
|
|
188
|
+
which every bridge, spawned or external, already uses. A refusal is a
|
|
189
|
+
403 naming a utility session, started from the app, as the way to do it. Pinned by
|
|
190
|
+
`src/test/agent-scope.test.ts`; SECURITY.md, "What an agent token can
|
|
191
|
+
do", has the reasoning.
|
|
192
|
+
- **Revoking one closes its bridge** with 4401, including a bridge that
|
|
193
|
+
dialed as this account (an external's first connection, a session
|
|
194
|
+
from before agent tokens), whose socket is tagged with its narrative's
|
|
195
|
+
agent token. The bridge then stops reconnecting and answers every tool
|
|
196
|
+
call with "this session's brane token was revoked". The tmux session is
|
|
197
|
+
left alone.
|
|
198
|
+
- **In the list and the cascade.** Agent rows show as "Agent claude-…",
|
|
199
|
+
"started by" the device or CLI that launched them (`recordLauncher` sets
|
|
200
|
+
`minted_by`), and revoking a device offers its agents' tokens too.
|
|
201
|
+
- **Running sessions from before agent tokens keep working**: at daemon
|
|
202
|
+
start, `adoptBindTokens` turns the plaintext bind secret of each narrative
|
|
203
|
+
whose tmux session is alive into an adopted agent row (once; a hash
|
|
204
|
+
already known is skipped, so a revoked one never comes back). Only
|
|
205
|
+
running ones: a dead or archived narrative's stored secret would
|
|
206
|
+
otherwise become a permanent bearer for a session nobody runs. The
|
|
207
|
+
plaintext column is left as it was; a secret not adopted authenticates
|
|
208
|
+
nothing. A connected external client (no tmux session) is not adopted,
|
|
209
|
+
and lands on a fresh `external-*` narrative once, at the upgrade.
|
|
210
|
+
Adopted agent rows don't trigger the plaintext-token warning.
|
|
211
|
+
- **Archiving revokes** a narrative's agent token when its session is not
|
|
212
|
+
running. Unarchiving brings nothing back; a revive mints a new one.
|
|
213
|
+
- **An external goes with its dialer, always.** An external's token
|
|
214
|
+
records the token that dialed it (`minted_by`); it is that token's
|
|
215
|
+
credential in another form, since one `/ws/mcp` dial mints it, so
|
|
216
|
+
revoking the dialer revokes it with no checkbox (it isn't offered as a
|
|
217
|
+
choice in the cascade), and a completing rotation revokes every external
|
|
218
|
+
whose dialer is no longer live — a stolen token can't mint itself a
|
|
219
|
+
survivor. Spawned agents are not touched by a rotation. The requester's own
|
|
220
|
+
children are re-parented onto its replacement row.
|
|
221
|
+
- **The secret stays out of argv.** Launch commands reach tmux on stdin
|
|
222
|
+
(`newTmuxSession`, `tmux start-server \; source-file -`; `start-server`
|
|
223
|
+
because `source-file` alone needs a server already running), so neither a tmux client's nor
|
|
224
|
+
a newly started tmux server's arguments carry it (a server started before
|
|
225
|
+
this change keeps the command line it was started with). The daemon drops
|
|
226
|
+
any inherited agent session (`INHERITED_AGENT_ENV`, `src/agent-env.ts`:
|
|
227
|
+
`BRANE_NARRATIVE_*` and the harness's session-scoped variables such as
|
|
228
|
+
`CLAUDE_CODE_SESSION_ID` and `CLAUDE_CODE_MESSAGING_TOKEN`) from its own
|
|
229
|
+
env and the tmux server's global env, and utility sessions (`POST
|
|
230
|
+
/api/run`) unset
|
|
231
|
+
them, so nothing that isn't the agent acts as it. Without the harness
|
|
232
|
+
half, a daemon restarted from a Claude shell handed that session's id to
|
|
233
|
+
every non-Claude agent it spawned, whose bridge then stamped it on their
|
|
234
|
+
narrations (claude-eiuzl79r:33). Goose is the exception: its inline
|
|
235
|
+
`--with-extension` env is an argument, visible in `ps` for that agent's
|
|
236
|
+
life.
|
|
237
|
+
- **No rollback.** `main` before this change can't bind a session whose
|
|
238
|
+
secret exists only as a hash; going back rebinds such sessions to fresh
|
|
239
|
+
`external-*` narratives.
|
|
240
|
+
|
|
241
|
+
**Not containment:** an agent runs as your Unix user, so it can drop its
|
|
242
|
+
secret, reach `daemon.sock` with no token, and act as this machine instead
|
|
243
|
+
of as itself. Retiring `.daemon_token` did not change that; the socket gives
|
|
244
|
+
the same standing to the same account. A
|
|
245
|
+
per-agent token is attribution and revocation, not containment, and brane
|
|
246
|
+
offers no sandbox (SECURITY.md, "Who the operator is").
|
|
247
|
+
|
|
248
|
+
## No local token
|
|
249
|
+
|
|
250
|
+
This machine's standing is the account row: the one live `local` row, whose
|
|
251
|
+
`hash` column holds a placeholder (`no-token:<uuid>`, `ACCOUNT_HASH_PREFIX`)
|
|
252
|
+
instead of a SHA-256, so no presented token can ever match it. Only a
|
|
253
|
+
tokenless request over `daemon.sock` acts as it, and only this Unix account
|
|
254
|
+
can reach the socket.
|
|
255
|
+
|
|
256
|
+
Older branes kept a **local token** in `~/.brane/.daemon_token`, which the
|
|
257
|
+
CLI and bridges re-read on every use. It was a bearer token like any other:
|
|
258
|
+
it worked over TCP from any address the IP filter admits, so a copy of the
|
|
259
|
+
file (a backup, a synced home directory, an agent that printed it) was this
|
|
260
|
+
machine's shell from somewhere else on the tailnet. `bootstrapTokens`
|
|
261
|
+
retires it at daemon start:
|
|
262
|
+
|
|
263
|
+
- Every live `local` row that a token can open (`isTokenHash`) is revoked,
|
|
264
|
+
whether or not its token is still in the file. What any earlier `local`
|
|
265
|
+
row had minted, dialed in or launched — the rows just retired, and ones
|
|
266
|
+
revoked before, by a shell rotation on an older brane or by an older
|
|
267
|
+
daemon started in between — is re-parented onto the account row, so a device
|
|
268
|
+
`brane meld` added last week is still "added by this machine", the agents
|
|
269
|
+
`brane spawn` started are still listed under it, and its externals are
|
|
270
|
+
not swept by the next rotation.
|
|
271
|
+
- **The file is emptied, not removed.** Its hash stays a revoked row, so an
|
|
272
|
+
older brane started later cannot adopt the same token as a device. Emptying
|
|
273
|
+
matters most on an install from before hashing, where the file holds the
|
|
274
|
+
shared token every device still uses: that token stays live (adopted as a
|
|
275
|
+
device row), and must not stay on disk. If the file cannot be emptied the
|
|
276
|
+
daemon warns and carries on; the token in it is revoked either way.
|
|
277
|
+
- `brane doctor` reports the leftover (`– token file`) with the `rm` line.
|
|
278
|
+
Removing it is the operator's to do.
|
|
279
|
+
- Nothing writes the file again, and no client reads it. A CLI from just
|
|
280
|
+
before this change that finds it empty falls back to the socket and keeps
|
|
281
|
+
working.
|
|
282
|
+
|
|
283
|
+
**There is no fallback when the socket cannot be used.** If the daemon
|
|
284
|
+
cannot listen on it (the path is over 103 bytes, the filesystem cannot hold
|
|
285
|
+
a socket, something that is not a stale socket is in the way) or `~/.brane`
|
|
286
|
+
is not `0700` and the daemon's own, the shell CLI and a new external MCP
|
|
287
|
+
client cannot reach the daemon. The daemon logs why (`[socket]`), every CLI
|
|
288
|
+
command says so, and `brane doctor` fails its daemon check with the reason.
|
|
289
|
+
Agents and signed-in devices hold tokens and are unaffected. On such a
|
|
290
|
+
machine the first device comes from `--token` or `BRANE_DEVICE_TOKEN_SHA256`
|
|
291
|
+
at daemon start. Writing the file there instead would bring back the
|
|
292
|
+
copyable token on exactly the machines least looked at.
|
|
293
|
+
|
|
294
|
+
Pinned by `src/test/token-bootstrap.test.ts` ("retiring the local token")
|
|
295
|
+
and `src/test/socket-account.test.ts`.
|
|
296
|
+
|
|
297
|
+
## The Devices list
|
|
298
|
+
|
|
299
|
+
`GET /api/tokens` lists every live token, `brane token list` prints it, and
|
|
300
|
+
the brane switcher's **Devices** layer shows it with a Revoke on each other
|
|
301
|
+
device. Less obvious than it looks:
|
|
302
|
+
|
|
303
|
+
- **A row is named by its first sighting.** The store records the
|
|
304
|
+
User-Agent and peer address of the first request, HTTP or WebSocket, that
|
|
305
|
+
presents the token (`DbTokenStore.find`; columns `user_agent`,
|
|
306
|
+
`remote_addr`), and never overwrites them, so a row keeps naming the
|
|
307
|
+
device that claimed it. A rotation's replacement takes the sighting of the
|
|
308
|
+
request that confirms it, which is the requester's device.
|
|
309
|
+
- **A copy shows as a second address.** Beside the first sighting the store
|
|
310
|
+
keeps the latest (`last_user_agent`, `last_remote_addr`, on the same
|
|
311
|
+
once-a-minute throttle as `last_used_at`), and stamps `elsewhere_at` the
|
|
312
|
+
moment a token is presented from an address other than its first. The
|
|
313
|
+
stamp is sticky, so a copy used once is still flagged after the real
|
|
314
|
+
device is back. On a tailnet each device has a stable address, so a
|
|
315
|
+
second one is almost always a copy; it can also be the same phone on the
|
|
316
|
+
LAN, which is why the app says "also used from another address", not
|
|
317
|
+
"stolen". Loopback is one place however it is spelled (`samePlace`):
|
|
318
|
+
the MCP bridge dials `localhost`, often `::1`, the CLI `127.0.0.1`, and
|
|
319
|
+
both prefer the daemon's Unix socket, recorded as `daemon.sock` since it
|
|
320
|
+
has no address (`connectionLabel`); one token used by any of them is not
|
|
321
|
+
a copy.
|
|
322
|
+
- **The address is the only field a token holder cannot choose.** The
|
|
323
|
+
User-Agent and the label are both free text from whoever holds the
|
|
324
|
+
token. So the app never uses the label as a row's title — a row with a
|
|
325
|
+
narrative is "Agent <nid>", and a device whose User-Agent does not parse
|
|
326
|
+
is named by its address ("Device at 100.x.y.z"), label quoted beneath —
|
|
327
|
+
and nothing branches on the parsed name (`src/user-agent.ts`), which is
|
|
328
|
+
only for a person telling rows apart. A name from `tailscale whois` on
|
|
329
|
+
the address would be the first trustworthy one; it is not done yet,
|
|
330
|
+
since it shells out to the extension that has wedged before.
|
|
331
|
+
- **Who added a device is recorded at mint** (`minted_by`): the token whose
|
|
332
|
+
request minted it, shown as "added by Safari on iPhone" or "by this
|
|
333
|
+
machine" (`brane meld`). It can't be reconstructed later, which is why it
|
|
334
|
+
is written before anything uses it, and it is what the cascade below
|
|
335
|
+
walks.
|
|
336
|
+
- **Which device started each agent** is recorded the same way
|
|
337
|
+
(`narratives.launched_by`, written by spawn, run and revive), and the
|
|
338
|
+
list shows each token's agents. Each agent holds a token of its own
|
|
339
|
+
([Agent tokens](#agent-tokens)), which revoking the device offers in its
|
|
340
|
+
cascade. **A revoke never touches
|
|
341
|
+
their sessions**: only token revocation cascades, and tmux is left alone
|
|
342
|
+
(the operator's decision, claude-xk1iyc17:58).
|
|
343
|
+
- **`connected`** marks a token with a socket open now, since a client
|
|
344
|
+
parked on one makes no requests and its `last_used_at` stands still.
|
|
345
|
+
- **Recording is best-effort, like `last_used_at`**: it never fails the
|
|
346
|
+
request it rides on (nor a rotation's transaction), and control
|
|
347
|
+
characters become spaces before anything is stored, since a newline in a
|
|
348
|
+
list is a forged row.
|
|
349
|
+
- **An unscanned QR is listed**, as `unused` with its `expires_at`, until it
|
|
350
|
+
lapses: a code still on someone's screen is a live credential. Once
|
|
351
|
+
lapsed it is left out even before the sweep revokes it.
|
|
352
|
+
|
|
353
|
+
`POST /api/tokens/:id/revoke` is bodiless, so the app sends it without a
|
|
354
|
+
preflight, and stamps `revoked_at` (rows are never deleted). **Every socket
|
|
355
|
+
the revoked token opened closes with 4401**: control and terminal sockets,
|
|
356
|
+
and MCP sockets too when the token is a device token
|
|
357
|
+
(`closesOnRevoke`, `src/daemon/token-routes.ts`). The MCP one is the one
|
|
358
|
+
that matters: a device token can open `/ws/mcp`, which reaches every MCP
|
|
359
|
+
tool, and the first version of this list left it open, so a revoked token
|
|
360
|
+
kept narrating and reviving for as long as it stayed connected. The same
|
|
361
|
+
set is what a rotation closes. Local MCP bridges stay exempt; this account
|
|
362
|
+
is not what either retires.
|
|
363
|
+
|
|
364
|
+
It refuses three things, each naming what the caller probably meant: the
|
|
365
|
+
**local** row (400; it is this machine's account, which holds no token to
|
|
366
|
+
revoke), the **requester's own** token (409; "Sign
|
|
367
|
+
out other devices" keeps this one and signs out the rest), and an id that
|
|
368
|
+
is **not live** (404, which the app treats as done).
|
|
369
|
+
|
|
370
|
+
**A revoke can take the devices it added** (`?also=a,b`, in the query
|
|
371
|
+
string so the request stays bodiless; `brane token revoke --cascade`). The
|
|
372
|
+
first thing a stolen token's holder does is mint a spare, and a spare's
|
|
373
|
+
first sighting is the thief, so its row looks clean: without the cascade,
|
|
374
|
+
revoking the stolen row leaves them in. The app's confirm step lists every
|
|
375
|
+
device the one being revoked added, down the chain, each **checked by
|
|
376
|
+
default**, so the honest cost — you melded your laptop from the phone you
|
|
377
|
+
lost — is one visible untick, and the dishonest one is not silent.
|
|
378
|
+
`DbTokenStore.descendants` computes the set, walking through links that
|
|
379
|
+
are already revoked; the route revokes only ids in it, in one write, and
|
|
380
|
+
refuses the request otherwise. It never includes the requester or anything
|
|
381
|
+
the requester added by a path that doesn't run through the device being
|
|
382
|
+
revoked. Through the root matters: the requester is often an *ancestor* (this
|
|
383
|
+
machine's `brane meld` added the phone), and revoking the phone from here
|
|
384
|
+
must still take the laptop the phone added. The first version excluded the
|
|
385
|
+
requester's whole subtree and so found nothing in exactly that case; the
|
|
386
|
+
sandbox run caught it, and a test pins it. Once passkeys gate minting, a
|
|
387
|
+
stolen token can't mint spares at all, and the default may flip to
|
|
388
|
+
unchecked.
|
|
389
|
+
|
|
390
|
+
**Revoking a device abandons any rotation it had begun.** A pending
|
|
391
|
+
rotation is held in memory against its requester's row. Left alone, the
|
|
392
|
+
revoked device could confirm it afterwards: the confirm writes a fresh row
|
|
393
|
+
of the requester's kind and revokes every other device, so the device you
|
|
394
|
+
just signed out would be back in and everyone else would be out.
|
|
395
|
+
`TokenRing.abandonRotationBy` drops it.
|
|
396
|
+
|
|
397
|
+
**Who may revoke.** Any live token can revoke any other device, which is no
|
|
398
|
+
more than "Sign out other devices" already lets it do. Whether a passkey
|
|
399
|
+
should guard revoking, as it will guard adding a device, is open: see
|
|
400
|
+
[Passkeys](passkeys.md), open question 1.
|
|
401
|
+
|
|
402
|
+
**Known gaps, outside the token layer:**
|
|
403
|
+
|
|
404
|
+
- A revoke does not cut a streaming response already in flight (the Files
|
|
405
|
+
search and `POST /api/absorb`, both NDJSON). Each is bounded work, not a
|
|
406
|
+
listener, and ends on its own.
|
|
407
|
+
- The WebSocket origin check compares only the port for same-origin and
|
|
408
|
+
accepts any `*.ts.net` host. Nothing rides on it today, since every
|
|
409
|
+
request must present a token; a passkey verifier will need its own exact
|
|
410
|
+
origin list.
|
|
411
|
+
- Fold registration treats a loopback peer as the operator's shell, so a
|
|
412
|
+
forwarder to 127.0.0.1 (`tailscale serve`, an SSH tunnel) would make a
|
|
413
|
+
remote caller look local.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# View URLs
|
|
2
|
+
|
|
3
|
+
The query string of a narrative screen is not decoration — it is the only way
|
|
4
|
+
a view travels. Active tab, pinned tabs, open file and line range are kept in
|
|
5
|
+
`sessionStorage`, which is **origin-scoped**, and two menu items cross an
|
|
6
|
+
origin: **Isolate** hops between `brane.organelle.dev` and a daemon's own
|
|
7
|
+
bundle, and **Meld** hands a URL to another device. Whatever is not in the URL
|
|
8
|
+
does not arrive.
|
|
9
|
+
|
|
10
|
+
`src/shared/view-url.ts` holds the contract, window-free so it can be tested
|
|
11
|
+
without a browser (`src/test/view-url.test.ts` runs it over a grid of every
|
|
12
|
+
tab, pin subset, path and line spec):
|
|
13
|
+
|
|
14
|
+
| Piece | Does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `viewParams()` | The view as params, `undefined` for anything at its default — which is how `router.setParams` clears one |
|
|
17
|
+
| `parseActiveTab()`, `parsePinnedTabs()`, `parseLineSpec()` | Read them back. Writer and reader live together on purpose: a param whose two halves sit in different modules drifts |
|
|
18
|
+
| `buildViewUrl(target, opts)` | The same narrative, same view, somewhere else |
|
|
19
|
+
| `buildIsolateUrl()` / `buildMeldUrl()` | The same builder with one bit different — see below |
|
|
20
|
+
| `withoutConnectionParams()`, `withoutConnectionHash()` | Strip `host`/`token` from the query string and from the fragment, and leave everything else |
|
|
21
|
+
| `connectionFragment()` | `#host=…&token=…` for the shared app, `#token=…` for a daemon's own bundle — the one place the connection is written into a URL, used by `buildViewUrl` and by `brane meld` |
|
|
22
|
+
|
|
23
|
+
**Isolate flips the origin kind; Meld stays on it.** Isolate exists to change
|
|
24
|
+
which app serves you. Meld aims at another *device* — hand a phone a shared-SPA
|
|
25
|
+
link while the operator is reaching the daemon directly, and the phone loads an
|
|
26
|
+
app that has never heard of that daemon.
|
|
27
|
+
|
|
28
|
+
**Back undoes a jump, never a view change.** A jump pushes a history entry: to
|
|
29
|
+
a different narrative (from the list, an `@nid` link, the switcher, Revive or
|
|
30
|
+
Twin), and to a message by `nid:seq`, *including one in the narrative you are
|
|
31
|
+
already reading*. That last case is what makes a `/summarize` summary usable as
|
|
32
|
+
a table of contents: tap a citation, read, press Back, and you are where you
|
|
33
|
+
were, ready for the next one. Changing the view *within* a narrative (tab,
|
|
34
|
+
pins, Files path and line, the `?scrollToSeq=` selection) goes through
|
|
35
|
+
`router.setParams`, which Expo Router's linking writes with `replaceState`,
|
|
36
|
+
since the stack did not grow. So the URL keeps that state and Back returns to
|
|
37
|
+
it, but it never costs an extra Back: browsing ten files is one entry, not ten.
|
|
38
|
+
A bare link to the narrative you are in, with no seq, goes nowhere. The list
|
|
39
|
+
is a jump too: **All narratives** in the switcher pushes it, so Back returns
|
|
40
|
+
to the narrative, whether or not a list was already beneath. The header has no
|
|
41
|
+
← of its own; Back is the browser's.
|
|
42
|
+
|
|
43
|
+
Back is instant and complete because the Stack keeps the screens behind the
|
|
44
|
+
top one mounted. The list behind a narrative still holds its search query and
|
|
45
|
+
results, and the narrative behind another (or behind a later copy of itself)
|
|
46
|
+
still holds its scroll, selection and open file. Nothing is rebuilt from
|
|
47
|
+
storage. A deep stack is left mounted on purpose: on the web, a new tab is the
|
|
48
|
+
way out of one. Because two mounted screens can watch the same narrative,
|
|
49
|
+
`subscribe`/`unsubscribe` in `expo/lib/connection.ts` are refcounted. Otherwise
|
|
50
|
+
the top screen's unmount would silence the one that Back returns to.
|
|
51
|
+
|
|
52
|
+
Staying mounted has a cost: a screen behind the top still runs its effects, so
|
|
53
|
+
a listener it registers on `document` still hears every event on the page,
|
|
54
|
+
including taps on the screen in front. And `router.setParams` is the *global*
|
|
55
|
+
router: it writes to whichever route is focused, not to the screen that
|
|
56
|
+
called it. So a backgrounded screen that reacts to a document event by writing
|
|
57
|
+
its view writes it into the URL of the screen you are looking at, and that
|
|
58
|
+
screen's URL-sync effect adopts it. That is how tapping a Terminal pane once
|
|
59
|
+
opened and pinned Chat: the narrative underneath was on its Chat tab, its
|
|
60
|
+
pane-select handler pinned the outgoing Chat, and the pin landed on top. Every
|
|
61
|
+
document-level listener in `[nid].tsx` is therefore gated on `useIsFocused()`,
|
|
62
|
+
the same rule #342 applied to keystrokes.
|
|
63
|
+
|
|
64
|
+
**Why a same-narrative jump is a new screen, not a scroll.** Expo Router (v55,
|
|
65
|
+
React Navigation 7) has no way to add a history entry without growing a stack:
|
|
66
|
+
no `pushParams`, and `singular`/`getId` reuse a screen by moving it, which
|
|
67
|
+
shrinks the stack. A scroll-in-place jump with its own `pushState` was tried
|
|
68
|
+
(#554) and the Back entry was set aside, because the router keeps its own model
|
|
69
|
+
of the history stack. An entry it didn't write makes browser Back `resetRoot`
|
|
70
|
+
the stack (unmounting what's under it) and made the header's own ← (since
|
|
71
|
+
removed; the browser's Back is the only one) miscount its `history.go(n)`.
|
|
72
|
+
Re-rendering the screen costs a React render, not a page load.
|
|
73
|
+
|
|
74
|
+
**Seed from the URL, then overlay live state.** Seeding carries params the
|
|
75
|
+
screen doesn't model (`scrollToSeq`). The overlay wins on everything it does
|
|
76
|
+
own, because a tab layout restored from `sessionStorage` deliberately never
|
|
77
|
+
writes itself back to the URL (`router.setParams` there can run before the root
|
|
78
|
+
layout mounts and blank the screen). A copy of `window.location.search` alone
|
|
79
|
+
would miss exactly that case.
|
|
80
|
+
|
|
81
|
+
**Connection params are never carried, only re-set.** They describe the origin
|
|
82
|
+
building the link. And the receiving side cleans them: `clearMeldUrl` in
|
|
83
|
+
`app/_layout.tsx` strips `host`/`token` from the address bar once the meld has
|
|
84
|
+
been used — with `withoutConnectionParams` and `withoutConnectionHash`, *not*
|
|
85
|
+
by resetting to the bare pathname, which would throw away the view that just
|
|
86
|
+
arrived.
|
|
87
|
+
|
|
88
|
+
**The connection rides in the fragment; the view rides in the query string.**
|
|
89
|
+
A query string is part of the HTTP request. On a `brane.organelle.dev` link
|
|
90
|
+
that meant the daemon's bearer token and tailnet hostname reached Cloudflare's
|
|
91
|
+
edge with every meld; on an Isolate hop it landed in the daemon's own request
|
|
92
|
+
log; and a meld URL pasted into iMessage or Slack handed it to the link
|
|
93
|
+
unfurler. A fragment is never sent to any server, so `#host=…&token=…` is what
|
|
94
|
+
every link carries now (`connectionFragment`), and `?token=` is read only for
|
|
95
|
+
old QR codes and bookmarks. The view stays in the query string because it is
|
|
96
|
+
harmless to share and because the fragment belongs to the connection.
|
|
97
|
+
|
|
98
|
+
**The fragment must be consumed before Expo Router reads the URL.** On web the
|
|
99
|
+
router parses the initial URL's hash into a route param named `#` and appends
|
|
100
|
+
it again every time it writes the URL (`appendQueryAndHash` in its
|
|
101
|
+
`getPathFromState` fork, and `routeInfo`). Consume it in a `useEffect` and
|
|
102
|
+
strip it with `replaceState`, and the next `router.setParams` — a tab change,
|
|
103
|
+
a pin, an opened file — writes the token straight back into the address bar
|
|
104
|
+
and the back stack. So `expo/lib/api.ts` runs `consumeConnectionFromLocation`
|
|
105
|
+
at **module evaluation**: the credentials go into the `sessionStorage` slot
|
|
106
|
+
`parseConnectionParams` already falls back to, and the URL is rewritten
|
|
107
|
+
without them, view intact. Query params get the same treatment. Observed in a
|
|
108
|
+
headless browser (claude-7irbudpw:65): the router still restores the hash on
|
|
109
|
+
its *first* URL write, so the fragment is visible while the "Meld this
|
|
110
|
+
device?" dialog is up; `clearMeldUrl` removes it at confirm, and it did not
|
|
111
|
+
come back across an in-app card tap and two tab changes. So the module-level
|
|
112
|
+
consume is what gets the credentials into storage early, `clearMeldUrl` is
|
|
113
|
+
what actually clears the address bar, and `clearStoredConnection` still ends
|
|
114
|
+
the flow.
|
package/docs/web-app.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Web App (Expo)
|
|
2
|
+
|
|
3
|
+
The Expo app runs as a web PWA served by the daemon (and optionally via Cloudflare Workers at `brane.organelle.dev`). It provides:
|
|
4
|
+
|
|
5
|
+
- **Narrative list** — shows all narratives with summary, status, and MCP connection indicator
|
|
6
|
+
- **Spawn button** — header button that opens fold picker modal (or spawns directly with 1 fold)
|
|
7
|
+
- **Chat view** — message bubbles (blue for operator, gray for agent, pastel for foreign)
|
|
8
|
+
- **🎨 Recolor** — a 🦠 menu item that reshuffles the hues foreign speakers wear in one narrative, for when two agents hash to near-identical colors. Hues come from `src/shared/speaker-color.ts` (salt 0 is the palette every narrative has always had; a nonzero salt is mixed in per speaker through a nonlinear finalizer, since seeding the polynomial hash would rotate every equal-length nid by the same amount). The salt is a per-device view preference in `localStorage` (`expo/lib/recolor.ts`), never sent to the daemon; the list card's menu offers the same item and only advances the stored salt. Export sends it along (`&palette=`), so a snapshot shows the palette the operator was looking at
|
|
9
|
+
- **Terminal view** — live tmux mirroring with ANSI color rendering (ansi_up)
|
|
10
|
+
- **Files view** — browse the narrative's fold: directory listing, Shiki-highlighted code (`CodeView`), rendered markdown (`Markdown`), PDFs (`PdfView`, platform-split — the `.web` variant lazy-imports pdf.js), hex for everything else (`HexView`), and archives as folders — see [Files View Formats](files-view.md#files-view-formats). The bottom bar searches whatever is showing: **🔎 find in file** on code and markdown, and on hex, where the daemon searches every byte (see [Searching the bytes](files-view.md#searching-the-bytes)) (smart-case literal, `:N` jumps to a line, hits painted with the CSS Custom Highlight API so no DOM changes — `expo/lib/findInFile.ts`, rules in `src/shared/find-in-text.ts`). The hit counter is a drum (`HitDrum`, physics in `src/shared/hit-drum.ts`): faces read `3/17` right-aligned so the totals stand in one column, swipe or flick to roll, tap a half to step, Enter / ⇧Enter roll it too. It stops at the ends (rubber-band past them, a bump on a step into them). The page doesn't glide with it: as the drum crosses each detent the page snaps that hit onto the scroller's middle line, so every hit lands where the eye already is, and half a screen of room below the file lets the last hits reach it. Settling with the keyboard down writes `line=` as closing search does; clearing the query takes it back, **🔮 fuzzy path finder** over `/api/files/list` everywhere else. The view picks the mode and the emoji shows it — there is no switch; the query survives navigation and re-runs in the next view's mode. Closing 🔎 on a hit writes its line into the URL. `#L10-L20` line specs deep-link and scroll to a range
|
|
11
|
+
- **Markdown link handling** — `MarkdownLinksProvider` (`components/MarkdownLinks.tsx`) is mounted once per screen and supplies `knownNarrativeIds`, `knownProtocols` (defaulting to `useBrane().protocols`), `onNarrativePress`, and `onProtocolPress` to every `MarkdownContent` beneath it, so chat bubbles, Files-tab markdown, and the wiki reader linkify `nid:seq` and `/command` identically without threading props. Nested providers override only the fields they set (the wiki modal wraps itself to close before navigating); explicit props on `MarkdownContent` still win
|
|
12
|
+
- **Tabs** — Chat / Terminal / Files. The active tab plus any **pinned** tabs render simultaneously; the set persists per narrative in storage and round-trips through the `tab` and `pinned` URL params. Tapping into a visible pane selects it *without changing which panes are visible* and never unpins anything — a pin comes off only through its 📌 (`selectPane`, `src/shared/pane-layout.ts`), and a press on a control — a quick key, a menu, a copy button — selects nothing at all: selection runs on `pointerdown`, so a pane that widens under the finger slides the button away before the release, and the tap that caused it is never delivered as a `click`
|
|
13
|
+
- **Quick bar** — macros (via MacroMenu component) + quick keys
|
|
14
|
+
- **Full software keyboard** — multitouch with sticky/held modifiers
|
|
15
|
+
- **Live updates** — control WebSocket pushes `status`, `meta`, and `new_events` hints
|
|
16
|
+
- **Archive/unarchive** — swipe or long-press to archive narratives
|
|
17
|
+
- **Payload rendering** — inline display of image payloads in chat
|
|
18
|
+
- **Export** — download a narrative as standalone HTML via `/api/narratives/:id/export?format=html`. Foreign rows carry their speaker's hue inline, from the same `speakerColor` / `speakerBgColor` the chat uses, at the 🎨 Recolor salt the device sends as `&palette=` — the one place the salt leaves the browser. With messages selected, the menu offers both sides of the selection — **those messages only**, or the conversation with **those messages left out** — see [Export Scope](export-scope.md)
|
|
19
|
+
- **Brane switcher** — dropdown to filter narratives by brane, add/switch daemons. Each brane row (and "All branes") shows its running metered cost as `$2441.72 (138) + 100.2 AIC (1)` — one total per unit with the number of sessions it covers, so a per-session figure can be read off without the line asserting one; the list screen re-reads `/api/branes` (debounced) when a cost `meta` arrives, since the total includes archived narratives the screen never loads. **Devices** lists every token on the active daemon (this device, this machine's CLI and agents, each melded device by browser, platform, address and when it was last seen, and any meld QR not yet scanned) with a Revoke on each other device; **Sign out other devices**, at the foot of that list, rotates instead — see [Token Rotation](token-rotation.md#the-devices-list)
|
|
20
|
+
|
|
21
|
+
`expo/lib/api.ts` also exports `searchNarratives()`, `searchWiki()`, and `readWikiSection()` clients for `/api/search` and `/api/wiki/*`. These have no UI yet — the transport landed ahead of the views.
|
|
22
|
+
|
|
23
|
+
Connection via the URL **fragment**, `#host=<host>&token=<token>` on the shared app and `#token=<token>` on a daemon's own bundle (the legacy `?token=` query form is still read). Tailscale hostname auto-detected by `brane meld`. See [View URLs](view-urls.md) for why the fragment, and for the Expo Router trap it has to dodge.
|
package/docs/wiki.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Wiki: Local and Shared
|
|
2
|
+
|
|
3
|
+
A fold's wiki is `wiki/` plus at most one subdirectory, `wiki/shared/`.
|
|
4
|
+
Both are flat. A page's **slug is its path under `wiki/`** — `foo.md` is
|
|
5
|
+
local, `shared/foo.md` is shared — so a citation (`slug#anchor`) means the
|
|
6
|
+
same thing to every reader and never depends on who is asking.
|
|
7
|
+
|
|
8
|
+
One visibility rule, no special cases. Every wiki source is `(dir, owned)`:
|
|
9
|
+
|
|
10
|
+
| Caller | Sees |
|
|
11
|
+
|---|---|
|
|
12
|
+
| An agent in its own fold | everything under `wiki/` |
|
|
13
|
+
| An agent reaching another fold (`all: true`, `source:`) | that fold's `shared/` only |
|
|
14
|
+
| The operator over HTTP (`/api/wiki/*`) | everything, in every fold — scoping is about agents' reach, not operator secrecy |
|
|
15
|
+
|
|
16
|
+
`list_wiki`, `read_wiki`, and `search_wiki` all apply it (`shared_only` on
|
|
17
|
+
`WikiSource`, `src/memory/wiki.ts`). Reading another fold's local page
|
|
18
|
+
returns *"local to wiki source X, not shared"* rather than not-found, and
|
|
19
|
+
`locatePage` lets a bare `foo` fall through to `shared/foo.md`. The index
|
|
20
|
+
holds a fold's whole wiki (its owner searches it too); visibility is a
|
|
21
|
+
query-time filter, so no schema changed.
|
|
22
|
+
|
|
23
|
+
**The install directory is a fold.** `ensureInstallFold` registers the
|
|
24
|
+
package root (`dist/../..` — a git checkout, a global npm install, a
|
|
25
|
+
`node_modules` copy) in `folds` at daemon startup if it isn't there, and
|
|
26
|
+
never touches an existing row. There is no longer a hardcoded `brane` wiki
|
|
27
|
+
source: brane's shipped pages are the install fold's `shared/`, visible to
|
|
28
|
+
every fold by the rule above and *owned* only when the caller's fold is the
|
|
29
|
+
checkout itself. `package.json` `files` ships the single directory
|
|
30
|
+
`wiki/shared`, which is what makes the tarball the same set of pages the
|
|
31
|
+
daemon serves. Source labels are the fold's basename, disambiguated in
|
|
32
|
+
registration order (`brane`, `brane~2`) by `wikiFoldLabels` so a label is
|
|
33
|
+
stable across callers; `protocols` is reserved.
|
|
34
|
+
|
|
35
|
+
**Links are filesystem-relative, and shared → local is a citation, not a
|
|
36
|
+
promise.** From `shared/a.md`, `b.md` is `shared/b.md` and `../c.md` is the
|
|
37
|
+
local `c.md`. `resolveWikiLink` in `src/shared/wiki-links.ts` is the one
|
|
38
|
+
resolver, used by `expo/components/WikiReader.tsx` and pinned by
|
|
39
|
+
`src/test/wiki-links.test.ts`; it returns `null` for a scheme, an absolute
|
|
40
|
+
path, or a climb above `wiki/`. A shared page may link to a local one — a
|
|
41
|
+
non-owner following it gets the scoped error — so the protocol requires a
|
|
42
|
+
shared page to read without its local links. Which side a page lives on is
|
|
43
|
+
the operator's call; `/wiki` never moves pages.
|