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.
Files changed (39) hide show
  1. package/AGENTS.md +12 -7
  2. package/CHANGELOG.md +37 -4
  3. package/CONTRIBUTING.md +12 -2
  4. package/DESIGN.md +7 -4
  5. package/README.md +2 -2
  6. package/SECURITY.md +8 -0
  7. package/dist/commands/config.js +17 -0
  8. package/dist/commands/meld.js +38 -10
  9. package/dist/commands/spawn.js +11 -4
  10. package/dist/commands/token.js +2 -1
  11. package/dist/daemon/server.js +88 -84
  12. package/dist/db/schema.js +10 -0
  13. package/dist/db/tokens.js +43 -2
  14. package/dist/shared/app-origin.js +26 -0
  15. package/dist/shared/narrative-menu.js +8 -6
  16. package/dist/shared/view-url.js +39 -20
  17. package/dist/test/app-host-literal.test.js +53 -0
  18. package/dist/test/cors-preflight.test.js +28 -21
  19. package/dist/test/hook-config.test.js +38 -1
  20. package/dist/test/meld.test.js +59 -1
  21. package/dist/test/narrative-menu.test.js +13 -0
  22. package/dist/test/remote-meld.test.js +13 -0
  23. package/dist/test/terminal-size.test.js +36 -0
  24. package/dist/test/token-devices.test.js +59 -0
  25. package/dist/test/view-url.test.js +93 -47
  26. package/dist/test/wrangler-route.test.js +27 -0
  27. package/dist/tmux.js +11 -0
  28. package/docker-entrypoint.sh +15 -2
  29. package/docs/daemon-api.md +4 -4
  30. package/docs/releasing.md +59 -8
  31. package/docs/terminal-mirroring.md +32 -0
  32. package/docs/token-rotation.md +22 -4
  33. package/docs/view-urls.md +26 -6
  34. package/docs/web-app.md +1 -1
  35. package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-e94247f119fdcd3b424a1764233a41d2.js} +47 -45
  36. package/expo/dist/index.html +1 -1
  37. package/expo/dist/sw.js +2 -2
  38. package/package.json +1 -1
  39. package/wiki/shared/index.md +5 -0
@@ -24,8 +24,8 @@ import { signalEventFields, signalStanding } from "./signal-source.js";
24
24
  import { AGENT_LOOPBACK_REFUSAL, AGENT_REVIVE_REFUSAL, agentRefusal, agentReviveRefusal } from "./agent-scope.js";
25
25
  import { DbTokenStore, bootstrapTokens, legacyTokenWarning, parseTokenHashes, liveAgentTokenId, mintAgentToken, recordLauncher, settleAgentLaunch, verifyAgentToken } from "../db/tokens.js";
26
26
  import { closeSocketsOf, listTokensRoute, socketOwners, retireTokens } from "./token-routes.js";
27
- import { getConfig } from "../commands/config.js";
28
- import { doSpawn, buildGooseExtensionArg, reestablishMcpConfigArgs, braneTmuxEnv, resolveAgentCommand, LIVE_LOOP_PROMPT } from "../commands/spawn.js";
27
+ import { getConfig, resolveAppHost } from "../commands/config.js";
28
+ import { doSpawn, buildGooseExtensionArg, reestablishMcpConfigArgs, braneTmuxEnv, ensureHookConfig, resolveAgentCommand, LIVE_LOOP_PROMPT } from "../commands/spawn.js";
29
29
  import { ensureSessionAffinityConfig } from "../session-affinity.js";
30
30
  import { revivedContent, restartedContent } from "../shared/narrative-format.js";
31
31
  import { parseRecolorSalt } from "../shared/speaker-color.js";
@@ -47,7 +47,7 @@ import { getTailscaleIps, getTailscaleIpsAsync } from "../tailscale.js";
47
47
  import { addressesToBind, nextProbeDelay, TAILSCALE_PROBE_MAX_MS } from "./tailscale-rebind.js";
48
48
  import { CertManager } from "./cert-manager.js";
49
49
  import { KeepAwake } from "./keep-awake.js";
50
- import { resolveTmux, ensureTmux, newTmuxSession } from "../tmux.js";
50
+ import { resolveTmux, ensureTmux, newTmuxSession, parseTerminalSize } from "../tmux.js";
51
51
  import { INHERITED_AGENT_ENV, unsetAgentEnvPrefix } from "../agent-env.js";
52
52
  import { highlight } from "../highlight.js";
53
53
  import { browseFold, openFoldSource, parseOffsetParam } from "../files/browse.js";
@@ -191,6 +191,13 @@ export function reviveNarrative(db, nid, opts = {}) {
191
191
  return { status: "continued", nid: result.narrativeId, agent: effectiveAgent };
192
192
  }
193
193
  // Same agent — use resume args
194
+ // Point the fold's tool-call hooks at this daemon before anything else can
195
+ // fail. The config names a port, and a daemon that came back on another
196
+ // one (its old port was taken, or a Fly boot cleared the hint) leaves it
197
+ // naming a dead one: every tool call then fails its hook, and the chat
198
+ // stops showing tool rows. doSpawn does this for fresh sessions; a revive
199
+ // builds its launch here, so it has to as well (claude-nkrks0of:23).
200
+ ensureHookConfig(db, effectiveAgent, absoluteFoldPath, nid);
194
201
  const agentCommand = resolveAgentCommand(effectiveAgent);
195
202
  // The revived session's agent token. Minted with the previous one left
196
203
  // live until the launch succeeds (settleAgentLaunch, below): a revive that
@@ -306,74 +313,47 @@ export function reviveNarrative(db, nid, opts = {}) {
306
313
  }
307
314
  return { status: "revived", nid, agent: effectiveAgent };
308
315
  }
309
- function isLocalhostOrigin(origin) {
310
- try {
311
- const url = new URL(origin);
312
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
313
- }
314
- catch {
315
- return false;
316
- }
317
- }
318
- function isAppDomainOrigin(origin, appDomain) {
319
- try {
320
- const url = new URL(origin);
321
- // Match exact domain or any subdomain
322
- return url.hostname === appDomain || url.hostname.endsWith(`.${appDomain}`);
323
- }
324
- catch {
325
- return false;
326
- }
327
- }
328
- function isSameOrigin(origin, ...serverPorts) {
329
- try {
330
- const url = new URL(origin);
331
- const originPort = parseInt(url.port, 10);
332
- return serverPorts.some((p) => originPort === p);
333
- }
334
- catch {
335
- return false;
336
- }
337
- }
338
- function isTailscaleOrigin(origin) {
339
- try {
340
- const url = new URL(origin);
341
- return url.hostname.endsWith(".ts.net");
342
- }
343
- catch {
344
- return false;
345
- }
346
- }
347
- function isAllowedOrigin(origin, appDomain, ...serverPorts) {
348
- if (isLocalhostOrigin(origin))
349
- return true;
350
- if (isSameOrigin(origin, ...serverPorts))
351
- return true;
352
- if (isAppDomainOrigin(origin, appDomain))
353
- return true;
354
- if (isTailscaleOrigin(origin))
355
- return true;
356
- return false;
316
+ /** What `GET /api/status` reports as `app_domain`: the config, reduced to a
317
+ * host exactly as `brane meld` reduces it (`resolveAppHost`), so an operator
318
+ * who stored it as a URL (`brane config app_domain https://staging.example/`)
319
+ * gets a working Isolate link from a daemon's bundle rather than
320
+ * `https://https://…`, and a blank value means the default on both sides.
321
+ * The app reads a missing field, from a daemon older than this, as "no way
322
+ * out". */
323
+ export function statusAppDomain(db) {
324
+ return resolveAppHost(getConfig("app_domain", db));
357
325
  }
358
- export function setCorsHeaders(req, res, appDomain, ...serverPorts) {
359
- const origin = req.headers.origin;
360
- if (origin && isAllowedOrigin(origin, appDomain, ...serverPorts)) {
361
- res.setHeader("Access-Control-Allow-Origin", origin);
362
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
363
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
364
- res.setHeader("Access-Control-Allow-Credentials", "true");
365
- // How long the browser may reuse this preflight's answer. Without it
366
- // Chrome caches for 5 seconds, which on a fast link is indistinguishable
367
- // from caching forever and on a slow one is indistinguishable from not
368
- // caching at all: nearly every request pays its own extra round trip.
369
- // Measured at 741ms RTT that was fatal rather than merely slow — see the
370
- // note on `apiUrl` in expo/lib/api.ts. The bodiless requests avoid the
371
- // preflight entirely now; this is for the ones that carry JSON and can't.
372
- // A day is the practical ceiling (Chrome caps at 2h, Firefox 24h), and
373
- // the answer being cached is safe: it does not authenticate anything,
374
- // it only says which methods and headers this origin may use.
375
- res.setHeader("Access-Control-Max-Age", "86400");
376
- }
326
+ /** CORS for a browser on any origin. The token is the only credential, so
327
+ * the origin is not a trust decision: a page holding no token gets a 401
328
+ * whatever its origin, and a page holding one can act with it from any
329
+ * origin regardless of what is answered here — `?token=` is taken on any
330
+ * method and bodies are parsed as JSON whatever their `Content-Type`, so a
331
+ * `text/plain` POST needs no preflight. An origin allowlist would only
332
+ * hide responses and refuse WebSockets. So the answer is `*`, never a
333
+ * reflected origin, and never `Allow-Credentials`: brane sets no cookies,
334
+ * and credentials with a reflected origin is the one CORS combination that
335
+ * could ever be dangerous. This is what lets one daemon be fronted
336
+ * by `brane.organelle.dev`, a staging hostname, a fork's own deployment, or
337
+ * its own bundle, with nothing to configure. Which origin a device actually
338
+ * runs is recorded on its token and shown in the Devices list, not gated
339
+ * here (SECURITY.md, "Authentication"). */
340
+ export function setCorsHeaders(req, res) {
341
+ if (req.headers.origin === undefined)
342
+ return;
343
+ res.setHeader("Access-Control-Allow-Origin", "*");
344
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
345
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
346
+ // How long the browser may reuse this preflight's answer. Without it
347
+ // Chrome caches for 5 seconds, which on a fast link is indistinguishable
348
+ // from caching forever and on a slow one is indistinguishable from not
349
+ // caching at all: nearly every request pays its own extra round trip.
350
+ // Measured at 741ms RTT that was fatal rather than merely slow — see the
351
+ // note on `apiUrl` in expo/lib/api.ts. The bodiless requests avoid the
352
+ // preflight entirely now; this is for the ones that carry JSON and can't.
353
+ // A day is the practical ceiling (Chrome caps at 2h, Firefox 24h), and
354
+ // the answer being cached is safe: it does not authenticate anything,
355
+ // it only says which methods and headers a page may use.
356
+ res.setHeader("Access-Control-Max-Age", "86400");
377
357
  }
378
358
  /** Below this, compressing costs more than it saves — a gzip header is 20-odd
379
359
  * bytes and the CPU is not free. Above it, JSON of brane's shape compresses
@@ -616,9 +596,11 @@ export function isLoopbackRequest(req) {
616
596
  * list can tell rows apart. */
617
597
  export function authenticateRequest(ring, presented, req, braneDir, onTrust, now = Date.now()) {
618
598
  const userAgent = req.headers["user-agent"];
599
+ const origin = req.headers.origin;
619
600
  const seen = {
620
601
  userAgent: typeof userAgent === "string" ? userAgent : null,
621
602
  remoteAddr: connectionLabel(req.socket),
603
+ origin: typeof origin === "string" ? origin : null,
622
604
  };
623
605
  if (!presented && isUnixConnection(req.socket)) {
624
606
  const refusal = tokenlessRefusal(req, braneDir);
@@ -794,7 +776,6 @@ export async function startServer(config) {
794
776
  return refusal;
795
777
  };
796
778
  const braneId = getBraneId(db);
797
- const appDomain = getConfig("app_domain", db) ?? "brane.organelle.dev";
798
779
  let actualPort = port; // updated after bind when port is 0
799
780
  let actualHttpPort = 0; // HTTP fallback port (set when httpServer binds)
800
781
  // TLS cert lifecycle. CertManager owns provisioning, renewal, fs.watch,
@@ -1175,7 +1156,7 @@ export async function startServer(config) {
1175
1156
  // response — including the early auth and error exits — can see whether
1176
1157
  // the client accepts gzip.
1177
1158
  trackRequest(res, req);
1178
- setCorsHeaders(req, res, appDomain, actualPort, actualHttpPort);
1159
+ setCorsHeaders(req, res);
1179
1160
  // Handle CORS preflight
1180
1161
  if (req.method === "OPTIONS") {
1181
1162
  res.writeHead(204);
@@ -1300,6 +1281,12 @@ export async function startServer(config) {
1300
1281
  // Listening on the address over HTTPS, but the HTTP fallback (the
1301
1282
  // local/LAN path) is not up there yet.
1302
1283
  fallback_missing: [...needFallback],
1284
+ // The hostname that serves the shared app: where `brane meld` points
1285
+ // a QR, and where a daemon's own bundle offers to hop *out* to
1286
+ // (Isolate's way back). The app reads it from here rather than
1287
+ // carrying a host of its own, so a fork's daemon names the fork's
1288
+ // shell (src/shared/view-url.ts).
1289
+ app_domain: statusAppDomain(db),
1303
1290
  });
1304
1291
  return;
1305
1292
  }
@@ -2016,7 +2003,7 @@ export async function startServer(config) {
2016
2003
  // Route: POST /api/spawn
2017
2004
  if (path === "/api/spawn" && req.method === "POST") {
2018
2005
  parseBody(req).then(async (body) => {
2019
- const { agent, role, model, re, fold_path, extra_args } = JSON.parse(body);
2006
+ const { agent, role, model, re, fold_path, extra_args, terminal_size } = JSON.parse(body);
2020
2007
  if (!agent) {
2021
2008
  sendJson(res, 400, { error: "agent is required" });
2022
2009
  return;
@@ -2035,7 +2022,11 @@ export async function startServer(config) {
2035
2022
  extraArgs = extra_args;
2036
2023
  }
2037
2024
  await ensureTmux();
2038
- const result = doSpawn({ db, agent, role, model, re, foldPath: fold_path, extraArgs, terminalSize: lastTerminalSize ?? undefined });
2025
+ // Start at the requesting viewer's grid when it sent one: a session
2026
+ // resized right after launch redraws at each width, and the stale
2027
+ // frames land in the scrollback the mirror shows.
2028
+ const terminalSize = parseTerminalSize(terminal_size) ?? lastTerminalSize ?? undefined;
2029
+ const result = doSpawn({ db, agent, role, model, re, foldPath: fold_path, extraArgs, terminalSize });
2039
2030
  recordLauncher(db, result.narrativeId, auth.record.id);
2040
2031
  const connectedNids = mcpConnectedNids(mcpClients);
2041
2032
  sendJson(res, 200, {
@@ -2060,7 +2051,7 @@ export async function startServer(config) {
2060
2051
  // Route: POST /api/run
2061
2052
  if (path === "/api/run" && req.method === "POST") {
2062
2053
  parseBody(req).then(async (body) => {
2063
- const { command, fold_path } = JSON.parse(body);
2054
+ const { command, fold_path, terminal_size } = JSON.parse(body);
2064
2055
  if (!command || !Array.isArray(command) || command.length === 0) {
2065
2056
  sendJson(res, 400, { error: "command is required (array of strings)" });
2066
2057
  return;
@@ -2070,7 +2061,8 @@ export async function startServer(config) {
2070
2061
  return;
2071
2062
  }
2072
2063
  await ensureTmux();
2073
- const result = doRun({ db, command, foldPath: fold_path, terminalSize: lastTerminalSize ?? undefined });
2064
+ const terminalSize = parseTerminalSize(terminal_size) ?? lastTerminalSize ?? undefined;
2065
+ const result = doRun({ db, command, foldPath: fold_path, terminalSize });
2074
2066
  recordLauncher(db, result.narrativeId, auth.record.id);
2075
2067
  sendJson(res, 200, {
2076
2068
  narrative: {
@@ -2612,13 +2604,10 @@ export async function startServer(config) {
2612
2604
  }
2613
2605
  if (auth.retired)
2614
2606
  finishRotation({ retired: auth.retired });
2615
- // Origin check
2616
- const origin = req.headers.origin;
2617
- if (origin && !isAllowedOrigin(origin, appDomain, actualPort, actualHttpPort)) {
2618
- socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
2619
- socket.destroy();
2620
- return;
2621
- }
2607
+ // No Origin check: the token above is the credential, and the origin it
2608
+ // was presented from is now on its row for the Devices list to show
2609
+ // (`authenticateRequest`). Every browser sends one on an upgrade, so
2610
+ // this is the request that names which copy of the app a device runs.
2622
2611
  // WS: /ws/control — single real-time channel
2623
2612
  if (path === "/ws/control") {
2624
2613
  controlWss.handleUpgrade(req, socket, head, (ws) => {
@@ -2796,7 +2785,6 @@ export async function startServer(config) {
2796
2785
  console.warn("[socket] not listening: this machine's shell (the brane CLI outside an agent session) and new external MCP clients " +
2797
2786
  "cannot reach this daemon; agents and signed-in devices still can");
2798
2787
  }
2799
- console.log(`app domain: ${appDomain}`);
2800
2788
  console.log(`pid: ${process.pid}`);
2801
2789
  // Eagerly load the embedding model so first search is fast
2802
2790
  preloadEmbeddingModel();
@@ -6146,6 +6134,9 @@ function handleTerminalSocket(ws, nid) {
6146
6134
  case "mouse":
6147
6135
  sendMouseToTmux(nid, msg);
6148
6136
  break;
6137
+ case "clear_history":
6138
+ clearTerminalHistory(nid);
6139
+ break;
6149
6140
  case "refresh": {
6150
6141
  // Client requests a clean full frame. The client has just wiped its
6151
6142
  // <pre> DOM, so if we wait for `forceCapture()` alone the capture
@@ -6224,6 +6215,19 @@ function fitWindowToViewers(nid) {
6224
6215
  cap.appliedSize = { cols, rows };
6225
6216
  resizeTerminal(nid, cols, rows);
6226
6217
  }
6218
+ // The operator's "Clear scrollback": drop the pane's tmux history and leave
6219
+ // the live screen alone. Scrollback is where a TUI's stale redraws pile up —
6220
+ // Claude's trust dialog drawn at three widths before the session settled, its
6221
+ // full-width rules now wider than the pane — and the mirror shows all of it
6222
+ // (`capture-pane -S -`). Only ever on an explicit request, since it throws
6223
+ // that history away. The forced capture sends the shorter frame at once; the
6224
+ // client trims to `totalLines`, so the stale lines go with it.
6225
+ function clearTerminalHistory(nid) {
6226
+ execFile(resolveTmux(), ["clear-history", "-t", nid], { timeout: 2000 }, (err) => {
6227
+ if (!err)
6228
+ terminalCaptures.get(nid)?.forceCapture();
6229
+ });
6230
+ }
6227
6231
  function resizeTerminal(nid, cols, rows) {
6228
6232
  if (!cols || !rows || cols < 1 || rows < 1)
6229
6233
  return;
package/dist/db/schema.js CHANGED
@@ -439,6 +439,16 @@ export function applyMigrations(db) {
439
439
  if (!tokenCols.has("owner_nid")) {
440
440
  db.exec("ALTER TABLE tokens ADD COLUMN owner_nid TEXT");
441
441
  }
442
+ // The `Origin` a token was first presented from, and the latest: which
443
+ // copy of the app a device runs (a hosted one, or a daemon's own). The
444
+ // origin is reported, never gated — the token is the credential — so this
445
+ // is what lets the Devices list say "via main.brane.organelle.dev".
446
+ if (!tokenCols.has("app_origin")) {
447
+ db.exec("ALTER TABLE tokens ADD COLUMN app_origin TEXT");
448
+ }
449
+ if (!tokenCols.has("last_app_origin")) {
450
+ db.exec("ALTER TABLE tokens ADD COLUMN last_app_origin TEXT");
451
+ }
442
452
  // Which token's request last launched a narrative's process (spawn, run or
443
453
  // revive), so revoking a device can say which agents it started. Recorded
444
454
  // from now on; there is nothing to backfill it from.
package/dist/db/tokens.js CHANGED
@@ -10,6 +10,18 @@ import { hashToken, LEGACY_TOKEN_LABEL, UNUSED_TOKEN_LAPSE_MS, } from "../daemon
10
10
  /** A user agent is shown in a devices list and nowhere else; past this it is
11
11
  * noise, and an unbounded header is not something to keep forever. */
12
12
  const MAX_USER_AGENT_LENGTH = 512;
13
+ /** An `Origin` is `scheme://host[:port]`; anything longer than this is not
14
+ * one a browser sent. */
15
+ const MAX_ORIGIN_LENGTH = 256;
16
+ /** An `Origin` header as it may be stored. The literal `null` is what a
17
+ * browser sends for an opaque origin (a sandboxed iframe, a `file://` page,
18
+ * some cross-origin redirects): it names nothing, and stored it would read
19
+ * "via null" for good, since the first origin is never overwritten. So it
20
+ * counts as no origin at all. */
21
+ function cleanOrigin(value) {
22
+ const clean = cleanSighting(value, MAX_ORIGIN_LENGTH);
23
+ return clean === "null" ? null : clean;
24
+ }
13
25
  /** A header or address as it may be stored and shown: control characters
14
26
  * become spaces (a newline in a list is a forged row), capped, or null. */
15
27
  function cleanSighting(value, max) {
@@ -130,7 +142,7 @@ export function adoptBindTokens(db, now = Date.now(), only) {
130
142
  const TOUCH_INTERVAL_MS = 60_000;
131
143
  export { LEGACY_TOKEN_LABEL };
132
144
  const ROW_COLUMNS = "id, kind, label, origin, created_at, last_used_at, user_agent, remote_addr, " +
133
- "last_user_agent, last_remote_addr, elsewhere_at, minted_by, owner_nid";
145
+ "last_user_agent, last_remote_addr, app_origin, last_app_origin, elsewhere_at, minted_by, owner_nid";
134
146
  /** `ROW_COLUMNS` qualified as `t.`, plus what the minting token is, for the list. */
135
147
  const LISTING_COLUMNS = ROW_COLUMNS.split(", ").map((c) => `t.${c}`).join(", ") +
136
148
  ", m.kind AS minted_by_kind, m.user_agent AS minted_by_user_agent";
@@ -188,6 +200,18 @@ export class DbTokenStore {
188
200
  }
189
201
  catch { }
190
202
  }
203
+ // The origin is sighted on its own, not with the user agent and address:
204
+ // the first request to present a token is often a same-origin GET, which
205
+ // carries no Origin, so it is the first request that *has* one — in
206
+ // practice the control socket's upgrade — that names the app. Written
207
+ // once, like the sighting, and best-effort, like everything here.
208
+ const origin = seen ? cleanOrigin(seen.origin) : null;
209
+ if (origin !== null && row.app_origin === null) {
210
+ try {
211
+ this.recordOrigin(row.id, origin);
212
+ }
213
+ catch { }
214
+ }
191
215
  // The first sighting names the row; the latest, and whether it came from
192
216
  // somewhere else, is what shows a copied token. An address is the one
193
217
  // thing about a request its sender cannot choose, so it is what's compared.
@@ -204,9 +228,10 @@ export class DbTokenStore {
204
228
  .prepare(`UPDATE tokens SET last_used_at = ?,
205
229
  last_user_agent = COALESCE(?, last_user_agent),
206
230
  last_remote_addr = COALESCE(?, last_remote_addr),
231
+ last_app_origin = COALESCE(?, last_app_origin),
207
232
  elsewhere_at = CASE WHEN ? THEN ? ELSE elsewhere_at END
208
233
  WHERE id = ?`)
209
- .run(now, userAgent, remoteAddr, elsewhere ? 1 : 0, now, row.id);
234
+ .run(now, userAgent, remoteAddr, origin, elsewhere ? 1 : 0, now, row.id);
210
235
  this.touched.set(row.id, now);
211
236
  if (elsewhere)
212
237
  this.elsewhereStamped.set(row.id, now);
@@ -237,8 +262,24 @@ export class DbTokenStore {
237
262
  this.sighted.add(record.id);
238
263
  }
239
264
  catch { }
265
+ if (opts.seen) {
266
+ const origin = cleanOrigin(opts.seen.origin);
267
+ if (origin !== null)
268
+ try {
269
+ this.recordOrigin(record.id, origin);
270
+ }
271
+ catch { }
272
+ }
240
273
  return record;
241
274
  }
275
+ /** Write a row's first origin, if it has none; the latest rides with
276
+ * `last_used_at`. Never overwrites one, for the same reason as the
277
+ * sighting: the row keeps naming the app that claimed it. */
278
+ recordOrigin(id, origin) {
279
+ this.db
280
+ .prepare("UPDATE tokens SET app_origin = ?, last_app_origin = COALESCE(last_app_origin, ?) WHERE id = ? AND app_origin IS NULL")
281
+ .run(origin, origin, id);
282
+ }
242
283
  /** Write a row's first sighting, if it has none. Never overwrites one: the
243
284
  * row should keep naming the device that first presented it, not whoever
244
285
  * presents it next. Returns whether anything was written. */
@@ -0,0 +1,26 @@
1
+ /** An `Origin` header as a Devices list shows it: the host, port included
2
+ * when it has one, without the scheme. `https://main.brane.organelle.dev`
3
+ * reads "via main.brane.organelle.dev"; a daemon's own bundle reads "via
4
+ * mac.tail1234.ts.net:47443". Anything that is not a URL is shown as it
5
+ * came (already stripped of control characters by the store). */
6
+ export function originHost(origin) {
7
+ try {
8
+ return new URL(origin).host || origin;
9
+ }
10
+ catch {
11
+ return origin;
12
+ }
13
+ }
14
+ /** A host as `app_domain` or `--app` may give it: a bare host, or a URL whose
15
+ * host is taken (`https://main.brane.organelle.dev/` reads as the host), with
16
+ * a port kept if there is one, lower-cased. Every reader of `app_domain`
17
+ * goes through this — `brane meld`, `GET /api/status`, the app — so a value
18
+ * stored in URL form by `brane config` builds the same link everywhere,
19
+ * rather than a QR that works beside an Isolate link that reads
20
+ * `https://https://…`. Empty in, empty out. */
21
+ export function cleanAppHost(value) {
22
+ const trimmed = value.trim();
23
+ const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(trimmed);
24
+ return (m ? m[1] : trimmed.replace(/[/?#].*$/, "")).toLowerCase();
25
+ }
26
+ //# sourceMappingURL=app-origin.js.map
@@ -20,7 +20,7 @@
20
20
  export function revivesAsTwin(role) {
21
21
  return role === "external" || role === "absorbed";
22
22
  }
23
- export function narrativeMenuItems({ role, status, mcpConnected, archived = false, isIsolated = false, selectedCount = 0, onMeld, onIsolate, onExport, onRecolor, onTwin, onRevive, onEnd, onArchiveToggle, }) {
23
+ export function narrativeMenuItems({ role, status, mcpConnected, archived = false, isIsolated = false, canIsolate = true, selectedCount = 0, onMeld, onIsolate, onExport, onRecolor, onTwin, onRevive, onEnd, onArchiveToggle, }) {
24
24
  const isEnded = status === "ended" && !mcpConnected;
25
25
  // External narratives have no tmux process — no in-place End/Revive applies;
26
26
  // their only lifecycle action is Twin (continue into a brane-managed agent).
@@ -30,11 +30,13 @@ export function narrativeMenuItems({ role, status, mcpConnected, archived = fals
30
30
  const isExternal = role === "external";
31
31
  return [
32
32
  { icon: "🖖", label: "Meld", onPress: onMeld },
33
- {
34
- icon: isIsolated ? "🏘️" : "🏠",
35
- label: isIsolated ? "Unisolate" : "Isolate",
36
- onPress: onIsolate,
37
- },
33
+ ...(canIsolate
34
+ ? [{
35
+ icon: isIsolated ? "🏘️" : "🏠",
36
+ label: isIsolated ? "Unisolate" : "Isolate",
37
+ onPress: onIsolate,
38
+ }]
39
+ : []),
38
40
  // With a selection, Export becomes two items — the picks, or everything but
39
41
  // the picks — because a selection is as often "these are the interesting
40
42
  // ones" as "these are the ones nobody else should read". Both items name
@@ -4,8 +4,8 @@
4
4
  * This lives in `shared/` rather than in the screen that uses it because the
5
5
  * URL is the only carrier of view state between two *origins*. Tab layout and
6
6
  * last-visited file are also kept in `sessionStorage`, which is origin-scoped,
7
- * and the Isolate menu item hops between `brane.organelle.dev` and a daemon's
8
- * own host —
7
+ * and the Isolate menu item hops between the shared app's origin and a
8
+ * daemon's own host —
9
9
  * so whatever isn't in the query string does not survive the jump. Keeping
10
10
  * the serialization and the link builder in one pure module means the URL you
11
11
  * get from tapping around and the URL Isolate hands the other origin are the
@@ -15,14 +15,17 @@
15
15
  * matter what order they were toggled in, so a URL is stable. */
16
16
  export const CANONICAL_TABS = ["chat", "terminal", "files"];
17
17
  const VALID_TABS = new Set(CANONICAL_TABS);
18
- /** The multi-daemon SPA, which can be melded to any number of daemons. Every
19
- * other host is a single daemon serving its own bundle. */
20
- export const SHARED_SPA_HOST = "brane.organelle.dev";
21
- /** True when this page is a single daemon's own bundle rather than the shared
22
- * SPA — i.e. when "isolated" is the *current* state and the menu offers the
23
- * way back out. */
24
- export function isIsolatedHost(hostname) {
25
- return hostname !== SHARED_SPA_HOST;
18
+ /** True when this page is served by the daemon it is connected to — a
19
+ * daemon's own bundle — so "isolated" is the *current* state and the menu
20
+ * offers the way back out. Everything else is a shared app: the hosted
21
+ * shell, a staging hostname, a fork's deployment, a dev server. The app
22
+ * carries no host of its own, so this is a comparison with no constant in
23
+ * it: `window.location.host` against the connection's host, which a
24
+ * daemon's bundle fills from the same `location.host` (expo/lib/api.ts).
25
+ * Hosts are case-insensitive; the port is part of the answer, since a
26
+ * daemon's bundle is reached on its port. */
27
+ export function isIsolated(pageHost, daemonHost) {
28
+ return pageHost.toLowerCase() === daemonHost.toLowerCase();
26
29
  }
27
30
  export function parseActiveTab(tab) {
28
31
  return VALID_TABS.has(tab) ? tab : "chat";
@@ -128,7 +131,7 @@ export function withoutConnectionHash(hash) {
128
131
  /** Where a link puts the daemon's address and token: the URL **fragment**.
129
132
  *
130
133
  * A query string is part of the HTTP request, so `?host=…&token=…` on a
131
- * `brane.organelle.dev` link reached Cloudflare's edge with the daemon's
134
+ * shared-app link reached its host's edge with the daemon's
132
135
  * bearer token in it, landed in the daemon's own request log on an Isolate
133
136
  * hop, and was fetched by every chat app's link unfurler when a meld URL was
134
137
  * pasted. A fragment is never sent to any server: the browser keeps it, the
@@ -173,25 +176,41 @@ export function buildViewUrl(target, opts) {
173
176
  const query = params.toString();
174
177
  const rest = (query ? `?${query}` : "") + connectionFragment(conn, target === "shared");
175
178
  if (target === "shared") {
176
- // The shared SPA serves every daemon, so it has to be told which one.
177
- return `https://${SHARED_SPA_HOST}${path}${rest}`;
179
+ // A shared app serves every daemon, so it has to be told which one.
180
+ const origin = sharedOrigin(opts);
181
+ return origin === null ? null : `${origin}${path}${rest}`;
178
182
  }
179
183
  // A daemon's own bundle already knows which daemon it is; only the token is
180
184
  // needed. The scheme follows the page building the link, so a plain-http LAN
181
185
  // session doesn't hand out https links that won't load.
182
- const scheme = protocol === "https:" ? "https" : "http";
183
- return `${scheme}://${conn.host}${path}${rest}`;
186
+ return `${pageScheme(protocol)}://${conn.host}${path}${rest}`;
187
+ }
188
+ function pageScheme(protocol) {
189
+ return protocol === "https:" ? "https" : "http";
190
+ }
191
+ /** The shared app's origin, from where this page stands. On a shared app it
192
+ * is this page's own origin, scheme included (a dev server is plain http).
193
+ * On a daemon's own bundle it is whatever the daemon reports as its
194
+ * `app_domain`, over https, since that is a hosted shell; with none
195
+ * reported there is no shared origin to name, and the answer is null. */
196
+ function sharedOrigin(opts) {
197
+ if (!isIsolated(opts.host, opts.conn.host))
198
+ return `${pageScheme(opts.protocol)}://${opts.host}`;
199
+ return opts.appHost ? `https://${opts.appHost}` : null;
184
200
  }
185
201
  /** Isolate / Unisolate: the same view, on the *other* kind of origin. Which
186
- * way it goes is just where you are now. */
202
+ * way it goes is just where you are now. Null only from a daemon's own
203
+ * bundle that was told no `appHost`: there is nowhere to hop out to, and
204
+ * the menu should say so rather than offer a link. */
187
205
  export function buildIsolateUrl(opts) {
188
- return buildViewUrl(isIsolatedHost(opts.hostname) ? "shared" : "daemon", opts);
206
+ return buildViewUrl(isIsolated(opts.host, opts.conn.host) ? "shared" : "daemon", opts);
189
207
  }
190
208
  /** Meld: the same view, on another *device*. The origin kind is the one thing
191
209
  * that must not change — someone reaching the daemon directly can't be handed
192
- * a shared-SPA link, and someone on the shared app expects to stay there — so
193
- * where Isolate flips, this stays put. */
210
+ * a shared-app link, and someone on a shared app expects to stay on *that*
211
+ * one — so where Isolate flips, this stays put. It never needs `appHost`:
212
+ * the shared origin it names is the page's own, so it is never null. */
194
213
  export function buildMeldUrl(opts) {
195
- return buildViewUrl(isIsolatedHost(opts.hostname) ? "daemon" : "shared", opts);
214
+ return buildViewUrl(isIsolated(opts.host, opts.conn.host) ? "daemon" : "shared", opts);
196
215
  }
197
216
  //# sourceMappingURL=view-url.js.map
@@ -0,0 +1,53 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ // The app carries no host of its own. "Isolated" is `location.host` against
7
+ // the connection's host; where a daemon's bundle hops out to is what the
8
+ // daemon reports (`app_domain` in GET /api/status); `brane meld` reads the
9
+ // same config. So any brane host spelled out in the app or in `src/shared/`
10
+ // is a dependency creeping back in, and this is what keeps it out. A literal
11
+ // in a comment is an example, not a dependency, so comments are stripped
12
+ // before the scan. The one legitimate literal is the absolute `og:image`
13
+ // URL in the HTML template, which docs/link-previews.md explains.
14
+ //
15
+ // This reads *source*, which the npm tarball does not carry, so it skips when
16
+ // the source is absent rather than failing `brane doctor` on every install.
17
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+ const SCAN = ["src/shared", "expo/app", "expo/lib", "expo/components"];
19
+ const ALLOWED = new Set(["expo/app/+html.tsx"]);
20
+ const HOST = /\borganelle\.dev\b/;
21
+ function* files(dir) {
22
+ for (const name of readdirSync(dir)) {
23
+ const p = join(dir, name);
24
+ if (statSync(p).isDirectory())
25
+ yield* files(p);
26
+ else if (/\.(ts|tsx)$/.test(name))
27
+ yield p;
28
+ }
29
+ }
30
+ function withoutComments(source) {
31
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
32
+ }
33
+ describe("the app names no brane host", () => {
34
+ it("has no organelle.dev literal in src/shared or the app, outside comments and the og:image tag", (t) => {
35
+ if (!SCAN.every((d) => existsSync(join(root, d))))
36
+ return t.skip("source is not present (an installed package)");
37
+ const offenders = [];
38
+ for (const dir of SCAN) {
39
+ for (const file of files(join(root, dir))) {
40
+ const rel = relative(root, file);
41
+ if (ALLOWED.has(rel))
42
+ continue;
43
+ const code = withoutComments(readFileSync(file, "utf-8"));
44
+ code.split("\n").forEach((line, i) => {
45
+ if (HOST.test(line))
46
+ offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
47
+ });
48
+ }
49
+ }
50
+ assert.deepEqual(offenders, [], "a host literal outside a comment:\n" + offenders.join("\n"));
51
+ });
52
+ });
53
+ //# sourceMappingURL=app-host-literal.test.js.map