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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tokens` table: the daemon's bearer tokens, by hash only. The ring that
|
|
3
|
+
* reads it — authentication, rotation, and why only hashes — is
|
|
4
|
+
* `src/daemon/token-ring.ts`; this module is the storage and the one-time
|
|
5
|
+
* migration from the plaintext token brane kept before.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { hashToken, LEGACY_TOKEN_LABEL, UNUSED_TOKEN_LAPSE_MS, } from "../daemon/token-ring.js";
|
|
10
|
+
/** A user agent is shown in a devices list and nowhere else; past this it is
|
|
11
|
+
* noise, and an unbounded header is not something to keep forever. */
|
|
12
|
+
const MAX_USER_AGENT_LENGTH = 512;
|
|
13
|
+
/** A header or address as it may be stored and shown: control characters
|
|
14
|
+
* become spaces (a newline in a list is a forged row), capped, or null. */
|
|
15
|
+
function cleanSighting(value, max) {
|
|
16
|
+
if (typeof value !== "string")
|
|
17
|
+
return null;
|
|
18
|
+
const clean = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, max);
|
|
19
|
+
return clean || null;
|
|
20
|
+
}
|
|
21
|
+
/** An IPv4 peer arrives on a dual-stack socket as `::ffff:100.64.0.1`. */
|
|
22
|
+
export function normalizeRemoteAddr(addr) {
|
|
23
|
+
const clean = cleanSighting(addr, 64);
|
|
24
|
+
return clean?.startsWith("::ffff:") && clean.includes(".") ? clean.slice("::ffff:".length) : clean;
|
|
25
|
+
}
|
|
26
|
+
/** The sighting recorded for a connection on the daemon's Unix socket,
|
|
27
|
+
* which has no address of its own. */
|
|
28
|
+
export const UNIX_SOCKET_SIGHTING = "daemon.sock";
|
|
29
|
+
/** Whether two sightings are the same place. Loopback is one place however
|
|
30
|
+
* it is spelled: the MCP bridge dials `localhost` (often `::1`), the CLI
|
|
31
|
+
* dials `127.0.0.1` or `daemon.sock`, and a token used by both is not a
|
|
32
|
+
* copy. */
|
|
33
|
+
export function samePlace(a, b) {
|
|
34
|
+
const loopback = (x) => x === "::1" || x === UNIX_SOCKET_SIGHTING || /^127\.\d+\.\d+\.\d+$/.test(x);
|
|
35
|
+
return a === b || (loopback(a) && loopback(b));
|
|
36
|
+
}
|
|
37
|
+
/** Record which token's request launched (spawned, ran or revived) a
|
|
38
|
+
* narrative's process, on the narrative and on the agent token that launch
|
|
39
|
+
* minted — which is what lets revoking a device cascade to the agents it
|
|
40
|
+
* started. Best-effort: it labels, and must never fail the launch it
|
|
41
|
+
* follows. */
|
|
42
|
+
export function recordLauncher(db, nid, tokenId) {
|
|
43
|
+
try {
|
|
44
|
+
db.prepare("UPDATE narratives SET launched_by = ? WHERE id = ?").run(tokenId, nid);
|
|
45
|
+
db.prepare("UPDATE tokens SET minted_by = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND minted_by IS NULL").run(tokenId, nid);
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
/** Mint a narrative's agent token: the credential its session uses for
|
|
50
|
+
* everything (the MCP bridge, the `brane` CLI in its shell, hooks), handed
|
|
51
|
+
* to it in the tmux env as `BRANE_NARRATIVE_SECRET`. The daemon keeps only
|
|
52
|
+
* the hash. Every launch mints a fresh one and revokes the narrative's
|
|
53
|
+
* previous ones in the same write, so exactly one is live per narrative and
|
|
54
|
+
* a revoked agent comes back only by being revived. Returns the plaintext,
|
|
55
|
+
* which the caller puts in the session's env and nowhere else. */
|
|
56
|
+
export function mintAgentToken(db, nid, now = Date.now(), mint = () => randomUUID(),
|
|
57
|
+
/** false: leave the previous token live, for a launch that may still fail —
|
|
58
|
+
* the caller then calls `settleAgentLaunch` with the outcome. */
|
|
59
|
+
revokePrevious = true) {
|
|
60
|
+
const token = mint();
|
|
61
|
+
db.transaction(() => {
|
|
62
|
+
if (revokePrevious) {
|
|
63
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL").run(now, nid);
|
|
64
|
+
}
|
|
65
|
+
db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
|
|
66
|
+
VALUES (?, ?, 'agent', ?, 'minted', ?, ?, ?)`).run(randomUUID(), hashToken(token), nid, now, now, nid);
|
|
67
|
+
})();
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
/** Finish a launch that minted with `revokePrevious = false`. On success the
|
|
71
|
+
* narrative's other agent tokens are revoked, so one is live; on failure the
|
|
72
|
+
* new token is, and the previous one stands. Without this split, a revive
|
|
73
|
+
* that raced another (a double tap) would revoke the running session's
|
|
74
|
+
* token and then fail to launch — leaving the live agent cut off by a revive
|
|
75
|
+
* that never happened. */
|
|
76
|
+
export function settleAgentLaunch(db, nid, token, launched, now = Date.now()) {
|
|
77
|
+
const hash = hashToken(token);
|
|
78
|
+
if (launched) {
|
|
79
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL AND hash != ?").run(now, nid, hash);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE hash = ? AND revoked_at IS NULL").run(now, hash);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The id of `nid`'s live agent token row, if it has one. */
|
|
86
|
+
export function liveAgentTokenId(db, nid) {
|
|
87
|
+
const row = db
|
|
88
|
+
.prepare("SELECT id FROM tokens WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
|
89
|
+
.get(nid);
|
|
90
|
+
return row?.id ?? null;
|
|
91
|
+
}
|
|
92
|
+
/** Whether `secret` is `nid`'s live agent token. What `/ws/mcp` and the hook
|
|
93
|
+
* route check a narrative claim against, in place of comparing the plaintext
|
|
94
|
+
* `narratives.bind_token` brane used to keep. */
|
|
95
|
+
export function verifyAgentToken(db, nid, secret) {
|
|
96
|
+
return (db
|
|
97
|
+
.prepare("SELECT 1 FROM tokens WHERE hash = ? AND kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL")
|
|
98
|
+
.get(hashToken(secret), nid) !== undefined);
|
|
99
|
+
}
|
|
100
|
+
/** Adopt the plaintext bind secret of each narrative in `only` as an agent
|
|
101
|
+
* token, so sessions launched before agent tokens and still running keep
|
|
102
|
+
* working: their tmux env holds that secret, and it is now checked by hash.
|
|
103
|
+
* Only running sessions: a dead or archived narrative's stored secret would
|
|
104
|
+
* otherwise become a permanent bearer token for a session nobody is running
|
|
105
|
+
* (367 of them on the first machine this ran on, claude-jrjh1e8w:13 #3).
|
|
106
|
+
* Omit `only` to adopt every stored secret (tests). Idempotent, and a hash
|
|
107
|
+
* already known — revoked or not — is never adopted again. The plaintext
|
|
108
|
+
* column is left as it is; a secret not adopted authenticates nothing. */
|
|
109
|
+
export function adoptBindTokens(db, now = Date.now(), only) {
|
|
110
|
+
const rows = db
|
|
111
|
+
.prepare("SELECT id, bind_token FROM narratives WHERE bind_token IS NOT NULL AND bind_token != ''")
|
|
112
|
+
.all().filter((r) => !only || only.has(r.id));
|
|
113
|
+
const known = db.prepare("SELECT 1 FROM tokens WHERE hash = ?");
|
|
114
|
+
const insert = db.prepare(`INSERT INTO tokens (id, hash, kind, label, origin, created_at, last_used_at, owner_nid)
|
|
115
|
+
VALUES (?, ?, 'agent', ?, 'adopted', ?, NULL, ?)`);
|
|
116
|
+
let adopted = 0;
|
|
117
|
+
db.transaction(() => {
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
const hash = hashToken(r.bind_token);
|
|
120
|
+
if (known.get(hash))
|
|
121
|
+
continue;
|
|
122
|
+
insert.run(randomUUID(), hash, r.id, now, r.id);
|
|
123
|
+
adopted++;
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
return adopted;
|
|
127
|
+
}
|
|
128
|
+
/** `last_used_at` is a "last seen" for a devices list, not an audit log, so a
|
|
129
|
+
* busy token writes it at most this often rather than on every request. */
|
|
130
|
+
const TOUCH_INTERVAL_MS = 60_000;
|
|
131
|
+
export { LEGACY_TOKEN_LABEL };
|
|
132
|
+
const ROW_COLUMNS = "id, kind, label, origin, created_at, last_used_at, user_agent, remote_addr, " +
|
|
133
|
+
"last_user_agent, last_remote_addr, elsewhere_at, minted_by, owner_nid";
|
|
134
|
+
/** `ROW_COLUMNS` qualified as `t.`, plus what the minting token is, for the list. */
|
|
135
|
+
const LISTING_COLUMNS = ROW_COLUMNS.split(", ").map((c) => `t.${c}`).join(", ") +
|
|
136
|
+
", m.kind AS minted_by_kind, m.user_agent AS minted_by_user_agent";
|
|
137
|
+
/** The rows `UNUSED_TOKEN_LAPSE_MS` applies to, as SQL: minted device tokens
|
|
138
|
+
* never presented. */
|
|
139
|
+
const UNUSED_DEVICE = "kind = 'device' AND origin = 'minted' AND last_used_at IS NULL";
|
|
140
|
+
export class DbTokenStore {
|
|
141
|
+
db;
|
|
142
|
+
touched = new Map();
|
|
143
|
+
/** Rows presented since this daemon started. A token's first use is what
|
|
144
|
+
* saves it from lapsing, and the `last_used_at` stamp that records it is
|
|
145
|
+
* best-effort; this is the in-memory record that cannot fail. */
|
|
146
|
+
used = new Set();
|
|
147
|
+
/** Rows whose sighting is on disk, so a busy token checks it once per
|
|
148
|
+
* daemon rather than on every request. */
|
|
149
|
+
sighted = new Set();
|
|
150
|
+
/** When this daemon last stamped `elsewhere_at`, per row, so a copy in
|
|
151
|
+
* steady use writes at most once a minute. */
|
|
152
|
+
elsewhereStamped = new Map();
|
|
153
|
+
constructor(db) {
|
|
154
|
+
this.db = db;
|
|
155
|
+
}
|
|
156
|
+
find(hash, now, seen) {
|
|
157
|
+
const row = this.db
|
|
158
|
+
.prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE hash = ? AND revoked_at IS NULL`)
|
|
159
|
+
.get(hash);
|
|
160
|
+
return row ? this.admit(row, now, seen) : null;
|
|
161
|
+
}
|
|
162
|
+
account(now, seen) {
|
|
163
|
+
const row = this.db
|
|
164
|
+
.prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1`)
|
|
165
|
+
.get();
|
|
166
|
+
return row ? this.admit(row, now, seen) : null;
|
|
167
|
+
}
|
|
168
|
+
/** A live row being used: refuse it if it has lapsed, else stamp its
|
|
169
|
+
* sighting and last use, and return it. */
|
|
170
|
+
admit(row, now, seen) {
|
|
171
|
+
if (this.lapsed(row, now)) {
|
|
172
|
+
// Refused whether or not the revoke lands; the sweep retries it.
|
|
173
|
+
try {
|
|
174
|
+
this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(now, row.id);
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
this.used.add(row.id);
|
|
180
|
+
if (seen && !this.sighted.has(row.id)) {
|
|
181
|
+
if (row.user_agent !== null || row.remote_addr !== null)
|
|
182
|
+
this.sighted.add(row.id);
|
|
183
|
+
// Best-effort, like the stamp below; the next request retries it.
|
|
184
|
+
else
|
|
185
|
+
try {
|
|
186
|
+
if (this.recordSighting(row.id, seen))
|
|
187
|
+
this.sighted.add(row.id);
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
191
|
+
// The first sighting names the row; the latest, and whether it came from
|
|
192
|
+
// somewhere else, is what shows a copied token. An address is the one
|
|
193
|
+
// thing about a request its sender cannot choose, so it is what's compared.
|
|
194
|
+
const userAgent = seen ? cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH) : null;
|
|
195
|
+
const remoteAddr = seen ? normalizeRemoteAddr(seen.remoteAddr) : null;
|
|
196
|
+
const elsewhere = remoteAddr !== null && row.remote_addr !== null && !samePlace(remoteAddr, row.remote_addr);
|
|
197
|
+
const last = this.touched.get(row.id) ?? row.last_used_at ?? 0;
|
|
198
|
+
const lastElsewhere = this.elsewhereStamped.get(row.id) ?? row.elsewhere_at ?? 0;
|
|
199
|
+
if (now - last >= TOUCH_INTERVAL_MS || (elsewhere && now - lastElsewhere >= TOUCH_INTERVAL_MS)) {
|
|
200
|
+
// Best-effort: a "last seen" stamp must never fail the request it rides
|
|
201
|
+
// on (a busy or read-only database, say). The next request retries it.
|
|
202
|
+
try {
|
|
203
|
+
this.db
|
|
204
|
+
.prepare(`UPDATE tokens SET last_used_at = ?,
|
|
205
|
+
last_user_agent = COALESCE(?, last_user_agent),
|
|
206
|
+
last_remote_addr = COALESCE(?, last_remote_addr),
|
|
207
|
+
elsewhere_at = CASE WHEN ? THEN ? ELSE elsewhere_at END
|
|
208
|
+
WHERE id = ?`)
|
|
209
|
+
.run(now, userAgent, remoteAddr, elsewhere ? 1 : 0, now, row.id);
|
|
210
|
+
this.touched.set(row.id, now);
|
|
211
|
+
if (elsewhere)
|
|
212
|
+
this.elsewhereStamped.set(row.id, now);
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
return { id: row.id, kind: row.kind, label: row.label, ...(row.owner_nid ? { ownerNid: row.owner_nid } : {}) };
|
|
217
|
+
}
|
|
218
|
+
/** Whether this hash has *ever* been a token, revoked or not — so a
|
|
219
|
+
* revoked token presented again at startup is never re-adopted. */
|
|
220
|
+
known(hash) {
|
|
221
|
+
return this.db.prepare("SELECT 1 FROM tokens WHERE hash = ?").get(hash) !== undefined;
|
|
222
|
+
}
|
|
223
|
+
insert(hash, kind, label, now, opts = {}) {
|
|
224
|
+
const record = this.write(hash, kind, label, now, "minted");
|
|
225
|
+
if (opts.mintedBy)
|
|
226
|
+
this.db.prepare("UPDATE tokens SET minted_by = ? WHERE id = ?").run(opts.mintedBy, record.id);
|
|
227
|
+
if (opts.used) {
|
|
228
|
+
this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").run(now, record.id);
|
|
229
|
+
this.used.add(record.id);
|
|
230
|
+
this.touched.set(record.id, now);
|
|
231
|
+
}
|
|
232
|
+
// Best-effort: a rotation's replacement is inserted inside the rotation's
|
|
233
|
+
// transaction, and a label for a list must never be what aborts it.
|
|
234
|
+
if (opts.seen)
|
|
235
|
+
try {
|
|
236
|
+
if (this.recordSighting(record.id, opts.seen))
|
|
237
|
+
this.sighted.add(record.id);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
return record;
|
|
241
|
+
}
|
|
242
|
+
/** Write a row's first sighting, if it has none. Never overwrites one: the
|
|
243
|
+
* row should keep naming the device that first presented it, not whoever
|
|
244
|
+
* presents it next. Returns whether anything was written. */
|
|
245
|
+
recordSighting(id, seen) {
|
|
246
|
+
const userAgent = cleanSighting(seen.userAgent, MAX_USER_AGENT_LENGTH);
|
|
247
|
+
const remoteAddr = normalizeRemoteAddr(seen.remoteAddr);
|
|
248
|
+
if (userAgent === null && remoteAddr === null)
|
|
249
|
+
return false;
|
|
250
|
+
this.db
|
|
251
|
+
.prepare("UPDATE tokens SET user_agent = ?, remote_addr = ? WHERE id = ? AND user_agent IS NULL AND remote_addr IS NULL")
|
|
252
|
+
.run(userAgent, remoteAddr, id);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/** Every live row, oldest first, for the Devices list. A minted token that
|
|
256
|
+
* has already lapsed but not yet been swept is left out: it can no longer
|
|
257
|
+
* authenticate, so it is not a device. */
|
|
258
|
+
list(now) {
|
|
259
|
+
const rows = this.db
|
|
260
|
+
.prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
|
|
261
|
+
WHERE t.revoked_at IS NULL ORDER BY t.created_at, t.id`)
|
|
262
|
+
.all();
|
|
263
|
+
return rows
|
|
264
|
+
.filter((r) => !this.lapsed(r, now))
|
|
265
|
+
.map((r) => ({ ...r, unused: this.unused(r) }));
|
|
266
|
+
}
|
|
267
|
+
retireDependents(_retired, requesterId, replacementId, now) {
|
|
268
|
+
this.db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?").run(replacementId, requesterId);
|
|
269
|
+
// Every external whose dialer is no longer live — the rows this rotation
|
|
270
|
+
// retired, and any revoked earlier without its externals (by a store
|
|
271
|
+
// from before that rule, say) — so no rotation leaves one standing.
|
|
272
|
+
const ids = this.db
|
|
273
|
+
.prepare(`SELECT t.id FROM tokens t
|
|
274
|
+
JOIN narratives n ON n.id = t.owner_nid
|
|
275
|
+
LEFT JOIN tokens d ON d.id = t.minted_by
|
|
276
|
+
WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
|
|
277
|
+
AND t.minted_by IS NOT NULL AND (d.id IS NULL OR d.revoked_at IS NOT NULL)`)
|
|
278
|
+
.all().map((r) => r.id);
|
|
279
|
+
this.revoke(ids, now);
|
|
280
|
+
return ids;
|
|
281
|
+
}
|
|
282
|
+
/** The live agent tokens of external narratives that any of `dialers`
|
|
283
|
+
* dialed in. An external's token is its dialer's credential in another
|
|
284
|
+
* form — one `/ws/mcp` dial mints it — so it goes wherever its dialer
|
|
285
|
+
* goes, without asking (claude-jrjh1e8w:22). */
|
|
286
|
+
externalsOf(dialers) {
|
|
287
|
+
if (dialers.length === 0)
|
|
288
|
+
return [];
|
|
289
|
+
return this.db
|
|
290
|
+
.prepare(`SELECT t.id FROM tokens t JOIN narratives n ON n.id = t.owner_nid
|
|
291
|
+
WHERE t.kind = 'agent' AND t.revoked_at IS NULL AND n.agent_type = 'external'
|
|
292
|
+
AND t.minted_by IN (${dialers.map(() => "?").join(", ")})`)
|
|
293
|
+
.all(...dialers).map((r) => r.id);
|
|
294
|
+
}
|
|
295
|
+
/** Every live device row minted by `rootId`, directly or through a chain of
|
|
296
|
+
* devices, revoked links included (a laptop revoked last week still
|
|
297
|
+
* connects the phone to the tablet it added) — what a cascading revoke may
|
|
298
|
+
* take. Never the `keep` row or anything it added by a path that does not
|
|
299
|
+
* run through `rootId`: that is the requester, and a cascade that reached
|
|
300
|
+
* it would strand the device doing the revoking. (Not through the root,
|
|
301
|
+
* because the requester is often an *ancestor*: `brane meld` from this
|
|
302
|
+
* machine added the phone, and revoking the phone from here must still
|
|
303
|
+
* take the laptop the phone added.) */
|
|
304
|
+
descendants(rootId, keep) {
|
|
305
|
+
const rows = this.db.prepare("SELECT id, kind, minted_by, revoked_at FROM tokens").all();
|
|
306
|
+
const children = new Map();
|
|
307
|
+
for (const r of rows) {
|
|
308
|
+
if (r.minted_by === null)
|
|
309
|
+
continue;
|
|
310
|
+
const list = children.get(r.minted_by) ?? [];
|
|
311
|
+
list.push(r);
|
|
312
|
+
children.set(r.minted_by, list);
|
|
313
|
+
}
|
|
314
|
+
const subtree = (from, stopAt) => {
|
|
315
|
+
const seen = new Set([from]);
|
|
316
|
+
const queue = [from];
|
|
317
|
+
while (queue.length) {
|
|
318
|
+
for (const c of children.get(queue.shift()) ?? []) {
|
|
319
|
+
if (c.id === stopAt || seen.has(c.id))
|
|
320
|
+
continue;
|
|
321
|
+
seen.add(c.id);
|
|
322
|
+
queue.push(c.id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return seen;
|
|
326
|
+
};
|
|
327
|
+
const kept = keep ? subtree(keep, rootId) : new Set();
|
|
328
|
+
const out = [];
|
|
329
|
+
for (const id of subtree(rootId)) {
|
|
330
|
+
if (id === rootId || kept.has(id))
|
|
331
|
+
continue;
|
|
332
|
+
const row = rows.find((r) => r.id === id);
|
|
333
|
+
if (row.kind !== "local" && row.revoked_at === null)
|
|
334
|
+
out.push(id);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
/** The narratives each token last launched (spawn, run or revive), not
|
|
339
|
+
* archived, keyed by token id. */
|
|
340
|
+
launched() {
|
|
341
|
+
const rows = this.db
|
|
342
|
+
.prepare(`SELECT id, title, status, agent_type, launched_by FROM narratives
|
|
343
|
+
WHERE launched_by IS NOT NULL AND (archived = 0 OR archived IS NULL)
|
|
344
|
+
ORDER BY created_at`)
|
|
345
|
+
.all();
|
|
346
|
+
const out = new Map();
|
|
347
|
+
for (const { launched_by, ...agent } of rows) {
|
|
348
|
+
const list = out.get(launched_by) ?? [];
|
|
349
|
+
list.push(agent);
|
|
350
|
+
out.set(launched_by, list);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
/** One live row by id, or null. */
|
|
355
|
+
get(id) {
|
|
356
|
+
const row = this.db
|
|
357
|
+
.prepare(`SELECT ${LISTING_COLUMNS} FROM tokens t LEFT JOIN tokens m ON m.id = t.minted_by
|
|
358
|
+
WHERE t.id = ? AND t.revoked_at IS NULL`)
|
|
359
|
+
.get(id);
|
|
360
|
+
return row ? { ...row, unused: this.unused(row) } : null;
|
|
361
|
+
}
|
|
362
|
+
unused(row) {
|
|
363
|
+
return row.kind === "device" && row.origin === "minted" && row.last_used_at === null && !this.used.has(row.id);
|
|
364
|
+
}
|
|
365
|
+
lapsed(row, now) {
|
|
366
|
+
return this.unused(row) && now - row.created_at >= UNUSED_TOKEN_LAPSE_MS;
|
|
367
|
+
}
|
|
368
|
+
/** Revoke every minted device token that lapsed unused — the sweep behind
|
|
369
|
+
* `find`'s own check, so a QR nobody scanned does not linger as a live
|
|
370
|
+
* row. Returns how many it revoked. */
|
|
371
|
+
lapseUnused(now) {
|
|
372
|
+
const rows = this.db
|
|
373
|
+
.prepare(`SELECT id FROM tokens WHERE ${UNUSED_DEVICE} AND revoked_at IS NULL AND created_at <= ?`)
|
|
374
|
+
.all(now - UNUSED_TOKEN_LAPSE_MS);
|
|
375
|
+
const ids = rows.map((r) => r.id).filter((id) => !this.used.has(id));
|
|
376
|
+
this.revoke(ids, now);
|
|
377
|
+
return ids.length;
|
|
378
|
+
}
|
|
379
|
+
/** Record a token that existed as plaintext before this daemon hashed it —
|
|
380
|
+
* one that may have been written down somewhere (older daemons logged it). */
|
|
381
|
+
adopt(hash, kind, label, now) {
|
|
382
|
+
return this.write(hash, kind, label, now, "adopted");
|
|
383
|
+
}
|
|
384
|
+
write(hash, kind, label, now, origin) {
|
|
385
|
+
const id = randomUUID();
|
|
386
|
+
this.db
|
|
387
|
+
.prepare("INSERT INTO tokens (id, hash, kind, label, origin, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
388
|
+
.run(id, hash, kind, label, origin, now);
|
|
389
|
+
return { id, kind, label };
|
|
390
|
+
}
|
|
391
|
+
revoke(ids, now) {
|
|
392
|
+
const stmt = this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL");
|
|
393
|
+
this.transaction(() => {
|
|
394
|
+
for (const id of ids)
|
|
395
|
+
stmt.run(now, id);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
transaction(fn) {
|
|
399
|
+
return this.db.transaction(fn)();
|
|
400
|
+
}
|
|
401
|
+
live(kind) {
|
|
402
|
+
return this.db
|
|
403
|
+
.prepare(`SELECT ${ROW_COLUMNS} FROM tokens WHERE kind = ? AND revoked_at IS NULL ORDER BY created_at`)
|
|
404
|
+
.all(kind).map((row) => ({ id: row.id, kind: row.kind, label: row.label, ...(this.unused(row) ? { unused: true } : {}) }));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Advice while an adopted token is still live, or null. Daemons before
|
|
408
|
+
* hashing printed their token into `daemon.log` at every start, and a
|
|
409
|
+
* `--token` sits in argv and its config, so an adopted token should be
|
|
410
|
+
* retired: one rotation, from the device in hand or the shell, then re-meld
|
|
411
|
+
* the others. (Re-melding first would be undone: a rotation revokes every
|
|
412
|
+
* other device row.) Keyed on `origin`, not the label, so renaming the
|
|
413
|
+
* device does not silence it. */
|
|
414
|
+
export function legacyTokenWarning(db) {
|
|
415
|
+
const live = db
|
|
416
|
+
// Not agent rows: an adopted bind secret dies at its narrative's next
|
|
417
|
+
// revive, and was never something a person could have written down.
|
|
418
|
+
.prepare("SELECT 1 FROM tokens WHERE origin = 'adopted' AND kind != 'agent' AND revoked_at IS NULL")
|
|
419
|
+
.get();
|
|
420
|
+
if (!live)
|
|
421
|
+
return null;
|
|
422
|
+
return ("a token that existed as plaintext before it was hashed is still valid " +
|
|
423
|
+
"(older brane versions printed theirs in daemon.log, and a --token sits in argv and its config). " +
|
|
424
|
+
'Retire it with "Sign out other devices" in the app, or `brane token rotate`, then re-meld your devices.');
|
|
425
|
+
}
|
|
426
|
+
/** Read `.daemon_token`, the file older branes kept this machine's local
|
|
427
|
+
* token in, or null if it is absent or empty. Nothing presents it any more;
|
|
428
|
+
* it is read at daemon start (to retire what it holds, after which the
|
|
429
|
+
* daemon empties it) and by `brane doctor`. */
|
|
430
|
+
export function readTokenFile(path) {
|
|
431
|
+
try {
|
|
432
|
+
const token = readFileSync(path, "utf-8").trim();
|
|
433
|
+
return token || null;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** What the account row's `hash` column starts with. The rest is a UUID,
|
|
440
|
+
* only to satisfy the column's UNIQUE. */
|
|
441
|
+
export const ACCOUNT_HASH_PREFIX = "no-token:";
|
|
442
|
+
/** Whether a `hash` column holds a token's SHA-256, which some presented
|
|
443
|
+
* token could match — as opposed to the account row's placeholder, which
|
|
444
|
+
* none can: `hashToken` only ever yields 64 hex digits. */
|
|
445
|
+
export function isTokenHash(hash) {
|
|
446
|
+
return /^[0-9a-f]{64}$/.test(hash);
|
|
447
|
+
}
|
|
448
|
+
/** How a token still sitting in `.daemon_token` stands, for `brane doctor`:
|
|
449
|
+
* `live` when it authenticates (a pre-hashing token adopted as a device
|
|
450
|
+
* row, until a rotation retires it), `dead` when it does not. The daemon
|
|
451
|
+
* empties the file at start, so this only arises where it could not. */
|
|
452
|
+
export function tokenFileStanding(db, token) {
|
|
453
|
+
const row = db.prepare("SELECT 1 FROM tokens WHERE hash = ? AND revoked_at IS NULL").get(hashToken(token));
|
|
454
|
+
return row ? "live" : "dead";
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Bring the token state to "hashes only, and none for this machine" at
|
|
458
|
+
* daemon start. Idempotent: on an already-migrated install it finds the
|
|
459
|
+
* account row and changes nothing.
|
|
460
|
+
*
|
|
461
|
+
* **This machine holds no token.** Its CLI and MCP bridges reach the daemon
|
|
462
|
+
* over `daemon.sock` and present nothing (`authenticateRequest`); what they
|
|
463
|
+
* act as is the one live `local` row, the account row, whose `hash` is a
|
|
464
|
+
* placeholder no token can match (`ACCOUNT_HASH_PREFIX`). Older branes kept
|
|
465
|
+
* a local token in `.daemon_token`, which worked from any address the IP
|
|
466
|
+
* filter admits, so a copy of the file was this machine's shell from
|
|
467
|
+
* elsewhere. Every `local` row a token can open is revoked here, and the
|
|
468
|
+
* file is then emptied, so no token, live or dead, stays on disk. Its hash
|
|
469
|
+
* stays a revoked row, so an older brane started later cannot re-adopt it.
|
|
470
|
+
* The file itself is not removed; `brane doctor` reports the empty leftover
|
|
471
|
+
* for the operator to remove. What any earlier `local` row had minted,
|
|
472
|
+
* dialed in or launched moves to the account row, so a device `brane meld`
|
|
473
|
+
* added last week is still "added by this machine", and the agents `brane
|
|
474
|
+
* spawn` started are still listed under it.
|
|
475
|
+
*
|
|
476
|
+
* Before hashing, one plaintext token lived in both `meta.daemon_token` and
|
|
477
|
+
* `.daemon_token`, and every melded device held it. That token is adopted as
|
|
478
|
+
* a *device* row, so those devices keep working, and the `meta` row is
|
|
479
|
+
* dropped. Its plaintext leaves the file with everything else, so from then
|
|
480
|
+
* on it exists only on the devices that hold it, and dies with the first
|
|
481
|
+
* rotation like any other device token. A `--token` passed on the command line is adopted
|
|
482
|
+
* the same way, labelled as configured. A token whose hash is already known
|
|
483
|
+
* — live or revoked — is never adopted again, so a revoked token cannot come
|
|
484
|
+
* back through a stale file or a stale `--token`.
|
|
485
|
+
*/
|
|
486
|
+
/** The label of a device row `brane meld --remote` sent as a hash. */
|
|
487
|
+
export const REMOTE_MELD_LABEL = "melded remotely";
|
|
488
|
+
/** The device-token hashes in `BRANE_DEVICE_TOKEN_SHA256`: comma-separated
|
|
489
|
+
* lowercase hex SHA-256s. Anything else is dropped, so a malformed secret
|
|
490
|
+
* adopts nothing rather than an unusable row. */
|
|
491
|
+
export function parseTokenHashes(value) {
|
|
492
|
+
return [...new Set((value ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => /^[0-9a-f]{64}$/.test(s)))];
|
|
493
|
+
}
|
|
494
|
+
export function bootstrapTokens(db, opts) {
|
|
495
|
+
const store = new DbTokenStore(db);
|
|
496
|
+
const mint = opts.mint ?? (() => randomUUID());
|
|
497
|
+
const now = opts.now ?? Date.now();
|
|
498
|
+
const adopted = [];
|
|
499
|
+
const adopt = (plain, label) => {
|
|
500
|
+
if (!plain)
|
|
501
|
+
return;
|
|
502
|
+
const hash = hashToken(plain);
|
|
503
|
+
if (store.known(hash))
|
|
504
|
+
return;
|
|
505
|
+
store.adopt(hash, "device", label, now);
|
|
506
|
+
adopted.push(label);
|
|
507
|
+
};
|
|
508
|
+
const fileToken = readTokenFile(opts.tokenFile);
|
|
509
|
+
const metaRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_token'").get();
|
|
510
|
+
// A plain DELETE leaves the old value in the freed page and in WAL frames
|
|
511
|
+
// until something overwrites them. secure_delete zeroes the page, and the
|
|
512
|
+
// checkpoint below folds the WAL back in and truncates it.
|
|
513
|
+
const secureDelete = db.pragma("secure_delete", { simple: true });
|
|
514
|
+
if (metaRow)
|
|
515
|
+
db.pragma("secure_delete = ON");
|
|
516
|
+
db.transaction(() => {
|
|
517
|
+
// A file token that is a `local` row is known, so it is not adopted
|
|
518
|
+
// here; it is retired below.
|
|
519
|
+
adopt(fileToken, LEGACY_TOKEN_LABEL);
|
|
520
|
+
adopt(metaRow?.value, LEGACY_TOKEN_LABEL);
|
|
521
|
+
adopt(opts.configured, "configured with --token");
|
|
522
|
+
for (const hash of opts.configuredHashes ?? []) {
|
|
523
|
+
if (store.known(hash))
|
|
524
|
+
continue;
|
|
525
|
+
// Minted, not adopted: like any QR, it lapses if nobody scans it.
|
|
526
|
+
store.insert(hash, "device", REMOTE_MELD_LABEL, now);
|
|
527
|
+
adopted.push(REMOTE_MELD_LABEL);
|
|
528
|
+
}
|
|
529
|
+
// The plaintext this replaces. Its hash is now a row, so nothing is lost
|
|
530
|
+
// that any device needs.
|
|
531
|
+
db.prepare("DELETE FROM meta WHERE key = 'daemon_token'").run();
|
|
532
|
+
})();
|
|
533
|
+
if (metaRow) {
|
|
534
|
+
db.pragma(`secure_delete = ${secureDelete ? "ON" : "OFF"}`);
|
|
535
|
+
try {
|
|
536
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
537
|
+
}
|
|
538
|
+
catch { }
|
|
539
|
+
}
|
|
540
|
+
// One live `local` row, and no token opens it.
|
|
541
|
+
const retiredLocal = db.transaction(() => {
|
|
542
|
+
const locals = db
|
|
543
|
+
.prepare("SELECT id, hash FROM tokens WHERE kind = 'local' AND revoked_at IS NULL ORDER BY created_at DESC")
|
|
544
|
+
.all();
|
|
545
|
+
const withToken = locals.filter((r) => isTokenHash(r.hash)).map((r) => r.id);
|
|
546
|
+
const account = locals.find((r) => !isTokenHash(r.hash))?.id ??
|
|
547
|
+
store.insert(`${ACCOUNT_HASH_PREFIX}${mint()}`, "local", "this machine", now).id;
|
|
548
|
+
store.revoke(withToken, now);
|
|
549
|
+
// What any earlier `local` row minted, dialed in or launched is the
|
|
550
|
+
// account's: the rows just retired, and ones revoked before — by a shell
|
|
551
|
+
// rotation on an older brane, which never moved what it had launched, or
|
|
552
|
+
// by an older daemon started in between, which replaces the account row
|
|
553
|
+
// with a token-opened one of its own.
|
|
554
|
+
const earlier = db.prepare("SELECT id FROM tokens WHERE kind = 'local' AND id != ?").all(account);
|
|
555
|
+
const minted = db.prepare("UPDATE tokens SET minted_by = ? WHERE minted_by = ?");
|
|
556
|
+
const launched = db.prepare("UPDATE narratives SET launched_by = ? WHERE launched_by = ?");
|
|
557
|
+
for (const { id } of earlier) {
|
|
558
|
+
minted.run(account, id);
|
|
559
|
+
launched.run(account, id);
|
|
560
|
+
}
|
|
561
|
+
return withToken.length;
|
|
562
|
+
})();
|
|
563
|
+
// Whatever the file held is a row now, so nothing is lost with it: a
|
|
564
|
+
// revoked local token, or a pre-hashing token its devices still hold.
|
|
565
|
+
// Emptied only after the rows are written, and never fatal: a file that
|
|
566
|
+
// cannot be emptied is reported, by the caller and by `brane doctor`.
|
|
567
|
+
let tokenFile = "absent";
|
|
568
|
+
if (fileToken) {
|
|
569
|
+
try {
|
|
570
|
+
writeFileSync(opts.tokenFile, "", { mode: 0o600 });
|
|
571
|
+
chmodSync(opts.tokenFile, 0o600);
|
|
572
|
+
tokenFile = "emptied";
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
tokenFile = { error: err.message };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// Sessions launched before agent tokens hold a plaintext bind secret in
|
|
579
|
+
// their env; adopting each as an agent row keeps them working.
|
|
580
|
+
adoptBindTokens(db, now, opts.liveSessions ?? new Set());
|
|
581
|
+
return { adopted, retiredLocal, tokenFile };
|
|
582
|
+
}
|
|
583
|
+
//# sourceMappingURL=tokens.js.map
|