brane 0.3.0 → 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 +12 -7
- package/CHANGELOG.md +37 -4
- package/CONTRIBUTING.md +12 -2
- package/DESIGN.md +7 -4
- package/README.md +2 -2
- package/SECURITY.md +8 -0
- package/dist/commands/config.js +17 -0
- package/dist/commands/meld.js +38 -10
- package/dist/commands/spawn.js +11 -4
- package/dist/commands/token.js +2 -1
- package/dist/daemon/server.js +88 -84
- package/dist/db/schema.js +10 -0
- package/dist/db/tokens.js +43 -2
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/narrative-menu.js +8 -6
- package/dist/shared/view-url.js +39 -20
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/cors-preflight.test.js +28 -21
- package/dist/test/hook-config.test.js +38 -1
- package/dist/test/meld.test.js +59 -1
- package/dist/test/narrative-menu.test.js +13 -0
- package/dist/test/remote-meld.test.js +13 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/token-devices.test.js +59 -0
- package/dist/test/view-url.test.js +93 -47
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tmux.js +11 -0
- package/docker-entrypoint.sh +15 -2
- package/docs/daemon-api.md +4 -4
- package/docs/releasing.md +59 -8
- package/docs/terminal-mirroring.md +32 -0
- package/docs/token-rotation.md +22 -4
- package/docs/view-urls.md +26 -6
- package/docs/web-app.md +1 -1
- package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-e94247f119fdcd3b424a1764233a41d2.js} +47 -45
- package/expo/dist/index.html +1 -1
- package/expo/dist/sw.js +2 -2
- package/package.json +1 -1
- package/wiki/shared/index.md +5 -0
package/AGENTS.md
CHANGED
|
@@ -22,7 +22,7 @@ Brane is a TypeScript reimagining of the [organelle](https://github.com/benjamn/
|
|
|
22
22
|
- **Terminal mirroring** of tmux sessions with keyboard input
|
|
23
23
|
- The **LIVE loop** protocol for cursor-based agent communication
|
|
24
24
|
- A **semantic memory** system with embeddings and full-text search
|
|
25
|
-
- **Multi-agent support** with session resumption
|
|
25
|
+
- **Multi-agent support** across 8 agent types, with session resumption where the agent offers it
|
|
26
26
|
|
|
27
27
|
## Golden Rule
|
|
28
28
|
|
|
@@ -66,7 +66,7 @@ brane/
|
|
|
66
66
|
│ ├── components/ # MacroMenu, TerminalPane, TerminalKeyboard
|
|
67
67
|
│ ├── lib/ # api.ts, terminal.ts, connection.ts, registry.ts
|
|
68
68
|
│ └── dist/ # Built web bundle (served by daemon)
|
|
69
|
-
├── worker/ # Cloudflare Workers deployment (serves expo/dist as SPA)
|
|
69
|
+
├── worker/ # Cloudflare Workers deployment (serves the latest release's expo/dist as SPA)
|
|
70
70
|
├── protocols/
|
|
71
71
|
│ ├── LIVE_loop.md # Core agent communication protocol
|
|
72
72
|
│ ├── digest.md # /digest protocol for activity summaries
|
|
@@ -113,8 +113,11 @@ non-executable files) while `node dist/cli.js` keeps working.
|
|
|
113
113
|
|
|
114
114
|
Publishing to npm is done by `.github/workflows/release.yml`, triggered by a
|
|
115
115
|
published GitHub release, and only by it. **Agents never run `npm publish`.**
|
|
116
|
-
The
|
|
117
|
-
|
|
116
|
+
The shared web app ships with it: a release deploys its tarball's
|
|
117
|
+
`expo/dist`, and a merge to `main` deploys nothing (staging is Isolate on a
|
|
118
|
+
daemon running `main`). The version bump, tag rule, dist-tag choice, app
|
|
119
|
+
deploy and rollback, and why 0.3.0 has no provenance attestation are in
|
|
120
|
+
[docs/releasing.md](docs/releasing.md).
|
|
118
121
|
|
|
119
122
|
## CLI Commands
|
|
120
123
|
|
|
@@ -125,7 +128,7 @@ attestation are in [docs/releasing.md](docs/releasing.md).
|
|
|
125
128
|
| `attach` | Attach to a tmux session |
|
|
126
129
|
| `list` | List narratives grouped by brane (`--brane`, `--archived`, `--limit`) — reads `GET /api/narratives` + `GET /api/branes` |
|
|
127
130
|
| `fold` | Register directories as spawn points (`--brane` to associate with a brane) — delegates to `POST /api/folds` |
|
|
128
|
-
| `meld` | Generate QR code for mobile access (
|
|
131
|
+
| `meld` | Generate QR code for mobile access (points at the `app_domain` config, `brane.organelle.dev` unless set; `--app <host>` for one QR, `--local` for a direct daemon URL); waits for Touch ID at this machine — see [Presence Check](docs/presence.md#the-gate). `--remote --send … --ask …` melds a daemon on another machine (a Fly machine: /fly) with a token minted here, and refuses in an agent's session |
|
|
129
132
|
| `mcp` | MCP stdio server (used internally by agent sessions) |
|
|
130
133
|
| `revive` | Restart dead agent sessions (`--prompt`, `--no-prompt`, `--agent`) — delegates to `POST /api/revive/:nid` |
|
|
131
134
|
| `config` | Get/set brane configuration (git-config style: `--list`, `--unset`) |
|
|
@@ -170,6 +173,8 @@ Brane supports multiple agent types with session resumption. Each agent has an a
|
|
|
170
173
|
| `codex` | `resume <session_id>` | Subcommand-based resumption |
|
|
171
174
|
| `amp` | (none) | Uses thread references `@T-<id>` in prompt |
|
|
172
175
|
| `copilot` | `--session-id <session_id>` | Daemon-pinned UUID resumes the exact local session |
|
|
176
|
+
| `cline` | (none) | Each revive starts a fresh session; brane captures its id for cost |
|
|
177
|
+
| `crush` | (none) | Each revive starts a fresh session; brane captures its id for cost |
|
|
173
178
|
|
|
174
179
|
Spawning a session generates agent-specific MCP config. This happens in `doSpawn()`, which always runs in the daemon — `brane spawn` gets it by delegating to `POST /api/spawn`, not by writing these files itself:
|
|
175
180
|
- **Claude**: `.mcp.json` in fold directory
|
|
@@ -187,7 +192,7 @@ See `SECURITY.md` for full details. Key features:
|
|
|
187
192
|
- **Atomic daemon startup** — `BEGIN EXCLUSIVE` SQLite transaction prevents race conditions
|
|
188
193
|
- **Version checking** — daemon stores version for CLI compatibility detection
|
|
189
194
|
- **Payload security** — path traversal protection, 50MB size limits, immutable cached URLs
|
|
190
|
-
- **
|
|
195
|
+
- **The token is the only credential** — no origin allowlist: CORS answers `*` without credentials, the WebSocket upgrade checks the token and nothing else, and the `Origin` a device runs the app from is recorded on its token for the Devices list (`SECURITY.md`, Authentication)
|
|
191
196
|
|
|
192
197
|
## Rules for Every Change
|
|
193
198
|
|
|
@@ -345,7 +350,7 @@ read at runtime).
|
|
|
345
350
|
- **The mirror sends text; everything else is explicit**: the pane's cursor is the only thing about a terminal that isn't in its text, so it rides the same tmux round trip as the capture — and gets its own message when it moves and the text doesn't
|
|
346
351
|
- **The chat never scrolls itself**: after landing, only the operator moves the chat. It is a `column-reverse` scroller (`CHAT_SCROLLER_STYLE`, `expo/app/[nid].tsx`), not a virtualized `FlatList`, so scroll position is measured from the bottom: it opens on the latest message without a scroll, an arrival is followed only when the view is already exactly at the bottom, a page of older history lands at the far end and moves nothing, and no row is ever swapped for an estimated spacer mid-scroll. The one scroll the code writes is landing on `?scrollToSeq=` / `?scrollTo=`, plus the ↑/↓ tab button the operator taps. Sending doesn't scroll, and vertical scroll is never disabled, not even under a horizontal pane swipe. Measured in Chromium and WebKit against a sandbox daemon: the message under the viewport moves 0 px on arrival, on send and on each page loaded
|
|
347
352
|
- **A tap should cost what it changed**: the chat's selection lives in a store with per-seq subscriptions, so selecting a message wakes that bubble, the header's count and the composer — not the screen, not `renderItem`, not the other two hundred rows; the same store is what makes two taps in one frame both land
|
|
348
|
-
- **The URL carries the view**: anything that must survive an origin hop (Isolate) or a device hop (Meld) goes in the query string, built through `src/shared/view-url.ts` — `sessionStorage` is origin-scoped and does not travel
|
|
353
|
+
- **The URL carries the view**: anything that must survive an origin hop (Isolate) or a device hop (Meld) goes in the query string, built through `src/shared/view-url.ts` — `sessionStorage` is origin-scoped and does not travel. The app names no host: "isolated" is `location.host` equal to the connection's host, and the way out is the daemon's `app_domain` from `/api/status`; `src/test/app-host-literal.test.ts` keeps it that way
|
|
349
354
|
- **Back undoes a jump, never a view change**: a different narrative or any `nid:seq` link (even into the narrative you're reading) is a `router.push`; a tab, pin, file or selection change is `router.setParams`, which replaces. Screens behind the top stay mounted, so Back restores them whole, and narrative subscriptions are refcounted because two of them can watch the same narrative — see [View URLs](docs/view-urls.md)
|
|
350
355
|
- **`shared/` is the publication unit**: a fold's `wiki/shared/` is what other folds — and the npm tarball — can see; everything directly under `wiki/` is fold-local, and the install directory is just another fold
|
|
351
356
|
- **One renderer, two targets**: markdown features that must survive export (code highlighting, math) render server-side or through shared code, never through a script the exported file carries
|
package/CHANGELOG.md
CHANGED
|
@@ -6,14 +6,47 @@ page, which is also what triggers its publish (see
|
|
|
6
6
|
[docs/releasing.md](docs/releasing.md)). This file is the summary that
|
|
7
7
|
travels in the npm tarball.
|
|
8
8
|
|
|
9
|
+
## 0.3.1 - 2026-10-12
|
|
10
|
+
|
|
11
|
+
- The shared web app ships with the release. It serves the `latest`
|
|
12
|
+
release's own `expo/dist`, and a merge to `main` deploys nothing; a
|
|
13
|
+
daemon running `main` serves its own newer bundle, through Isolate
|
|
14
|
+
([#600](https://github.com/benjamn/brane/pull/600)).
|
|
15
|
+
- The app knows no host. `brane meld` and `brane spawn --meld` point
|
|
16
|
+
their QR at the `app_domain` config (`--app` / `--meld-app` for one
|
|
17
|
+
QR), `GET /api/status` reports it, and Isolate and the in-app Meld
|
|
18
|
+
work from whichever host serves the app
|
|
19
|
+
([#598](https://github.com/benjamn/brane/pull/598),
|
|
20
|
+
[#599](https://github.com/benjamn/brane/pull/599)).
|
|
21
|
+
- The token is the only credential. The daemon no longer checks an
|
|
22
|
+
origin allowlist: CORS answers `*` without credentials. The origin
|
|
23
|
+
each device runs the app from is recorded and shown in the Devices
|
|
24
|
+
list and `brane token list`
|
|
25
|
+
([#593](https://github.com/benjamn/brane/pull/593),
|
|
26
|
+
[#595](https://github.com/benjamn/brane/pull/595)).
|
|
27
|
+
- The spawn dialog's "Other…" agent runs any command on the daemon's
|
|
28
|
+
`PATH`, as `brane spawn <agent>` does, and spawn errors show in the
|
|
29
|
+
dialog ([#596](https://github.com/benjamn/brane/pull/596)).
|
|
30
|
+
- New terminals start at the viewer's size, so launch redraws no longer
|
|
31
|
+
pile up in scrollback, and the 🦾 menu gains "Clear 🧽" to clear a
|
|
32
|
+
pane's scrollback ([#574](https://github.com/benjamn/brane/pull/574)).
|
|
33
|
+
- On a Fly machine the daemon keeps its ports across boots, and a revive
|
|
34
|
+
repoints the fold's hook config at the running daemon
|
|
35
|
+
([#594](https://github.com/benjamn/brane/pull/594)).
|
|
36
|
+
- Vulnerabilities are reported through GitHub's private vulnerability
|
|
37
|
+
reporting (see `SECURITY.md`). This is the first release published
|
|
38
|
+
from the public repository, so it carries npm provenance; 0.3.0 has
|
|
39
|
+
none ([#597](https://github.com/benjamn/brane/pull/597)).
|
|
40
|
+
|
|
9
41
|
## 0.3.0 - 2026-10-11
|
|
10
42
|
|
|
11
43
|
The first release of this codebase. What ships:
|
|
12
44
|
|
|
13
45
|
- The `brane` CLI and userspace daemon: register directories as folds,
|
|
14
|
-
spawn agents in them (Claude Code, Codex, Gemini, Goose,
|
|
15
|
-
or any command) under tmux, and keep every session's
|
|
16
|
-
SQLite database, every message with a stable `nid:seq`
|
|
46
|
+
spawn agents in them (Claude Code, Codex, Gemini, Goose, Amp, Copilot,
|
|
47
|
+
Cline, Crush, or any command) under tmux, and keep every session's
|
|
48
|
+
narrative in one SQLite database, every message with a stable `nid:seq`
|
|
49
|
+
address.
|
|
17
50
|
- The LIVE loop: an MCP server injected into each agent session
|
|
18
51
|
(`narrate`, `listen`, `whoami`, wiki and memory search), and the
|
|
19
52
|
protocols agents read at runtime, in `protocols/`.
|
|
@@ -29,5 +62,5 @@ The first release of this codebase. What ships:
|
|
|
29
62
|
|
|
30
63
|
## 0.1.1
|
|
31
64
|
|
|
32
|
-
An unrelated earlier package that held the name on npm (
|
|
65
|
+
An unrelated earlier package that held the name on npm (2020). Not an
|
|
33
66
|
ancestor of this code.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -6,6 +6,14 @@ and every one of them was reviewed, authored, committed, and merged by a
|
|
|
6
6
|
human. This document says what that means for anyone touching the
|
|
7
7
|
repository, human or agent.
|
|
8
8
|
|
|
9
|
+
## Issues and pull requests
|
|
10
|
+
|
|
11
|
+
Brane is not a product, and its maintainer makes no support promise.
|
|
12
|
+
Issues are open: bug reports and ideas are welcome there, and may or may
|
|
13
|
+
not get an answer. Pull requests are limited to the maintainer; if you
|
|
14
|
+
have a fix, describe it in an issue. Vulnerabilities go through private
|
|
15
|
+
reporting instead, as `SECURITY.md` describes.
|
|
16
|
+
|
|
9
17
|
## How changes land
|
|
10
18
|
|
|
11
19
|
- `main` takes pull requests only. The `protect-main-branch` ruleset
|
|
@@ -58,7 +66,8 @@ Responsibility for a change belongs to a person. Concretely:
|
|
|
58
66
|
the work under the project's license. A model cannot hold copyright,
|
|
59
67
|
enter an agreement, or take responsibility, so it cannot certify
|
|
60
68
|
anything, and an agent must not write a sign-off on a human's behalf.
|
|
61
|
-
- **Outside contributors sign off
|
|
69
|
+
- **Outside contributors sign off**, should outside pull requests open
|
|
70
|
+
(for now they are maintainer-only; see above). Add
|
|
62
71
|
`Signed-off-by: Your Name <you@example.com>` to each commit
|
|
63
72
|
(`git commit -s`), certifying the
|
|
64
73
|
[Developer Certificate of Origin 1.1](https://developercertificate.org/).
|
|
@@ -82,7 +91,8 @@ that way, including code whose origin you cannot account for.
|
|
|
82
91
|
|
|
83
92
|
## Security
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
Report a vulnerability privately, as `SECURITY.md` describes, not in an
|
|
95
|
+
issue.
|
|
86
96
|
|
|
87
97
|
## For agents
|
|
88
98
|
|
package/DESIGN.md
CHANGED
|
@@ -282,10 +282,13 @@ a manual escape hatch for forcing an immediate re-provision.
|
|
|
282
282
|
|
|
283
283
|
### CORS
|
|
284
284
|
|
|
285
|
-
The daemon
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
285
|
+
The daemon answers any origin with `Access-Control-Allow-Origin: *` and no
|
|
286
|
+
credentials. There is no allowlist: every API request and WebSocket upgrade
|
|
287
|
+
must present a token, so a page holding none gets a 401 whatever its origin,
|
|
288
|
+
and the hostname the app is served from (`brane.organelle.dev`, a staging
|
|
289
|
+
host, a fork's deployment, the daemon's own bundle, `localhost` in dev) is
|
|
290
|
+
not a trust decision. The origin each device runs the app from is recorded
|
|
291
|
+
on its token for the Devices list instead (`SECURITY.md`, Authentication).
|
|
289
292
|
|
|
290
293
|
## Web App (Expo)
|
|
291
294
|
|
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ CLI whenever that calculus changes for you.
|
|
|
164
164
|
|
|
165
165
|
## Contributing
|
|
166
166
|
|
|
167
|
-
Brane is developed by one maintainer working with coding agents. Commits an agent writes carry a disclosure trailer; responsibility for every change stays with a human, who reviews, signs off, and merges it. Sign-off is human-only: an agent never certifies a contribution. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules, including the DCO sign-off
|
|
167
|
+
Brane is developed by one maintainer working with coding agents. Commits an agent writes carry a disclosure trailer; responsibility for every change stays with a human, who reviews, signs off, and merges it. Sign-off is human-only: an agent never certifies a contribution. Issues are open; pull requests are maintainer-only. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules, including the DCO sign-off, and for working from a clone.
|
|
168
168
|
|
|
169
169
|
## License
|
|
170
170
|
|
|
@@ -172,4 +172,4 @@ GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See [LICENS
|
|
|
172
172
|
|
|
173
173
|
Brane is designed to be run as a standalone daemon by individual operators, not embedded into other products. The AGPL's network-use clause ensures that anyone who modifies brane and offers it as a hosted service must share their modifications back.
|
|
174
174
|
|
|
175
|
-
> Note: the `brane`
|
|
175
|
+
> Note: the `brane` name on npm previously carried unrelated MIT-licensed packages (0.0.x in 2015, 0.1.1 in 2020). This codebase is a complete rewrite under AGPL-3.0-or-later, first published as 0.3.0.
|
package/SECURITY.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Brane is a local-first system. Your data never leaves your machine unless you explicitly connect a device.
|
|
4
4
|
|
|
5
|
+
## Reporting a vulnerability
|
|
6
|
+
|
|
7
|
+
Report it privately, through GitHub's [private vulnerability reporting](https://github.com/benjamn/brane/security/advisories/new) (the repository's **Security** tab, then **Report a vulnerability**). Only the maintainer sees the report. Don't open an issue for it: issues are public.
|
|
8
|
+
|
|
9
|
+
Brane has one maintainer and no support commitment, so there is no promised response time. The report that gets fixed fastest says which version (`brane --version`), what the attacker needs to start with (a tailnet peer, a process on the machine, a melded device, an agent session), and what they end up with.
|
|
10
|
+
|
|
5
11
|
## Network isolation by default
|
|
6
12
|
|
|
7
13
|
The daemon binds **only to loopback and Tailscale addresses**. The listen sockets themselves are the first line of defense: by default, the daemon is attached only to `127.0.0.1`, `::1`, and whatever IPs `tailscale status --json` reports (typically one IPv4 and one IPv6 in the 100.x / fd7a::/8 range). **The machine's LAN IP is not bound.** Same-subnet port scans see nothing on the daemon's port — the kernel rejects those SYNs with RST before any userspace code runs.
|
|
@@ -44,6 +50,8 @@ To opt in to LAN reach on a trusted home network, pass `brane meld --host <lan-i
|
|
|
44
50
|
|
|
45
51
|
Every API and WebSocket request over TCP requires a 128-bit UUID bearer token, passed via `Authorization: Bearer` header or query parameter. Brute-forcing 128 bits of entropy is not feasible. The one exception is a request over the daemon's Unix socket, which needs no token and acts as this machine's account (below).
|
|
46
52
|
|
|
53
|
+
**The token is the only credential; the origin is reported, never gated.** The daemon does not care which hostname serves the app: `brane.organelle.dev`, a staging hostname, a fork's own deployment, a daemon's own bundle, or a page on a laptop's `localhost`. A page holding no token gets a 401 whatever its origin. A page holding one can *act* with it from any origin: the token is accepted in `?token=` on any method, and request bodies are parsed as JSON whatever their `Content-Type`, so a `text/plain` POST, which needs no preflight, signals or spawns from any origin regardless. An origin allowlist would only hide responses and refuse WebSockets, never block a write, and the browser is the bridge across the IP filter that `curl` off the tailnet does not have. So the only honest boundary is the token. CORS therefore answers `*` with no credentials (brane sets no cookies, and `Allow-Credentials` with a reflected origin is the one CORS combination that could ever be dangerous), and the WebSocket upgrade checks the token and nothing else. What the origin *is* for is the Devices list: the first `Origin` a token is presented from, and the latest, are recorded on its row (`app_origin`, `last_app_origin`) and shown as "via main.brane.organelle.dev", so you can see which copy of the app each device runs. The one request that still looks at `Origin` is a tokenless one on the Unix socket, below, where its presence is what marks a browser behind a relay.
|
|
54
|
+
|
|
47
55
|
**Tokens are stored as hashes only.** The `tokens` table holds the SHA-256 of each token, never the token, so the daemon's own record of who may connect authenticates nothing by itself, and the daemon cannot print a token back. Each melded device gets its own token, minted when its QR is made, whether by `brane meld` or by the app's Meld item; that QR is the only copy. The device showing an in-app Meld QR never hands over its own token, and opening the sheet mints a fresh one each time. A minted token nobody uses within 10 minutes lapses, so an unscanned or photographed QR stops working. This machine's CLI and MCP bridges hold no token: they reach the daemon over its Unix socket, `~/.brane/daemon.sock`, and present nothing. `~/.brane` is `0700` and the database is `0600`, so other accounts on the machine can read none of it, and cannot connect to the socket either: reaching it takes search permission on that directory. That is why a request over the socket needs no token: only this account can have sent it, and it acts as this machine's `local` row, which no token opens. This machine's standing therefore exists only on the socket and cannot be copied off the machine. Older branes kept a local token in `~/.brane/.daemon_token` instead, which worked from any address the IP filter admits; the daemon revokes it at its first start and empties the file, and `brane doctor` reports the empty leftover, which is yours to remove. Where the daemon cannot listen on the socket (a `~/.brane` path too long for one, a filesystem that cannot hold one), the shell CLI cannot reach it and says why; agents and devices, which hold tokens, still can. The daemon checks the directory is still owned by it and closed to group and other before every such request, and refuses them if it is not. It also refuses a tokenless request that carries an `Origin` header or a `Host` other than `localhost`, which is what a browser's request looks like when something relays a port to the socket (`ssh -L`, socat, a devcontainer forward). Even so, a relay to `daemon.sock` extends "this account" to whoever can reach the relay: don't run one.
|
|
48
56
|
|
|
49
57
|
**What hashing does not cover.** Two things.
|
package/dist/commands/config.js
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { openDatabase } from "../db/schema.js";
|
|
3
|
+
import { cleanAppHost } from "../shared/app-origin.js";
|
|
3
4
|
// Well-known config keys and their defaults
|
|
4
5
|
export const CONFIG_DEFAULTS = {
|
|
6
|
+
// The hostname that serves the shared app: where `brane meld` points a QR
|
|
7
|
+
// unless `--app` says otherwise, and what `GET /api/status` reports so a
|
|
8
|
+
// daemon's own bundle knows where Isolate hops out to. Never a trust
|
|
9
|
+
// decision: the daemon accepts any origin (SECURITY.md, Authentication).
|
|
10
|
+
// A fork changes this one line, and its daemons point at its own shell.
|
|
5
11
|
app_domain: "brane.organelle.dev",
|
|
6
12
|
"session-affinity.enabled": "true",
|
|
7
13
|
};
|
|
14
|
+
/** The shared app's host from a configured or given `app_domain` value,
|
|
15
|
+
* reduced to a host (`cleanAppHost`); a blank one means the default, so
|
|
16
|
+
* `brane config app_domain ""` or `brane meld --app ""` can never make a QR
|
|
17
|
+
* for `https:///`. Every reader applies this one rule — `brane meld`
|
|
18
|
+
* (`buildMeldUrl`) and `GET /api/status` (`statusAppDomain`) — so a daemon's
|
|
19
|
+
* bundle and the QR always agree on where the shared app is. Lives here,
|
|
20
|
+
* beside the default it falls back to, so the daemon need not import the
|
|
21
|
+
* meld command to read it. */
|
|
22
|
+
export function resolveAppHost(value) {
|
|
23
|
+
return cleanAppHost(value ?? "") || cleanAppHost(CONFIG_DEFAULTS.app_domain);
|
|
24
|
+
}
|
|
8
25
|
/** Reads a config key. Pass the caller's `db` whenever it has one: the daemon
|
|
9
26
|
* and every test do. Without it this opens `~/.brane/brane.db` itself — right
|
|
10
27
|
* for a CLI command, and exactly wrong anywhere else. That fallback is how a
|
package/dist/commands/meld.js
CHANGED
|
@@ -4,6 +4,8 @@ import qrcode from "qrcode-terminal";
|
|
|
4
4
|
import { getDaemonAddress, openDatabase } from "../db/schema.js";
|
|
5
5
|
import { getTailscaleHost } from "../tailscale.js";
|
|
6
6
|
import { connectionFragment } from "../shared/view-url.js";
|
|
7
|
+
import { CONFIG_DEFAULTS, getConfig, resolveAppHost } from "./config.js";
|
|
8
|
+
import { cleanAppHost } from "../shared/app-origin.js";
|
|
7
9
|
import { UNUSED_TOKEN_LAPSE_MS } from "../daemon/token-ring.js";
|
|
8
10
|
import { daemonRequest, resolveDaemonEndpoint, warnIfVersionMismatch, } from "../daemon/client.js";
|
|
9
11
|
/** What the CLI prints before a request the daemon holds for the presence
|
|
@@ -64,8 +66,14 @@ export function buildMeldUrl(opts) {
|
|
|
64
66
|
let path = opts.path ?? "/";
|
|
65
67
|
if (!path.startsWith("/"))
|
|
66
68
|
path = "/" + path;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// Where the shared app lives is config, never a literal: a fork sets its
|
|
70
|
+
// own default (CONFIG_DEFAULTS), an operator sets `app_domain`, and a
|
|
71
|
+
// one-off `--app` wins over both. The daemon trusts none of them — the
|
|
72
|
+
// token is the credential — so this only decides which copy of the app
|
|
73
|
+
// the phone loads.
|
|
74
|
+
const appHost = resolveAppHost(opts.app ?? getConfig("app_domain", db));
|
|
75
|
+
const url = meldLink({ daemonHost, scheme, useLocal, appHost }, token, path);
|
|
76
|
+
return { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded };
|
|
69
77
|
}
|
|
70
78
|
/** A meld URL for `token`, at the daemon `parts` describes. */
|
|
71
79
|
export function meldLink(parts, token, path = "/") {
|
|
@@ -75,9 +83,12 @@ export function meldLink(parts, token, path = "/") {
|
|
|
75
83
|
// the daemon's own request log does not record it. The app reads it from
|
|
76
84
|
// `location.hash` and strips it once it has been used.
|
|
77
85
|
const conn = { host: parts.daemonHost, token };
|
|
86
|
+
// A remote brane from before `appHost` was part of the connection reply
|
|
87
|
+
// (readMeldReply) names no shell; this install's default stands in.
|
|
88
|
+
const appHost = parts.appHost ?? CONFIG_DEFAULTS.app_domain;
|
|
78
89
|
return parts.useLocal
|
|
79
90
|
? `${parts.scheme}://${parts.daemonHost}${path}${connectionFragment(conn, false)}`
|
|
80
|
-
: `https
|
|
91
|
+
: `https://${appHost}${path}${connectionFragment(conn, true)}`;
|
|
81
92
|
}
|
|
82
93
|
/** Ask this machine's daemon for a device token for a daemon on another
|
|
83
94
|
* machine, and its hash. The daemon stores neither, and first asks the
|
|
@@ -117,8 +128,16 @@ export function readMeldReply(output) {
|
|
|
117
128
|
continue;
|
|
118
129
|
if (r.isLoopbackFallback)
|
|
119
130
|
return { kind: "wait", reason: "the remote machine has no Tailscale name yet" };
|
|
120
|
-
const { ready: _ready, ...connection } = r;
|
|
121
|
-
|
|
131
|
+
const { ready: _ready, appHost, ...connection } = r;
|
|
132
|
+
// A remote that names its shell does so as a string; anything else is
|
|
133
|
+
// treated as unnamed, and meldLink falls back to this install's default.
|
|
134
|
+
return {
|
|
135
|
+
kind: "address",
|
|
136
|
+
connection: {
|
|
137
|
+
...connection,
|
|
138
|
+
...(typeof appHost === "string" && appHost ? { appHost: cleanAppHost(appHost) } : {}),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
122
141
|
}
|
|
123
142
|
return { kind: "old" };
|
|
124
143
|
}
|
|
@@ -216,13 +235,17 @@ async function meldRemote(args) {
|
|
|
216
235
|
console.error("Run this again once it's up; the token sent this time was never shown, so nothing is lost.");
|
|
217
236
|
process.exit(1);
|
|
218
237
|
}
|
|
219
|
-
|
|
238
|
+
// The remote daemon's `app_domain` names the shell, unless --app does.
|
|
239
|
+
const link = args.app ? { ...connection, appHost: cleanAppHost(args.app) } : connection;
|
|
240
|
+
const url = meldLink(link, token);
|
|
220
241
|
console.log(`\nScan this QR code to meld:\n`);
|
|
221
242
|
qrcode.generate(url, { small: args.small ?? true }, (code) => {
|
|
222
243
|
console.log(code);
|
|
223
244
|
});
|
|
224
245
|
console.log(`\n${url}\n`);
|
|
225
246
|
console.log(`Daemon: ${connection.daemonHost} (${connection.scheme})`);
|
|
247
|
+
if (!connection.useLocal)
|
|
248
|
+
console.log(`App: ${link.appHost ?? CONFIG_DEFAULTS.app_domain}`);
|
|
226
249
|
// The clock started with the remote daemon's restart, not now.
|
|
227
250
|
console.log(`This QR is valid for ${UNUSED_TOKEN_LAPSE_MS / 60_000} minutes from the remote daemon's start; once scanned, the device stays signed in until revoked.`);
|
|
228
251
|
if (connection.tlsDegraded) {
|
|
@@ -245,7 +268,11 @@ export const meldCommand = defineCommand({
|
|
|
245
268
|
},
|
|
246
269
|
local: {
|
|
247
270
|
type: "boolean",
|
|
248
|
-
description: "Generate direct daemon URL instead of
|
|
271
|
+
description: "Generate a direct daemon URL instead of the shared app's (see --app)",
|
|
272
|
+
},
|
|
273
|
+
app: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description: "The shared app's host to point the QR at (default: the `app_domain` config, brane.organelle.dev unless set)",
|
|
249
276
|
},
|
|
250
277
|
connection: {
|
|
251
278
|
type: "boolean",
|
|
@@ -278,7 +305,7 @@ export const meldCommand = defineCommand({
|
|
|
278
305
|
try {
|
|
279
306
|
const address = getDaemonAddress(db);
|
|
280
307
|
const reply = address
|
|
281
|
-
? (({ url: _url, ...connection }) => ({ ready: true, ...connection }))(buildMeldUrl({ db, daemon: { port: address.port, token: "" }, host: args.host, local: args.local }))
|
|
308
|
+
? (({ url: _url, ...connection }) => ({ ready: true, ...connection }))(buildMeldUrl({ db, daemon: { port: address.port, token: "" }, host: args.host, local: args.local, app: args.app }))
|
|
282
309
|
: { ready: false, reason: "no daemon is running yet" };
|
|
283
310
|
console.log(JSON.stringify(reply));
|
|
284
311
|
}
|
|
@@ -300,11 +327,12 @@ export const meldCommand = defineCommand({
|
|
|
300
327
|
}
|
|
301
328
|
const daemon = { port: endpoint.port, token };
|
|
302
329
|
const db = openDatabase();
|
|
303
|
-
const { url, daemonHost, scheme, useLocal, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
|
|
330
|
+
const { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
|
|
304
331
|
db,
|
|
305
332
|
daemon,
|
|
306
333
|
host: args.host,
|
|
307
334
|
local: args.local,
|
|
335
|
+
app: args.app,
|
|
308
336
|
});
|
|
309
337
|
db.close();
|
|
310
338
|
console.log(`\nScan this QR code to meld:\n`);
|
|
@@ -314,7 +342,7 @@ export const meldCommand = defineCommand({
|
|
|
314
342
|
console.log(`\n${url}\n`);
|
|
315
343
|
console.log(`Daemon: ${daemonHost} (${scheme})`);
|
|
316
344
|
if (!useLocal) {
|
|
317
|
-
console.log(`App:
|
|
345
|
+
console.log(`App: ${appHost}`);
|
|
318
346
|
}
|
|
319
347
|
console.log(meldLapseNote());
|
|
320
348
|
if (isLoopbackFallback) {
|
package/dist/commands/spawn.js
CHANGED
|
@@ -929,7 +929,9 @@ export function doSpawn(opts) {
|
|
|
929
929
|
return { narrativeId, foldPath, agentType: agent, role };
|
|
930
930
|
}
|
|
931
931
|
/** Set up MCP config and build launch command for an agent in a fold.
|
|
932
|
-
*
|
|
932
|
+
* Nothing calls this: switching agents on revive goes through
|
|
933
|
+
* `doSpawn({ re })`, and a same-agent revive builds its own launch in
|
|
934
|
+
* `reviveNarrative`, which has to refresh hook and affinity config itself. */
|
|
933
935
|
export function prepareAgentLaunch(db, agent, narrativeId, foldPath) {
|
|
934
936
|
const agentCommand = resolveAgentCommand(agent);
|
|
935
937
|
const absoluteFoldPath = fromPortablePath(foldPath);
|
|
@@ -1017,7 +1019,11 @@ export const spawnCommand = defineCommand({
|
|
|
1017
1019
|
},
|
|
1018
1020
|
"meld-local": {
|
|
1019
1021
|
type: "boolean",
|
|
1020
|
-
description: "Use a direct daemon URL in the meld output instead of
|
|
1022
|
+
description: "Use a direct daemon URL in the meld output instead of the shared app's",
|
|
1023
|
+
},
|
|
1024
|
+
"meld-app": {
|
|
1025
|
+
type: "string",
|
|
1026
|
+
description: "The shared app's host for the meld output (default: the `app_domain` config)",
|
|
1021
1027
|
},
|
|
1022
1028
|
},
|
|
1023
1029
|
async run({ args }) {
|
|
@@ -1053,12 +1059,13 @@ export const spawnCommand = defineCommand({
|
|
|
1053
1059
|
console.log(` re: ${args.re}`);
|
|
1054
1060
|
if (deviceToken) {
|
|
1055
1061
|
const meldDb = openDatabase();
|
|
1056
|
-
const { url, daemonHost, scheme, useLocal, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
|
|
1062
|
+
const { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
|
|
1057
1063
|
db: meldDb,
|
|
1058
1064
|
daemon: { port: endpoint.port, token: deviceToken },
|
|
1059
1065
|
path: `/${narrative.id}`,
|
|
1060
1066
|
host: args["meld-host"],
|
|
1061
1067
|
local: args["meld-local"],
|
|
1068
|
+
app: args["meld-app"],
|
|
1062
1069
|
});
|
|
1063
1070
|
meldDb.close();
|
|
1064
1071
|
console.log(`\nScan this QR code to open ${narrative.id}:\n`);
|
|
@@ -1068,7 +1075,7 @@ export const spawnCommand = defineCommand({
|
|
|
1068
1075
|
console.log(`\n${url}\n`);
|
|
1069
1076
|
console.log(`Daemon: ${daemonHost} (${scheme})`);
|
|
1070
1077
|
if (!useLocal)
|
|
1071
|
-
console.log(`App:
|
|
1078
|
+
console.log(`App: ${appHost}`);
|
|
1072
1079
|
console.log(meldLapseNote());
|
|
1073
1080
|
if (isLoopbackFallback) {
|
|
1074
1081
|
console.log("\nNo Tailscale detected — this URL only works from this machine.");
|
package/dist/commands/token.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { resolveDaemonEndpoint, daemonRequest, warnIfVersionMismatch, } from "../daemon/client.js";
|
|
3
3
|
import { announcePresenceWait } from "./meld.js";
|
|
4
|
+
import { originHost } from "../shared/app-origin.js";
|
|
4
5
|
/** Sign out every device. One request: this shell holds no token, so there
|
|
5
6
|
* is none to swap and nothing to confirm — once the operator passes the
|
|
6
7
|
* presence prompt the daemon revokes every device token before it answers
|
|
@@ -67,7 +68,7 @@ export function formatTokenList(tokens) {
|
|
|
67
68
|
: "never used";
|
|
68
69
|
const lines = [
|
|
69
70
|
`${t.id} ${name}${marks.length ? ` [${marks.join("; ")}]` : ""}`,
|
|
70
|
-
` "${t.label}", created ${formatTime(t.created_at)}${t.minted_by_device ? ` by ${t.minted_by_device}` : ""}, ${seen}${t.remote_addr ? `, from ${t.remote_addr}` : ""}`,
|
|
71
|
+
` "${t.label}", created ${formatTime(t.created_at)}${t.minted_by_device ? ` by ${t.minted_by_device}` : ""}, ${seen}${t.remote_addr ? `, from ${t.remote_addr}` : ""}${t.app_origin ? `, via ${originHost(t.app_origin)}` : ""}`,
|
|
71
72
|
];
|
|
72
73
|
if (t.descendants?.length)
|
|
73
74
|
lines.push(` added ${t.descendants.length} device(s): ${t.descendants.join(", ")} (revoke --cascade takes them too)`);
|