brane 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -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 +133 -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 +360 -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 +1101 -0
- package/dist/commands/token.js +154 -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 +6353 -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 +643 -0
- package/dist/db/tokens.js +624 -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/app-origin.js +26 -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 +84 -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 +216 -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/app-host-literal.test.js +53 -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 +56 -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 +299 -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 +143 -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 +150 -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 +128 -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/terminal-size.test.js +36 -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 +646 -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 +627 -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/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -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 +104 -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 +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -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 +79 -27
- 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 +53 -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/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// CertManager — keeps the daemon's TLS cert alive without operator action.
|
|
2
|
+
//
|
|
3
|
+
// The big picture: if Tailscale is running, the daemon should always be
|
|
4
|
+
// serving on HTTPS with a valid cert. The operator should never have to run
|
|
5
|
+
// `brane certify` to keep things working. This module owns that promise.
|
|
6
|
+
//
|
|
7
|
+
// Responsibilities:
|
|
8
|
+
// - On startup, ensure a valid cert exists at the canonical path,
|
|
9
|
+
// provisioning one if needed (first-run or expired).
|
|
10
|
+
// - Periodically re-check the cert and renew when it enters the renewal
|
|
11
|
+
// window (last ~30 days of a 90-day Let's Encrypt issuance).
|
|
12
|
+
// - Watch the cert file for external changes (e.g. operator ran
|
|
13
|
+
// `brane certify` by hand) and hot-reload without restart.
|
|
14
|
+
// - Surface state so the daemon can expose it on /api/status, broadcast
|
|
15
|
+
// it to the web UI, and print it in `brane daemon status`.
|
|
16
|
+
//
|
|
17
|
+
// What this module deliberately does NOT do:
|
|
18
|
+
// - Bind HTTPS servers (that stays in daemon/server.ts).
|
|
19
|
+
// - Broadcast over WebSockets directly (caller registers a state-change
|
|
20
|
+
// callback that does the broadcasting).
|
|
21
|
+
// - Persist state (transient, rederivable from the cert file on disk).
|
|
22
|
+
import { readFileSync, watch } from "node:fs";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
import { provisionTlsCert, inspectCert, } from "../tls.js";
|
|
25
|
+
import { getTailscaleHost } from "../tailscale.js";
|
|
26
|
+
/** Renew when fewer than this many days remain on the cert. */
|
|
27
|
+
const RENEW_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
|
+
/** Periodic check cadence. Renewal itself is gated by the threshold. */
|
|
29
|
+
const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
30
|
+
/** Backoff schedule after a renewal failure. Capped at the last entry. */
|
|
31
|
+
const BACKOFF_SCHEDULE_MS = [
|
|
32
|
+
1 * 60 * 60 * 1000, // 1h
|
|
33
|
+
2 * 60 * 60 * 1000, // 2h
|
|
34
|
+
4 * 60 * 60 * 1000, // 4h
|
|
35
|
+
8 * 60 * 60 * 1000, // 8h
|
|
36
|
+
24 * 60 * 60 * 1000, // 24h
|
|
37
|
+
];
|
|
38
|
+
/** fs.watch debounce: coalesce the multiple events a single rename emits. */
|
|
39
|
+
const WATCH_DEBOUNCE_MS = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Owns the daemon's TLS cert lifecycle. The daemon creates one instance,
|
|
42
|
+
* calls `ensureCert()` before deciding whether to bind HTTPS, registers
|
|
43
|
+
* `setSecureContext` via `onReload`, and calls `start()` once the servers
|
|
44
|
+
* are listening to begin the renewal/watch loop. `stop()` on shutdown.
|
|
45
|
+
*/
|
|
46
|
+
export class CertManager {
|
|
47
|
+
opts;
|
|
48
|
+
// ^ archiveDir was removed: brane has no use for prior cert pairs (no
|
|
49
|
+
// pinning clients, no offline trust state), and unbounded archival would
|
|
50
|
+
// grow ~4 entries/year forever.
|
|
51
|
+
status = {
|
|
52
|
+
state: "disabled",
|
|
53
|
+
expiresAt: null,
|
|
54
|
+
lastErrorReason: null,
|
|
55
|
+
lastErrorMessage: null,
|
|
56
|
+
nextAttemptAt: null,
|
|
57
|
+
host: null,
|
|
58
|
+
};
|
|
59
|
+
pollTimer = null;
|
|
60
|
+
watcher = null;
|
|
61
|
+
watchDebounce = null;
|
|
62
|
+
renewInflight = null;
|
|
63
|
+
consecutiveFailures = 0;
|
|
64
|
+
stopped = false;
|
|
65
|
+
constructor(opts) {
|
|
66
|
+
this.opts = {
|
|
67
|
+
certPath: opts.certPath,
|
|
68
|
+
keyPath: opts.keyPath,
|
|
69
|
+
onReload: opts.onReload,
|
|
70
|
+
onStateChange: opts.onStateChange,
|
|
71
|
+
log: opts.log ?? (() => { }),
|
|
72
|
+
resolveHost: opts.resolveHost ?? getTailscaleHost,
|
|
73
|
+
pollIntervalMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Synchronously look at what's on disk and (if needed/possible) provision
|
|
78
|
+
* a cert before the daemon binds. Returns true iff the daemon has a valid
|
|
79
|
+
* cert it can use to bind HTTPS. Safe to call from daemon startup — does
|
|
80
|
+
* not start the renewal loop (call `start()` after binding for that).
|
|
81
|
+
*/
|
|
82
|
+
async ensureCert() {
|
|
83
|
+
const host = this.opts.resolveHost();
|
|
84
|
+
this.status.host = host ?? null;
|
|
85
|
+
const existing = inspectCert(this.opts.certPath);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
// Mirror tick()'s check: a cert whose CN doesn't match the current
|
|
88
|
+
// Tailscale host is unusable for HTTPS even if the dates are fine
|
|
89
|
+
// (tailnet rename, node rename). Without this, ensureCert would say
|
|
90
|
+
// "valid", startServer would bind HTTPS, and every client would see a
|
|
91
|
+
// TLS error until the hourly tick noticed and re-provisioned.
|
|
92
|
+
const subjectMismatch = existing && host && !subjectIs(existing.subject, host);
|
|
93
|
+
// Case A: valid cert exists for the right host. Use it; renewal loop
|
|
94
|
+
// will handle expiry later.
|
|
95
|
+
if (existing && existing.notAfter.getTime() > now && !subjectMismatch) {
|
|
96
|
+
this.status.expiresAt = existing.notAfter;
|
|
97
|
+
this.status.state = "valid";
|
|
98
|
+
this.emitStateChange();
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// Case B: cert is missing/expired/wrong-subject and no Tailscale.
|
|
102
|
+
// Nothing we can do — daemon will serve HTTP only.
|
|
103
|
+
if (!host) {
|
|
104
|
+
this.status.state = "disabled";
|
|
105
|
+
this.status.expiresAt = existing?.notAfter ?? null;
|
|
106
|
+
this.emitStateChange();
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
// Case C: cert is missing/expired/wrong-subject and Tailscale is
|
|
110
|
+
// available. Try to provision a fresh one.
|
|
111
|
+
this.opts.log("info", subjectMismatch && existing
|
|
112
|
+
? `TLS cert subject ${existing.subject} does not match ${host} — provisioning fresh cert`
|
|
113
|
+
: existing
|
|
114
|
+
? `TLS cert expired ${existing.notAfter.toISOString()} — provisioning fresh cert for ${host}`
|
|
115
|
+
: `No TLS cert found — provisioning for ${host}`);
|
|
116
|
+
const ok = await this.tryProvision(host);
|
|
117
|
+
return ok;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Begin the renewal poll + fs.watch loop. Idempotent.
|
|
121
|
+
*
|
|
122
|
+
* The expected ordering at daemon startup is:
|
|
123
|
+
* 1. await mgr.ensureCert() (provision if missing)
|
|
124
|
+
* 2. bind HTTPS server with the cert
|
|
125
|
+
* 3. mgr.start() (renewal + watch)
|
|
126
|
+
* 4. mgr.stop() during shutdown
|
|
127
|
+
*/
|
|
128
|
+
start() {
|
|
129
|
+
if (this.pollTimer || this.stopped)
|
|
130
|
+
return;
|
|
131
|
+
this.pollTimer = setInterval(() => {
|
|
132
|
+
void this.tick();
|
|
133
|
+
}, this.opts.pollIntervalMs);
|
|
134
|
+
// Don't keep the event loop alive on the renewal timer alone.
|
|
135
|
+
this.pollTimer.unref?.();
|
|
136
|
+
this.attachWatcher();
|
|
137
|
+
}
|
|
138
|
+
/** Shut down the renewal loop and fs.watch. Idempotent. */
|
|
139
|
+
stop() {
|
|
140
|
+
this.stopped = true;
|
|
141
|
+
if (this.pollTimer) {
|
|
142
|
+
clearInterval(this.pollTimer);
|
|
143
|
+
this.pollTimer = null;
|
|
144
|
+
}
|
|
145
|
+
if (this.watchDebounce) {
|
|
146
|
+
clearTimeout(this.watchDebounce);
|
|
147
|
+
this.watchDebounce = null;
|
|
148
|
+
}
|
|
149
|
+
if (this.watcher) {
|
|
150
|
+
try {
|
|
151
|
+
this.watcher.close();
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
this.watcher = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Current public status. Caller may serialize this to JSON. */
|
|
158
|
+
getStatus() {
|
|
159
|
+
return { ...this.status };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Force a renewal check now (skips backoff). Returns true if a renewal
|
|
163
|
+
* was attempted and succeeded. Useful for testing and for an eventual
|
|
164
|
+
* `brane certify` that just nudges the daemon instead of running the
|
|
165
|
+
* provisioning itself.
|
|
166
|
+
*/
|
|
167
|
+
async renewNow() {
|
|
168
|
+
const host = this.opts.resolveHost();
|
|
169
|
+
if (!host)
|
|
170
|
+
return false;
|
|
171
|
+
return this.tryProvision(host);
|
|
172
|
+
}
|
|
173
|
+
// ----- internals -----
|
|
174
|
+
async tick() {
|
|
175
|
+
if (this.stopped)
|
|
176
|
+
return;
|
|
177
|
+
if (this.renewInflight)
|
|
178
|
+
return;
|
|
179
|
+
const host = this.opts.resolveHost();
|
|
180
|
+
if (!host) {
|
|
181
|
+
// Tailscale went away mid-life. Don't fail loudly — the daemon may
|
|
182
|
+
// still be useful over HTTP. Update host but keep current state.
|
|
183
|
+
if (this.status.host !== null) {
|
|
184
|
+
this.status.host = null;
|
|
185
|
+
this.emitStateChange();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (this.status.host !== host) {
|
|
190
|
+
// Host changed (tailnet rename, etc.) — force a renewal.
|
|
191
|
+
this.status.host = host;
|
|
192
|
+
this.opts.log("info", `Tailscale host changed to ${host} — renewing cert`);
|
|
193
|
+
await this.tryProvision(host);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Respect backoff: if a previous attempt failed and the cooldown
|
|
197
|
+
// hasn't passed, do nothing this tick.
|
|
198
|
+
if (this.status.nextAttemptAt && this.status.nextAttemptAt.getTime() > Date.now()) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const current = inspectCert(this.opts.certPath);
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const expiresAt = current?.notAfter ?? null;
|
|
204
|
+
const expiresSoon = !current || current.notAfter.getTime() - now < RENEW_THRESHOLD_MS;
|
|
205
|
+
const subjectMismatch = current && this.status.host && !subjectIs(current.subject, this.status.host);
|
|
206
|
+
if (!expiresSoon && !subjectMismatch) {
|
|
207
|
+
// Steady state — nothing to do. Make sure status reflects reality.
|
|
208
|
+
if (this.status.state !== "valid" || this.status.expiresAt?.getTime() !== expiresAt?.getTime()) {
|
|
209
|
+
this.status.state = "valid";
|
|
210
|
+
this.status.expiresAt = expiresAt;
|
|
211
|
+
this.status.lastErrorReason = null;
|
|
212
|
+
this.status.lastErrorMessage = null;
|
|
213
|
+
this.status.nextAttemptAt = null;
|
|
214
|
+
this.emitStateChange();
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await this.tryProvision(host);
|
|
219
|
+
}
|
|
220
|
+
async tryProvision(host) {
|
|
221
|
+
if (this.renewInflight) {
|
|
222
|
+
await this.renewInflight;
|
|
223
|
+
return this.status.state === "valid";
|
|
224
|
+
}
|
|
225
|
+
this.status.state = "renewing";
|
|
226
|
+
this.emitStateChange();
|
|
227
|
+
let resolveInflight;
|
|
228
|
+
this.renewInflight = new Promise((r) => { resolveInflight = r; });
|
|
229
|
+
try {
|
|
230
|
+
this.opts.log("info", `Provisioning TLS certificate for ${host}...`);
|
|
231
|
+
const result = await provisionTlsCert({
|
|
232
|
+
host,
|
|
233
|
+
certPath: this.opts.certPath,
|
|
234
|
+
keyPath: this.opts.keyPath,
|
|
235
|
+
});
|
|
236
|
+
if (result.ok) {
|
|
237
|
+
this.consecutiveFailures = 0;
|
|
238
|
+
this.status.state = "valid";
|
|
239
|
+
this.status.expiresAt = result.notAfter;
|
|
240
|
+
this.status.lastErrorReason = null;
|
|
241
|
+
this.status.lastErrorMessage = null;
|
|
242
|
+
this.status.nextAttemptAt = null;
|
|
243
|
+
this.opts.log("info", ` Certificate written to ${result.certPath}`);
|
|
244
|
+
this.opts.log("info", ` Key written to ${result.keyPath}`);
|
|
245
|
+
this.opts.log("info", ` Expires: ${result.notAfter.toISOString()}`);
|
|
246
|
+
this.reloadFromDisk();
|
|
247
|
+
this.emitStateChange();
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
// Failure path: record state, schedule next attempt.
|
|
251
|
+
this.consecutiveFailures++;
|
|
252
|
+
const backoffMs = BACKOFF_SCHEDULE_MS[Math.min(this.consecutiveFailures - 1, BACKOFF_SCHEDULE_MS.length - 1)];
|
|
253
|
+
this.status.state = "failed";
|
|
254
|
+
this.status.lastErrorReason = result.reason;
|
|
255
|
+
this.status.lastErrorMessage = result.message;
|
|
256
|
+
this.status.nextAttemptAt = new Date(Date.now() + backoffMs);
|
|
257
|
+
// Don't clobber expiresAt — the old cert may still be live & valid.
|
|
258
|
+
const existing = inspectCert(this.opts.certPath);
|
|
259
|
+
this.status.expiresAt = existing?.notAfter ?? null;
|
|
260
|
+
this.opts.log("warn", `TLS provisioning failed (${result.reason}): ${result.message}. Next attempt at ${this.status.nextAttemptAt.toISOString()}`);
|
|
261
|
+
if (result.stderr) {
|
|
262
|
+
for (const line of result.stderr.split(/\r?\n/).filter(Boolean)) {
|
|
263
|
+
this.opts.log("warn", ` ${line}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (result.reason === "https_not_enabled") {
|
|
267
|
+
this.opts.log("warn", "Enable HTTPS for your tailnet at https://login.tailscale.com/admin/dns");
|
|
268
|
+
}
|
|
269
|
+
this.emitStateChange();
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
resolveInflight();
|
|
274
|
+
this.renewInflight = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
attachWatcher() {
|
|
278
|
+
if (this.watcher)
|
|
279
|
+
return;
|
|
280
|
+
// Watch the directory rather than the file — atomic rename removes the
|
|
281
|
+
// original inode, which detaches a file-level watcher on most platforms.
|
|
282
|
+
try {
|
|
283
|
+
this.watcher = watch(dirname(this.opts.certPath), (_event, filename) => {
|
|
284
|
+
if (filename !== "tls.crt" && filename !== "tls.key")
|
|
285
|
+
return;
|
|
286
|
+
if (this.watchDebounce)
|
|
287
|
+
clearTimeout(this.watchDebounce);
|
|
288
|
+
const t = setTimeout(() => {
|
|
289
|
+
this.watchDebounce = null;
|
|
290
|
+
this.handleExternalChange();
|
|
291
|
+
}, WATCH_DEBOUNCE_MS);
|
|
292
|
+
// Don't keep the event loop alive on a pending debounce.
|
|
293
|
+
// Without this, node:test's worker process on Linux never exits
|
|
294
|
+
// after the tests pass: the fs.watch callback fires once during
|
|
295
|
+
// mgr.stop()'s teardown, creates a new setTimeout, and the
|
|
296
|
+
// process hangs waiting for it.
|
|
297
|
+
t.unref?.();
|
|
298
|
+
this.watchDebounce = t;
|
|
299
|
+
});
|
|
300
|
+
// Same reason — the inotify handle on Linux keeps the event loop
|
|
301
|
+
// alive even after we've called .close() (close drains async).
|
|
302
|
+
// Unref so the test process can exit cleanly.
|
|
303
|
+
this.watcher.unref?.();
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.opts.log("warn", `Failed to attach cert watcher: ${err.message}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
handleExternalChange() {
|
|
310
|
+
if (this.stopped)
|
|
311
|
+
return;
|
|
312
|
+
const inspected = inspectCert(this.opts.certPath);
|
|
313
|
+
if (!inspected)
|
|
314
|
+
return;
|
|
315
|
+
const sameExpiry = this.status.expiresAt &&
|
|
316
|
+
inspected.notAfter.getTime() === this.status.expiresAt.getTime();
|
|
317
|
+
if (sameExpiry)
|
|
318
|
+
return; // no actual change
|
|
319
|
+
this.status.state = "valid";
|
|
320
|
+
this.status.expiresAt = inspected.notAfter;
|
|
321
|
+
this.status.lastErrorReason = null;
|
|
322
|
+
this.status.lastErrorMessage = null;
|
|
323
|
+
this.status.nextAttemptAt = null;
|
|
324
|
+
this.consecutiveFailures = 0;
|
|
325
|
+
this.opts.log("info", `TLS cert reloaded from disk (expires ${inspected.notAfter.toISOString()})`);
|
|
326
|
+
this.reloadFromDisk();
|
|
327
|
+
this.emitStateChange();
|
|
328
|
+
}
|
|
329
|
+
reloadFromDisk() {
|
|
330
|
+
try {
|
|
331
|
+
const cert = readFileSync(this.opts.certPath);
|
|
332
|
+
const key = readFileSync(this.opts.keyPath);
|
|
333
|
+
this.opts.onReload(cert, key);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.opts.log("error", `Failed to hot-reload cert: ${err.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
emitStateChange() {
|
|
340
|
+
this.opts.onStateChange?.(this.getStatus());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function subjectIs(subject, host) {
|
|
344
|
+
const cnLine = subject.split(/\r?\n/).find((l) => l.startsWith("CN="));
|
|
345
|
+
if (!cnLine)
|
|
346
|
+
return false;
|
|
347
|
+
return cnLine.slice(3).trim().toLowerCase() === host.toLowerCase();
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=cert-manager.js.map
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { openDatabase, getPackageVersion } from "../db/schema.js";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { getTailscaleHost } from "../tailscale.js";
|
|
5
|
+
import { ensureDaemonRunning } from "../commands/daemon.js";
|
|
6
|
+
/** Pick the origin a *local* client should use to reach the daemon.
|
|
7
|
+
*
|
|
8
|
+
* Without TLS this is just loopback on the primary port. With TLS it can't
|
|
9
|
+
* be: the cert is issued for the Tailscale hostname and is never valid for
|
|
10
|
+
* `127.0.0.1`, so loopback has to go through the daemon's plain-HTTP
|
|
11
|
+
* fallback listener (private IPs only, hence safe). The Tailscale hostname
|
|
12
|
+
* is the last resort for the narrow window where TLS is up but the fallback
|
|
13
|
+
* port hasn't been recorded yet. */
|
|
14
|
+
export function buildDaemonBaseUrl(addr) {
|
|
15
|
+
if (!addr.tls)
|
|
16
|
+
return `http://127.0.0.1:${addr.port}`;
|
|
17
|
+
if (addr.httpPort)
|
|
18
|
+
return `http://127.0.0.1:${addr.httpPort}`;
|
|
19
|
+
if (addr.tailscaleHost)
|
|
20
|
+
return `https://${addr.tailscaleHost}:${addr.port}`;
|
|
21
|
+
throw new Error("Daemon is running with TLS but has no HTTP fallback port recorded, and " +
|
|
22
|
+
"no Tailscale hostname was found to validate its certificate against.");
|
|
23
|
+
}
|
|
24
|
+
/** Read the daemon's address out of the meta table. `opts.tailscaleHost`
|
|
25
|
+
* injects the hostname instead of shelling out to `tailscale status` — used
|
|
26
|
+
* by tests to keep this deterministic; when the key is present (even as
|
|
27
|
+
* `undefined`) the live lookup is skipped. */
|
|
28
|
+
export function readDaemonAddress(db, port, opts = {}) {
|
|
29
|
+
const metaValue = (key) => db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value;
|
|
30
|
+
const tls = metaValue("daemon_tls") === "true";
|
|
31
|
+
const tailscaleHost = "tailscaleHost" in opts ? opts.tailscaleHost : getTailscaleHost();
|
|
32
|
+
return {
|
|
33
|
+
port,
|
|
34
|
+
tls,
|
|
35
|
+
httpPort: tls ? metaValue("daemon_http_port") : undefined,
|
|
36
|
+
tailscaleHost: tls ? tailscaleHost : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** What this process presents to the daemon. Inside an agent's session the
|
|
40
|
+
* CLI and MCP bridge act as that agent, with the token its launch minted
|
|
41
|
+
* for it, so what they do is attributed to, and revocable with, that
|
|
42
|
+
* narrative. Anywhere else it is nothing (null): this machine holds no
|
|
43
|
+
* token, and a request with none acts as this Unix account when it arrives
|
|
44
|
+
* over the daemon's socket, the one place only this account can reach. No
|
|
45
|
+
* file is read: `.daemon_token`, where older branes kept a local token, is
|
|
46
|
+
* retired (`bootstrapTokens`, src/db/tokens.ts). */
|
|
47
|
+
export function resolveCredential(env = process.env) {
|
|
48
|
+
return env.BRANE_NARRATIVE_SECRET || null;
|
|
49
|
+
}
|
|
50
|
+
/** Resolve a reachable daemon, starting one if none is running.
|
|
51
|
+
*
|
|
52
|
+
* `ensureDaemonRunning` returns as soon as the primary port is recorded, but
|
|
53
|
+
* the HTTP fallback port lands a beat later — so on a TLS daemon we give that
|
|
54
|
+
* row a moment to appear before falling back to the Tailscale hostname. */
|
|
55
|
+
export async function resolveDaemonEndpoint() {
|
|
56
|
+
const address = await ensureDaemonRunning();
|
|
57
|
+
for (let attempt = 0;; attempt++) {
|
|
58
|
+
const db = openDatabase();
|
|
59
|
+
let addr;
|
|
60
|
+
let token;
|
|
61
|
+
let socketPath;
|
|
62
|
+
try {
|
|
63
|
+
addr = readDaemonAddress(db, address.port);
|
|
64
|
+
token = resolveCredential();
|
|
65
|
+
socketPath = readDaemonSocket(db);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
if (addr.tls && !addr.httpPort && attempt < 10) {
|
|
71
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
baseUrl: buildDaemonBaseUrl(addr),
|
|
76
|
+
token,
|
|
77
|
+
port: address.port,
|
|
78
|
+
version: address.version,
|
|
79
|
+
...(socketPath ? { socketPath } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The socket a running daemon advertised, if its file is there. */
|
|
84
|
+
export function readDaemonSocket(db) {
|
|
85
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'daemon_socket'").get();
|
|
86
|
+
return row && existsSync(row.value) ? row.value : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Send one request to the daemon: over its Unix socket when it has one,
|
|
89
|
+
* else (or when the socket cannot be connected to, so nothing was sent)
|
|
90
|
+
* over TCP to `baseUrl`. */
|
|
91
|
+
export async function sendToDaemon(endpoint, path, init) {
|
|
92
|
+
if (endpoint.socketPath) {
|
|
93
|
+
const viaSocket = await new Promise((resolve, reject) => {
|
|
94
|
+
const req = httpRequest({ socketPath: endpoint.socketPath, path, method: init.method, headers: init.headers }, (res) => {
|
|
95
|
+
const status = res.statusCode ?? 0;
|
|
96
|
+
let consumed = false;
|
|
97
|
+
resolve({
|
|
98
|
+
ok: status >= 200 && status < 300,
|
|
99
|
+
status,
|
|
100
|
+
body: res,
|
|
101
|
+
async text() {
|
|
102
|
+
if (consumed)
|
|
103
|
+
return "";
|
|
104
|
+
consumed = true;
|
|
105
|
+
let out = "";
|
|
106
|
+
res.setEncoding("utf8");
|
|
107
|
+
for await (const chunk of res)
|
|
108
|
+
out += chunk;
|
|
109
|
+
return out;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
// Until the socket connects, nothing of this request has been sent, so
|
|
114
|
+
// any failure (gone, refused, no permission, not a socket) is safe to
|
|
115
|
+
// retry over TCP. After it, the daemon may have acted: report it.
|
|
116
|
+
let connected = false;
|
|
117
|
+
req.on("socket", (sock) => {
|
|
118
|
+
if (!sock.connecting)
|
|
119
|
+
connected = true;
|
|
120
|
+
else
|
|
121
|
+
sock.once("connect", () => { connected = true; });
|
|
122
|
+
});
|
|
123
|
+
req.on("error", (err) => {
|
|
124
|
+
if (!connected)
|
|
125
|
+
resolve(null);
|
|
126
|
+
else
|
|
127
|
+
reject(err);
|
|
128
|
+
});
|
|
129
|
+
if (init.body !== undefined)
|
|
130
|
+
req.write(init.body);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
if (viaSocket)
|
|
134
|
+
return viaSocket;
|
|
135
|
+
}
|
|
136
|
+
// Without a token, TCP would only answer 401: say what actually failed.
|
|
137
|
+
if (endpoint.token === null) {
|
|
138
|
+
throw new Error(endpoint.socketPath
|
|
139
|
+
? `could not connect to the daemon's socket (${endpoint.socketPath}); \`brane daemon restart\` puts it back`
|
|
140
|
+
: "the daemon is running without its socket (daemon.sock), which is the only way this shell reaches it; " +
|
|
141
|
+
"`brane daemon logs` says why it could not listen there, and `brane doctor` checks it");
|
|
142
|
+
}
|
|
143
|
+
const res = await fetch(`${endpoint.baseUrl}${path}`, init);
|
|
144
|
+
return {
|
|
145
|
+
ok: res.ok,
|
|
146
|
+
status: res.status,
|
|
147
|
+
text: () => res.text(),
|
|
148
|
+
body: res.body,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** What a 401 means for a request that presented no token over the socket. */
|
|
152
|
+
const TOKENLESS_REFUSED = "the daemon would not let a request without a token act as this account; " +
|
|
153
|
+
"it does so only while ~/.brane is owned by you and closed to group and other (chmod 700 ~/.brane), " +
|
|
154
|
+
"and `brane daemon logs` says why it refused";
|
|
155
|
+
function authorization(endpoint) {
|
|
156
|
+
return endpoint.token === null ? {} : { Authorization: `Bearer ${endpoint.token}` };
|
|
157
|
+
}
|
|
158
|
+
/** Warn when the daemon we're delegating to was built from different sources
|
|
159
|
+
* than this CLI. It matters more for delegated work than for read-only
|
|
160
|
+
* status: a request field this CLI knows about (say `extra_args`) is simply
|
|
161
|
+
* ignored by an older daemon, with no error to notice. */
|
|
162
|
+
export function warnIfVersionMismatch(endpoint) {
|
|
163
|
+
const myVersion = getPackageVersion();
|
|
164
|
+
if (endpoint.version && endpoint.version !== myVersion) {
|
|
165
|
+
process.stderr.write(`Warning: daemon is v${endpoint.version}, this CLI is v${myVersion}. ` +
|
|
166
|
+
`Run \`brane daemon restart\` to pick up this build.\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Call a daemon JSON API endpoint. Throws with the daemon's own `error`
|
|
170
|
+
* string when it reports one, so CLI output matches what the web app shows. */
|
|
171
|
+
export async function daemonRequest(endpoint, path, init = {}) {
|
|
172
|
+
const res = await sendToDaemon(endpoint, path, {
|
|
173
|
+
method: init.method ?? "GET",
|
|
174
|
+
headers: {
|
|
175
|
+
...authorization(endpoint),
|
|
176
|
+
...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
177
|
+
},
|
|
178
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
179
|
+
});
|
|
180
|
+
const text = await res.text();
|
|
181
|
+
let data;
|
|
182
|
+
try {
|
|
183
|
+
data = text ? JSON.parse(text) : {};
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
data = {};
|
|
187
|
+
}
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const message = data && typeof data === "object" && typeof data.error === "string"
|
|
190
|
+
? data.error
|
|
191
|
+
: `${init.method ?? "GET"} ${path} failed (${res.status})`;
|
|
192
|
+
// Inside an agent's session the CLI presented that agent's token; a 401
|
|
193
|
+
// then almost always means it was revoked or superseded by a revive.
|
|
194
|
+
if (res.status === 401 && process.env.BRANE_NARRATIVE_SECRET && endpoint.token === process.env.BRANE_NARRATIVE_SECRET) {
|
|
195
|
+
throw new Error(`${message}: this session's agent token (BRANE_NARRATIVE_SECRET) was refused, ` +
|
|
196
|
+
"probably revoked; the operator can revive the session");
|
|
197
|
+
}
|
|
198
|
+
if (res.status === 401 && endpoint.token === null)
|
|
199
|
+
throw new Error(`${message}: ${TOKENLESS_REFUSED}`);
|
|
200
|
+
throw new Error(message);
|
|
201
|
+
}
|
|
202
|
+
return data;
|
|
203
|
+
}
|
|
204
|
+
/** Call a daemon endpoint that answers with newline-delimited JSON, handing
|
|
205
|
+
* each line to `onLine` as it arrives — for work that reports progress
|
|
206
|
+
* (`POST /api/absorb`). A non-2xx status is an ordinary JSON error and is
|
|
207
|
+
* thrown the way `daemonRequest` throws it; the stream itself is only ever
|
|
208
|
+
* opened once the daemon has accepted the request. Resolves when the daemon
|
|
209
|
+
* ends the response. A partial trailing line (the connection dropped
|
|
210
|
+
* mid-record) is discarded rather than parsed. */
|
|
211
|
+
export async function daemonStream(endpoint, path, init, onLine) {
|
|
212
|
+
const res = await sendToDaemon(endpoint, path, {
|
|
213
|
+
method: init.method ?? "POST",
|
|
214
|
+
headers: {
|
|
215
|
+
...authorization(endpoint),
|
|
216
|
+
Accept: "application/x-ndjson",
|
|
217
|
+
...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
218
|
+
},
|
|
219
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
220
|
+
});
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const text = await res.text();
|
|
223
|
+
let message = `${init.method ?? "POST"} ${path} failed (${res.status})`;
|
|
224
|
+
try {
|
|
225
|
+
const data = JSON.parse(text);
|
|
226
|
+
if (typeof data.error === "string")
|
|
227
|
+
message = data.error;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// not JSON; keep the status line
|
|
231
|
+
}
|
|
232
|
+
if (res.status === 401 && endpoint.token === null)
|
|
233
|
+
message = `${message}: ${TOKENLESS_REFUSED}`;
|
|
234
|
+
throw new Error(message);
|
|
235
|
+
}
|
|
236
|
+
if (!res.body)
|
|
237
|
+
return;
|
|
238
|
+
const decoder = new TextDecoder();
|
|
239
|
+
let buffered = "";
|
|
240
|
+
for await (const chunk of res.body) {
|
|
241
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
242
|
+
let nl;
|
|
243
|
+
while ((nl = buffered.indexOf("\n")) !== -1) {
|
|
244
|
+
const line = buffered.slice(0, nl).trim();
|
|
245
|
+
buffered = buffered.slice(nl + 1);
|
|
246
|
+
if (line)
|
|
247
|
+
onLine(JSON.parse(line));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// The daemon terminates every record with a newline, so a non-empty
|
|
251
|
+
// remainder is a record the connection cut short.
|
|
252
|
+
const tail = (buffered + decoder.decode()).trim();
|
|
253
|
+
if (tail) {
|
|
254
|
+
try {
|
|
255
|
+
onLine(JSON.parse(tail));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// partial trailing record; discard
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=client.js.map
|