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,27 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CONFIG_DEFAULTS } from "../commands/config.js";
|
|
7
|
+
// The shared app's host is written in two places: the route the worker is
|
|
8
|
+
// deployed to (worker/wrangler.toml) and the default `app_domain` that
|
|
9
|
+
// `brane meld` points a QR at and a daemon's bundle hops out to. A fork that
|
|
10
|
+
// changes one and not the other melds phones to a host that serves nothing,
|
|
11
|
+
// so they are pinned together here.
|
|
12
|
+
//
|
|
13
|
+
// This reads *source*, which the npm tarball does not carry, so it skips when
|
|
14
|
+
// the source is absent rather than failing `brane doctor` on every install.
|
|
15
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
const WRANGLER = join(root, "worker", "wrangler.toml");
|
|
17
|
+
describe("the worker's route", () => {
|
|
18
|
+
it("is CONFIG_DEFAULTS.app_domain, as a custom domain, with no workers.dev alias", (t) => {
|
|
19
|
+
if (!existsSync(WRANGLER))
|
|
20
|
+
return t.skip("source is not present (an installed package)");
|
|
21
|
+
const toml = readFileSync(WRANGLER, "utf8").replace(/#.*$/gm, "");
|
|
22
|
+
const routes = [...toml.matchAll(/\{\s*pattern\s*=\s*"([^"]+)"\s*,\s*custom_domain\s*=\s*true\s*\}/g)].map((m) => m[1]);
|
|
23
|
+
assert.deepEqual(routes, [CONFIG_DEFAULTS.app_domain]);
|
|
24
|
+
assert.match(toml, /^workers_dev\s*=\s*false\s*$/m);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
//# sourceMappingURL=wrangler-route.test.js.map
|
package/dist/tls.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// TLS certificate management for the brane daemon.
|
|
2
|
+
//
|
|
3
|
+
// The daemon serves over HTTPS using Tailscale-issued Let's Encrypt certs.
|
|
4
|
+
// `provisionTlsCert` shells out to `tailscale cert` (the same command the
|
|
5
|
+
// `brane certify` CLI uses) and lands a fresh cert/key pair on disk.
|
|
6
|
+
// `parseCertExpiry` reads a cert file and returns its `notAfter` so the
|
|
7
|
+
// CertManager can decide when to renew.
|
|
8
|
+
//
|
|
9
|
+
// Both functions are pure plumbing — no schedule, no state, no logging.
|
|
10
|
+
// The renewal/scheduling/hot-reload policy lives in src/daemon/cert-manager.ts.
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { X509Certificate } from "node:crypto";
|
|
13
|
+
import { readFileSync, renameSync } from "node:fs";
|
|
14
|
+
/**
|
|
15
|
+
* Provision a TLS cert+key pair for `host` via `tailscale cert`.
|
|
16
|
+
*
|
|
17
|
+
* Writes to temporary `.new` files, validates the result (parses, future
|
|
18
|
+
* `notAfter`, subject CN matches `host`), then atomically renames them into
|
|
19
|
+
* place. Partial failures leave the existing cert intact.
|
|
20
|
+
*/
|
|
21
|
+
export function provisionTlsCert(opts) {
|
|
22
|
+
const { host, certPath, keyPath, timeoutMs = 30000 } = opts;
|
|
23
|
+
const tmpCert = certPath + ".new";
|
|
24
|
+
const tmpKey = keyPath + ".new";
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
execFile("tailscale", ["cert", "--cert-file", tmpCert, "--key-file", tmpKey, host], { timeout: timeoutMs }, (err, _stdout, stderr) => {
|
|
27
|
+
if (err) {
|
|
28
|
+
resolve({
|
|
29
|
+
ok: false,
|
|
30
|
+
reason: classifyTailscaleError(err, stderr),
|
|
31
|
+
message: err.message,
|
|
32
|
+
stderr,
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// tailscale cert exited 0 — validate before swapping in.
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new X509Certificate(readFileSync(tmpCert));
|
|
40
|
+
}
|
|
41
|
+
catch (parseErr) {
|
|
42
|
+
resolve({
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: "invalid_output",
|
|
45
|
+
message: `Provisioned cert at ${tmpCert} did not parse: ${parseErr.message}`,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const notAfter = parseValidTo(parsed.validTo);
|
|
50
|
+
if (!notAfter || notAfter.getTime() <= Date.now()) {
|
|
51
|
+
resolve({
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: "invalid_output",
|
|
54
|
+
message: `Provisioned cert has invalid or past notAfter: ${parsed.validTo}`,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Subject should match the requested host. tailscale puts the host
|
|
59
|
+
// in the CN; we accept any cert whose subject CN equals host, which
|
|
60
|
+
// is what tailscale always produces.
|
|
61
|
+
if (!subjectMatchesHost(parsed, host)) {
|
|
62
|
+
resolve({
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "invalid_output",
|
|
65
|
+
message: `Provisioned cert subject ${parsed.subject} does not match host ${host}`,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
renameSync(tmpCert, certPath);
|
|
71
|
+
renameSync(tmpKey, keyPath);
|
|
72
|
+
}
|
|
73
|
+
catch (renameErr) {
|
|
74
|
+
resolve({
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: "unknown",
|
|
77
|
+
message: `Failed to install provisioned cert: ${renameErr.message}`,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
resolve({ ok: true, certPath, keyPath, notAfter });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Read a cert file and return its X.509 `notAfter` date. */
|
|
86
|
+
export function parseCertExpiry(certPath) {
|
|
87
|
+
try {
|
|
88
|
+
const cert = new X509Certificate(readFileSync(certPath));
|
|
89
|
+
return parseValidTo(cert.validTo);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Read a cert file and return both expiry and subject in one shot. */
|
|
96
|
+
export function inspectCert(certPath) {
|
|
97
|
+
try {
|
|
98
|
+
const cert = new X509Certificate(readFileSync(certPath));
|
|
99
|
+
const notAfter = parseValidTo(cert.validTo);
|
|
100
|
+
if (!notAfter)
|
|
101
|
+
return null;
|
|
102
|
+
return { notAfter, subject: cert.subject };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the X509Certificate#validTo string into a Date.
|
|
110
|
+
*
|
|
111
|
+
* Node added `X509Certificate#validToDate` in v23.0.0; on Node 20 (our LTS
|
|
112
|
+
* baseline) only the string form is available. The string is OpenSSL's
|
|
113
|
+
* ASN1_TIME format, e.g. "Jun 21 14:41:05 2026 GMT" — a format `new Date()`
|
|
114
|
+
* parses reliably across V8 versions on every platform we ship to.
|
|
115
|
+
*/
|
|
116
|
+
function parseValidTo(validTo) {
|
|
117
|
+
if (!validTo)
|
|
118
|
+
return null;
|
|
119
|
+
const d = new Date(validTo);
|
|
120
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
121
|
+
}
|
|
122
|
+
function classifyTailscaleError(err, stderr) {
|
|
123
|
+
if (err.code === "ENOENT")
|
|
124
|
+
return "tailscale_missing";
|
|
125
|
+
if (err.signal === "SIGTERM" || err.killed)
|
|
126
|
+
return "timeout";
|
|
127
|
+
const haystack = (stderr ?? "").toLowerCase();
|
|
128
|
+
// Tailscale messaging is stable enough to pattern-match. Examples we've
|
|
129
|
+
// seen in the wild:
|
|
130
|
+
// "HTTPS must be enabled..." (admin console toggle off)
|
|
131
|
+
// "HTTPS is not enabled for this tailnet"
|
|
132
|
+
// "no such host" / "is not in your tailnet" (wrong host)
|
|
133
|
+
if (haystack.includes("https") && (haystack.includes("not enabled") || haystack.includes("must be enabled"))) {
|
|
134
|
+
return "https_not_enabled";
|
|
135
|
+
}
|
|
136
|
+
if (haystack.includes("no such host") || haystack.includes("not in your tailnet")) {
|
|
137
|
+
return "bad_host";
|
|
138
|
+
}
|
|
139
|
+
return "unknown";
|
|
140
|
+
}
|
|
141
|
+
function subjectMatchesHost(cert, host) {
|
|
142
|
+
// X509Certificate.subject is multi-line "CN=foo\nO=bar". Pull the CN.
|
|
143
|
+
const cnLine = cert.subject.split(/\r?\n/).find((l) => l.startsWith("CN="));
|
|
144
|
+
if (!cnLine)
|
|
145
|
+
return false;
|
|
146
|
+
return cnLine.slice(3).trim().toLowerCase() === host.toLowerCase();
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=tls.js.map
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, mkdirSync, chmodSync, unlinkSync, createWriteStream, createReadStream, } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir, platform, arch } from "node:os";
|
|
5
|
+
import { get as httpsGet } from "node:https";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
let cachedPath;
|
|
8
|
+
const TMUX_VERSION = "3.6a";
|
|
9
|
+
const BRANE_BIN = join(homedir(), ".brane", "bin");
|
|
10
|
+
const BUNDLED_TMUX = join(BRANE_BIN, "tmux");
|
|
11
|
+
// Map Node.js platform/arch to tmux-builds release-asset naming.
|
|
12
|
+
const PLATFORM_MAP = { darwin: "macos", linux: "linux" };
|
|
13
|
+
const ARCH_MAP = { arm64: "arm64", x64: "x86_64" };
|
|
14
|
+
// Pinned SHA-256 digests for tmux-builds v3.6a release assets, keyed by
|
|
15
|
+
// `${os}-${cpu}`. Sourced from the GitHub release API's asset `digest`
|
|
16
|
+
// field. If an upstream asset is ever re-uploaded or substituted, the
|
|
17
|
+
// hash check below fails closed and we refuse to extract/run it.
|
|
18
|
+
const SHA256 = {
|
|
19
|
+
"linux-arm64": "bb5afd9d646df54a7d7c66e198aa22c7d293c7453534f1670f7c540534db8b5e",
|
|
20
|
+
"linux-x86_64": "c0a772a5e6ca8f129b0111d10029a52e02bcbc8352d5a8c0d3de8466a1e59c2e",
|
|
21
|
+
"macos-arm64": "12b5b9f8696e1286897d946649c0a80d0169dd76e018d34476a1fbd34de89a0f",
|
|
22
|
+
"macos-x86_64": "b9b12eaeba43acf5671acf3857d947525440b544185a8db34ea557199a090251",
|
|
23
|
+
};
|
|
24
|
+
/** Scan PATH for an executable — pure in-process, no subprocess spawn. */
|
|
25
|
+
function findInPath(name) {
|
|
26
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
27
|
+
if (!dir)
|
|
28
|
+
continue;
|
|
29
|
+
const candidate = join(dir, name);
|
|
30
|
+
try {
|
|
31
|
+
accessSync(candidate, constants.X_OK);
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Resolve the tmux binary: system PATH first, then ~/.brane/bin/, then error. */
|
|
39
|
+
export function resolveTmux() {
|
|
40
|
+
if (cachedPath)
|
|
41
|
+
return cachedPath;
|
|
42
|
+
// 1. System PATH (in-process scan, no subprocess)
|
|
43
|
+
const systemTmux = findInPath("tmux");
|
|
44
|
+
if (systemTmux) {
|
|
45
|
+
cachedPath = systemTmux;
|
|
46
|
+
return cachedPath;
|
|
47
|
+
}
|
|
48
|
+
// 2. Binary fetched lazily by ensureTmux() into ~/.brane/bin/
|
|
49
|
+
try {
|
|
50
|
+
accessSync(BUNDLED_TMUX, constants.X_OK);
|
|
51
|
+
cachedPath = BUNDLED_TMUX;
|
|
52
|
+
return cachedPath;
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
throw new Error("tmux not found. Install it with: brew install tmux (macOS) or apt install tmux (Linux)");
|
|
56
|
+
}
|
|
57
|
+
let ensurePromise;
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a usable tmux exists, downloading a verified static binary into
|
|
60
|
+
* ~/.brane/bin/ if neither a system tmux nor a previously-fetched one is
|
|
61
|
+
* present. Best-effort and idempotent: single-flighted, never throws, and
|
|
62
|
+
* never blocks the caller's success path — if it cannot provide tmux,
|
|
63
|
+
* resolveTmux() will surface the actionable "install tmux" error when a
|
|
64
|
+
* tmux-dependent operation is actually attempted.
|
|
65
|
+
*/
|
|
66
|
+
export function ensureTmux() {
|
|
67
|
+
if (!ensurePromise)
|
|
68
|
+
ensurePromise = doEnsureTmux();
|
|
69
|
+
return ensurePromise;
|
|
70
|
+
}
|
|
71
|
+
async function doEnsureTmux() {
|
|
72
|
+
// Already satisfiable — system tmux or a prior fetch.
|
|
73
|
+
if (findInPath("tmux"))
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
accessSync(BUNDLED_TMUX, constants.X_OK);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
const os = PLATFORM_MAP[platform()];
|
|
81
|
+
const cpu = ARCH_MAP[arch()];
|
|
82
|
+
if (!os || !cpu) {
|
|
83
|
+
console.error(`brane: no prebuilt tmux for ${platform()}-${arch()}; install tmux manually`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const key = `${os}-${cpu}`;
|
|
87
|
+
const expected = SHA256[key];
|
|
88
|
+
const url = `https://github.com/tmux/tmux-builds/releases/download/v${TMUX_VERSION}/tmux-${TMUX_VERSION}-${key}.tar.gz`;
|
|
89
|
+
const tarball = join(BRANE_BIN, `tmux-${TMUX_VERSION}.tar.gz`);
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(BRANE_BIN, { recursive: true });
|
|
92
|
+
console.error("brane: tmux not found, downloading static binary…");
|
|
93
|
+
// Download (follows redirects — GitHub releases redirect to a CDN).
|
|
94
|
+
await download(url, tarball);
|
|
95
|
+
// Verify integrity before trusting the archive. Fail closed: on any
|
|
96
|
+
// mismatch (or missing pin) delete the file and refuse to extract or
|
|
97
|
+
// run an unverified binary.
|
|
98
|
+
const actual = await sha256File(tarball);
|
|
99
|
+
if (!expected || actual !== expected) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(tarball);
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
console.error(`brane: tmux archive failed SHA-256 verification (expected ${expected ?? "n/a"}, got ${actual}); refusing to install. Install manually: brew install tmux`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Extract — the tarball contains a bare "tmux" at its root.
|
|
108
|
+
const { execFileSync } = await import("node:child_process");
|
|
109
|
+
execFileSync("tar", ["xzf", tarball, "-C", BRANE_BIN, "tmux"], {
|
|
110
|
+
stdio: "ignore",
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(tarball);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
if (existsSync(BUNDLED_TMUX)) {
|
|
117
|
+
chmodSync(BUNDLED_TMUX, 0o755);
|
|
118
|
+
console.error(`brane: tmux ${TMUX_VERSION} installed to ${BUNDLED_TMUX}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.error("brane: failed to extract tmux binary from archive");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
try {
|
|
126
|
+
unlinkSync(tarball);
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
console.error(`brane: failed to download tmux (${err.message}). Install manually: brew install tmux`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Compute the SHA-256 hex digest of a file by streaming it. */
|
|
133
|
+
function sha256File(path) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const hash = createHash("sha256");
|
|
136
|
+
const stream = createReadStream(path);
|
|
137
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
138
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
139
|
+
stream.on("error", reject);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Download a URL to a file, following up to 5 redirects. */
|
|
143
|
+
function download(url, dest, redirects = 0) {
|
|
144
|
+
if (redirects > 5)
|
|
145
|
+
return Promise.reject(new Error("Too many redirects"));
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const file = createWriteStream(dest);
|
|
148
|
+
httpsGet(url, (res) => {
|
|
149
|
+
const status = res.statusCode ?? 0;
|
|
150
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
151
|
+
file.close();
|
|
152
|
+
download(res.headers.location, dest, redirects + 1).then(resolve, reject);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (status !== 200) {
|
|
156
|
+
file.close();
|
|
157
|
+
reject(new Error(`HTTP ${status}`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
res.pipe(file);
|
|
161
|
+
file.on("finish", () => file.close(() => resolve()));
|
|
162
|
+
file.on("error", reject);
|
|
163
|
+
}).on("error", (err) => {
|
|
164
|
+
file.close();
|
|
165
|
+
reject(err);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** A tmux command-language word: single-quoted, nothing inside is special
|
|
170
|
+
* (`$`, `~`, `#`, `;`), and a `'` is closed, escaped and reopened. */
|
|
171
|
+
export function tmuxQuote(word) {
|
|
172
|
+
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
173
|
+
}
|
|
174
|
+
/** A request's `terminal_size`, if it is a usable grid: the size the
|
|
175
|
+
* requesting viewer's pane last measured, which a new session should start
|
|
176
|
+
* at. Anything else — absent, non-integer, out of range — is null, and the
|
|
177
|
+
* caller falls back to the last size applied to any session. */
|
|
178
|
+
export function parseTerminalSize(value) {
|
|
179
|
+
if (!value || typeof value !== "object")
|
|
180
|
+
return null;
|
|
181
|
+
const { cols, rows } = value;
|
|
182
|
+
const ok = (n) => Number.isInteger(n) && n >= 1 && n <= 10000;
|
|
183
|
+
return ok(cols) && ok(rows) ? { cols, rows } : null;
|
|
184
|
+
}
|
|
185
|
+
/** The `new-session` line `newTmuxSession` sends, as tmux will parse it. */
|
|
186
|
+
export function newSessionLine(opts) {
|
|
187
|
+
const parts = ["new-session", "-d", "-s", tmuxQuote(opts.name), "-c", tmuxQuote(opts.cwd)];
|
|
188
|
+
if (opts.size)
|
|
189
|
+
parts.push("-x", String(opts.size.cols), "-y", String(opts.size.rows));
|
|
190
|
+
parts.push(tmuxQuote(opts.command));
|
|
191
|
+
return parts.join(" ");
|
|
192
|
+
}
|
|
193
|
+
/** Start a detached tmux session running `command`, without the command
|
|
194
|
+
* ever appearing in a process's arguments. A launch command carries the
|
|
195
|
+
* session's agent token (`BRANE_NARRATIVE_SECRET=…`), and arguments are
|
|
196
|
+
* readable by every user on the machine through `ps` — for the client's
|
|
197
|
+
* brief life, and for the tmux *server's* whole life when this call is the
|
|
198
|
+
* one that starts it, since the server is forked from that client and keeps
|
|
199
|
+
* its argv. So the command goes to `tmux source-file -` on stdin, and what
|
|
200
|
+
* `ps` shows is `tmux source-file -`. Throws if tmux refuses (a duplicate
|
|
201
|
+
* session name, say). */
|
|
202
|
+
export function newTmuxSession(opts) {
|
|
203
|
+
// `source-file` alone needs a server already running, and tmux exits with
|
|
204
|
+
// its last session, so the first launch after a reboot would fail
|
|
205
|
+
// (claude-jrjh1e8w:22). `start-server` starts one if needed — with this
|
|
206
|
+
// argv, which carries no secret — and is a no-op otherwise.
|
|
207
|
+
const socketArgs = opts.socket ? ["-S", opts.socket] : [];
|
|
208
|
+
execFileSync(resolveTmux(), [...socketArgs, "start-server", ";", "source-file", "-"], {
|
|
209
|
+
input: newSessionLine(opts) + "\n",
|
|
210
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=tmux.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short "Safari on iPhone" for a User-Agent header, for the Devices list.
|
|
3
|
+
* It is a label for a human telling rows apart, not a detection anyone should
|
|
4
|
+
* branch on: User-Agents lie (an iPad asks for desktop sites as a Mac), and
|
|
5
|
+
* that is fine here — the row also shows where and when it was seen.
|
|
6
|
+
*
|
|
7
|
+
* Order matters: every Chromium browser also says "Chrome" and "Safari", and
|
|
8
|
+
* every iOS browser is WebKit underneath and says "Safari" or nothing.
|
|
9
|
+
*/
|
|
10
|
+
const BROWSERS = [
|
|
11
|
+
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
|
|
12
|
+
[/\bOPR\/|\bOPiOS\//, "Opera"],
|
|
13
|
+
[/\bFirefox\/|\bFxiOS\//, "Firefox"],
|
|
14
|
+
[/\bCriOS\//, "Chrome"],
|
|
15
|
+
[/\bChrome\//, "Chrome"],
|
|
16
|
+
[/\bVersion\/[\d.]+.*\bSafari\//, "Safari"],
|
|
17
|
+
];
|
|
18
|
+
const PLATFORMS = [
|
|
19
|
+
[/\biPhone\b/, "iPhone"],
|
|
20
|
+
[/\biPad\b/, "iPad"],
|
|
21
|
+
[/\bAndroid\b/, "Android"],
|
|
22
|
+
[/\bCrOS\b/, "ChromeOS"],
|
|
23
|
+
[/\bMacintosh\b|\bMac OS X\b/, "macOS"],
|
|
24
|
+
[/\bWindows\b/, "Windows"],
|
|
25
|
+
[/\bLinux\b/, "Linux"],
|
|
26
|
+
];
|
|
27
|
+
export function describeUserAgent(ua) {
|
|
28
|
+
if (!ua)
|
|
29
|
+
return null;
|
|
30
|
+
const browser = BROWSERS.find(([re]) => re.test(ua))?.[1];
|
|
31
|
+
const platform = PLATFORMS.find(([re]) => re.test(ua))?.[1];
|
|
32
|
+
if (browser && platform)
|
|
33
|
+
return `${browser} on ${platform}`;
|
|
34
|
+
// An app saved to an iPhone's home screen drops the Safari token and keeps
|
|
35
|
+
// only WebKit's.
|
|
36
|
+
if (platform && /\bAppleWebKit\//.test(ua))
|
|
37
|
+
return `Web app on ${platform}`;
|
|
38
|
+
if (browser)
|
|
39
|
+
return browser;
|
|
40
|
+
if (platform)
|
|
41
|
+
return platform;
|
|
42
|
+
// Not a browser: `node`, `curl/8.7.1`. Its product name is the best label.
|
|
43
|
+
const product = /^([A-Za-z][\w.-]*)/.exec(ua)?.[1];
|
|
44
|
+
return product && product !== "Mozilla" ? product : null;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=user-agent.js.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Volume is mounted at /home/brane (the entire home directory).
|
|
5
|
+
# Migrate from old layout where volume was mounted at /home/brane/.brane
|
|
6
|
+
# and contained brane.db at the volume root.
|
|
7
|
+
if [ -f /home/brane/brane.db ] && [ ! -d /home/brane/.brane/db ]; then
|
|
8
|
+
echo "Migrating volume layout: moving contents into .brane/ subdirectory..."
|
|
9
|
+
mkdir -p /home/brane/.brane
|
|
10
|
+
# Move everything except .brane itself into .brane/
|
|
11
|
+
for f in /home/brane/*; do
|
|
12
|
+
[ "$(basename "$f")" = ".brane" ] && continue
|
|
13
|
+
mv "$f" /home/brane/.brane/
|
|
14
|
+
done
|
|
15
|
+
for f in /home/brane/.[!.]* /home/brane/..?*; do
|
|
16
|
+
[ -e "$f" ] || continue
|
|
17
|
+
[ "$(basename "$f")" = ".brane" ] && continue
|
|
18
|
+
mv "$f" /home/brane/.brane/
|
|
19
|
+
done
|
|
20
|
+
echo "Migration complete."
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Ensure the brane user owns the home directory
|
|
24
|
+
chown -R brane:brane /home/brane
|
|
25
|
+
|
|
26
|
+
# Copy tmux binary into the volume if missing (the image layer copy is
|
|
27
|
+
# hidden when the persistent volume mounts over /home/brane)
|
|
28
|
+
if [ ! -f /home/brane/.brane/bin/tmux ] && [ -f /root/.brane/bin/tmux ]; then
|
|
29
|
+
mkdir -p /home/brane/.brane/bin
|
|
30
|
+
cp /root/.brane/bin/tmux /home/brane/.brane/bin/tmux
|
|
31
|
+
chown brane:brane /home/brane/.brane/bin/tmux
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Tailscale state lives inside the brane data volume (single mount on Fly)
|
|
35
|
+
TS_STATE=/home/brane/.brane/tailscale
|
|
36
|
+
mkdir -p "$TS_STATE"
|
|
37
|
+
|
|
38
|
+
# Start tailscaled as root (needs privileges for networking)
|
|
39
|
+
tailscaled --tun=userspace-networking --statedir="$TS_STATE" &
|
|
40
|
+
|
|
41
|
+
# Wait for the tailscaled socket
|
|
42
|
+
for i in $(seq 1 10); do
|
|
43
|
+
[ -S /var/run/tailscale/tailscaled.sock ] && break
|
|
44
|
+
sleep 1
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
# Authenticate with Tailscale (as root).
|
|
48
|
+
# Build hostname: append Fly region if available (e.g., brane-5a04ec-nrt)
|
|
49
|
+
TS_HOSTNAME="${TS_HOSTNAME:-brane}"
|
|
50
|
+
if [ -n "$FLY_REGION" ]; then
|
|
51
|
+
TS_HOSTNAME="${TS_HOSTNAME}-${FLY_REGION}"
|
|
52
|
+
fi
|
|
53
|
+
# The node is tag-owned, so its key never expires. Every `up` names the tags:
|
|
54
|
+
# one without them asks control to drop them, which needs a login nobody here
|
|
55
|
+
# can complete. --timeout makes any such wait fail instead of hang.
|
|
56
|
+
TS_TAGS="${TS_TAGS:-tag:brane}"
|
|
57
|
+
TS_UP=(tailscale up --reset --hostname="$TS_HOSTNAME" --advertise-tags="$TS_TAGS")
|
|
58
|
+
|
|
59
|
+
# Fly's OIDC token for this machine (`sub` is <org>:<app>:<machine>), which
|
|
60
|
+
# Tailscale's workload identity federation trades for a node key.
|
|
61
|
+
fly_oidc_token() {
|
|
62
|
+
local out
|
|
63
|
+
out=$(curl -fsS --unix-socket /.fly/api -X POST http://localhost/v1/tokens/oidc \
|
|
64
|
+
-H 'Content-Type: application/json' --data "{\"aud\":\"$1\"}") || return 1
|
|
65
|
+
case "$out" in
|
|
66
|
+
\{*) printf '%s' "$out" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);process.stdout.write(j.token||j.jwt||j.id_token||"")})' ;;
|
|
67
|
+
*) printf '%s' "$out" | tr -d '"\r\n' ;;
|
|
68
|
+
esac
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# The state on the volume first (every restart after the first boot), then a
|
|
72
|
+
# credential: TS_CLIENT_ID (workload identity: no secret on Fly) or TS_AUTHKEY
|
|
73
|
+
# (an auth key or OAuth client secret allowed to apply $TS_TAGS).
|
|
74
|
+
if "${TS_UP[@]}" --timeout=30s; then
|
|
75
|
+
:
|
|
76
|
+
elif [ -n "$TS_CLIENT_ID" ]; then
|
|
77
|
+
ID_TOKEN=$(fly_oidc_token "${TS_AUDIENCE:-api.tailscale.com/${TS_CLIENT_ID}}") || ID_TOKEN=
|
|
78
|
+
if [ -z "$ID_TOKEN" ]; then
|
|
79
|
+
echo "entrypoint: could not get an OIDC token from Fly for Tailscale" >&2
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
"${TS_UP[@]}" --timeout=60s --client-id="${TS_CLIENT_ID}?ephemeral=false&preauthorized=true" --id-token="$ID_TOKEN"
|
|
83
|
+
elif [ -n "$TS_AUTHKEY" ]; then
|
|
84
|
+
"${TS_UP[@]}" --timeout=60s --auth-key="$TS_AUTHKEY"
|
|
85
|
+
else
|
|
86
|
+
echo "entrypoint: not on the tailnet, and neither TS_CLIENT_ID nor TS_AUTHKEY is set" >&2
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Allow the brane user to manage Tailscale (needed for cert provisioning)
|
|
91
|
+
tailscale set --operator=brane || true
|
|
92
|
+
|
|
93
|
+
# Provision TLS cert from Tailscale (non-fatal if it fails)
|
|
94
|
+
gosu brane brane certify 2>/dev/null || true
|
|
95
|
+
|
|
96
|
+
# Ensure workspace directory exists on the volume
|
|
97
|
+
mkdir -p /home/brane/workspace
|
|
98
|
+
chown brane:brane /home/brane/workspace
|
|
99
|
+
|
|
100
|
+
# Ensure .mcp.json exists so agents have brane MCP tools from the start
|
|
101
|
+
MCP_JSON=/home/brane/workspace/.mcp.json
|
|
102
|
+
if [ ! -f "$MCP_JSON" ]; then
|
|
103
|
+
cat > "$MCP_JSON" <<'MCPEOF'
|
|
104
|
+
{"mcpServers":{"brane":{"command":"node","args":["/usr/local/bin/brane","mcp"]}}}
|
|
105
|
+
MCPEOF
|
|
106
|
+
chown brane:brane "$MCP_JSON"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# Clear stale daemon meta from the persisted volume — PIDs from a previous
|
|
110
|
+
# container life are never valid in a new container, but may collide with
|
|
111
|
+
# a live PID here (e.g., tailscaled) and trip the "already running" check.
|
|
112
|
+
#
|
|
113
|
+
# Not the ports, though. Every melded device's URL and every fold's hook
|
|
114
|
+
# config names them, so the daemon comes back on the ones it had, as it
|
|
115
|
+
# does after any restart; clearing them moved both on every boot. The main
|
|
116
|
+
# port's row still goes (the fold registration below waits for the daemon
|
|
117
|
+
# to write it back), so it is read first and handed back with --port. The
|
|
118
|
+
# HTTP fallback port's row stays: the daemon only ever takes it as a hint
|
|
119
|
+
# for what to bind, and nothing reads it while daemon_tls is unset.
|
|
120
|
+
PREV_PORT=$(gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
121
|
+
"SELECT value FROM meta WHERE key = 'daemon_port';" 2>/dev/null || true)
|
|
122
|
+
gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
123
|
+
"DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','daemon_socket');" \
|
|
124
|
+
2>/dev/null || true
|
|
125
|
+
|
|
126
|
+
# Register the default workspace as a fold (idempotent) once the daemon is up.
|
|
127
|
+
# `brane fold` is a daemon client: run before the daemon, it would start one
|
|
128
|
+
# of its own, and the exec below would start a second on the same database.
|
|
129
|
+
# The daemon records its port only after its socket is bound, and the rows
|
|
130
|
+
# above are gone, so a port here means the daemon below is ready.
|
|
131
|
+
(
|
|
132
|
+
for i in $(seq 1 120); do
|
|
133
|
+
port=$(gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
134
|
+
"SELECT value FROM meta WHERE key = 'daemon_port';" 2>/dev/null || true)
|
|
135
|
+
if [ -n "$port" ]; then
|
|
136
|
+
gosu brane brane fold /home/brane/workspace >/dev/null ||
|
|
137
|
+
echo "entrypoint: could not register /home/brane/workspace as a fold" >&2
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
sleep 1
|
|
141
|
+
done
|
|
142
|
+
echo "entrypoint: no daemon after 120s; /home/brane/workspace is not registered as a fold" >&2
|
|
143
|
+
) &
|
|
144
|
+
|
|
145
|
+
# Drop privileges and run the daemon as the brane user.
|
|
146
|
+
# --bind 0.0.0.0: tailscaled --tun=userspace-networking doesn't put the
|
|
147
|
+
# 100.x.y.z address on a kernel interface, so a specific bind to it fails
|
|
148
|
+
# with EADDRNOTAVAIL. Binding broadly is safe here — Fly's only reachable
|
|
149
|
+
# networks are Tailscale and the private 6PN, both of which the userspace
|
|
150
|
+
# IP filter already restricts.
|
|
151
|
+
# --port: the port the last boot had (above). If something else holds it
|
|
152
|
+
# now, the daemon exits before writing its row back, so the next boot finds
|
|
153
|
+
# none and lets the OS pick.
|
|
154
|
+
exec gosu brane brane daemon start --foreground --bind 0.0.0.0 ${PREV_PORT:+--port "$PREV_PORT"} "$@"
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# docs/
|
|
2
|
+
|
|
3
|
+
Design notes for brane's subsystems, one file each. They are indexed, with a line on what each covers and which code it goes with, in [AGENTS.md's Further Reading](../AGENTS.md#further-reading). The rules that apply to every change are in [AGENTS.md](../AGENTS.md) itself.
|