brane 0.3.0 → 0.3.2

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 (42) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +56 -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/archive-gate.js +11 -0
  12. package/dist/daemon/server.js +112 -89
  13. package/dist/db/schema.js +10 -0
  14. package/dist/db/tokens.js +43 -2
  15. package/dist/shared/app-origin.js +26 -0
  16. package/dist/shared/narrative-menu.js +16 -11
  17. package/dist/shared/view-url.js +39 -20
  18. package/dist/test/app-host-literal.test.js +53 -0
  19. package/dist/test/archive-gate.test.js +22 -0
  20. package/dist/test/cors-preflight.test.js +28 -21
  21. package/dist/test/hook-config.test.js +38 -1
  22. package/dist/test/meld.test.js +59 -1
  23. package/dist/test/narrative-menu.test.js +30 -3
  24. package/dist/test/remote-meld.test.js +13 -0
  25. package/dist/test/terminal-size.test.js +36 -0
  26. package/dist/test/token-devices.test.js +59 -0
  27. package/dist/test/view-url.test.js +93 -47
  28. package/dist/test/wrangler-route.test.js +27 -0
  29. package/dist/tmux.js +11 -0
  30. package/docker-entrypoint.sh +15 -2
  31. package/docs/daemon-api.md +5 -5
  32. package/docs/mcp.md +1 -1
  33. package/docs/releasing.md +62 -9
  34. package/docs/terminal-mirroring.md +32 -0
  35. package/docs/token-rotation.md +26 -6
  36. package/docs/view-urls.md +32 -7
  37. package/docs/web-app.md +2 -2
  38. package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-807575a48b8a2f18f8bffe53c8b35fbd.js} +47 -45
  39. package/expo/dist/index.html +1 -1
  40. package/expo/dist/sw.js +2 -2
  41. package/package.json +1 -1
  42. package/wiki/shared/index.md +5 -0
@@ -22,10 +22,11 @@ import { getBraneId, getPayloadsDir, getTokenFilePath, fromPortablePath, toPorta
22
22
  import { TokenRing } from "./token-ring.js";
23
23
  import { signalEventFields, signalStanding } from "./signal-source.js";
24
24
  import { AGENT_LOOPBACK_REFUSAL, AGENT_REVIVE_REFUSAL, agentRefusal, agentReviveRefusal } from "./agent-scope.js";
25
+ import { archiveRefusal } from "./archive-gate.js";
25
26
  import { DbTokenStore, bootstrapTokens, legacyTokenWarning, parseTokenHashes, liveAgentTokenId, mintAgentToken, recordLauncher, settleAgentLaunch, verifyAgentToken } from "../db/tokens.js";
26
27
  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";
28
+ import { getConfig, resolveAppHost } from "../commands/config.js";
29
+ import { doSpawn, buildGooseExtensionArg, reestablishMcpConfigArgs, braneTmuxEnv, ensureHookConfig, resolveAgentCommand, LIVE_LOOP_PROMPT } from "../commands/spawn.js";
29
30
  import { ensureSessionAffinityConfig } from "../session-affinity.js";
30
31
  import { revivedContent, restartedContent } from "../shared/narrative-format.js";
31
32
  import { parseRecolorSalt } from "../shared/speaker-color.js";
@@ -47,7 +48,7 @@ import { getTailscaleIps, getTailscaleIpsAsync } from "../tailscale.js";
47
48
  import { addressesToBind, nextProbeDelay, TAILSCALE_PROBE_MAX_MS } from "./tailscale-rebind.js";
48
49
  import { CertManager } from "./cert-manager.js";
49
50
  import { KeepAwake } from "./keep-awake.js";
50
- import { resolveTmux, ensureTmux, newTmuxSession } from "../tmux.js";
51
+ import { resolveTmux, ensureTmux, newTmuxSession, parseTerminalSize } from "../tmux.js";
51
52
  import { INHERITED_AGENT_ENV, unsetAgentEnvPrefix } from "../agent-env.js";
52
53
  import { highlight } from "../highlight.js";
53
54
  import { browseFold, openFoldSource, parseOffsetParam } from "../files/browse.js";
@@ -191,6 +192,13 @@ export function reviveNarrative(db, nid, opts = {}) {
191
192
  return { status: "continued", nid: result.narrativeId, agent: effectiveAgent };
192
193
  }
193
194
  // Same agent — use resume args
195
+ // Point the fold's tool-call hooks at this daemon before anything else can
196
+ // fail. The config names a port, and a daemon that came back on another
197
+ // one (its old port was taken, or a Fly boot cleared the hint) leaves it
198
+ // naming a dead one: every tool call then fails its hook, and the chat
199
+ // stops showing tool rows. doSpawn does this for fresh sessions; a revive
200
+ // builds its launch here, so it has to as well (claude-nkrks0of:23).
201
+ ensureHookConfig(db, effectiveAgent, absoluteFoldPath, nid);
194
202
  const agentCommand = resolveAgentCommand(effectiveAgent);
195
203
  // The revived session's agent token. Minted with the previous one left
196
204
  // live until the launch succeeds (settleAgentLaunch, below): a revive that
@@ -306,74 +314,47 @@ export function reviveNarrative(db, nid, opts = {}) {
306
314
  }
307
315
  return { status: "revived", nid, agent: effectiveAgent };
308
316
  }
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
- }
317
+ /** What `GET /api/status` reports as `app_domain`: the config, reduced to a
318
+ * host exactly as `brane meld` reduces it (`resolveAppHost`), so an operator
319
+ * who stored it as a URL (`brane config app_domain https://staging.example/`)
320
+ * gets a working Isolate link from a daemon's bundle rather than
321
+ * `https://https://…`, and a blank value means the default on both sides.
322
+ * The app reads a missing field, from a daemon older than this, as "no way
323
+ * out". */
324
+ export function statusAppDomain(db) {
325
+ return resolveAppHost(getConfig("app_domain", db));
346
326
  }
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;
357
- }
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
- }
327
+ /** CORS for a browser on any origin. The token is the only credential, so
328
+ * the origin is not a trust decision: a page holding no token gets a 401
329
+ * whatever its origin, and a page holding one can act with it from any
330
+ * origin regardless of what is answered here — `?token=` is taken on any
331
+ * method and bodies are parsed as JSON whatever their `Content-Type`, so a
332
+ * `text/plain` POST needs no preflight. An origin allowlist would only
333
+ * hide responses and refuse WebSockets. So the answer is `*`, never a
334
+ * reflected origin, and never `Allow-Credentials`: brane sets no cookies,
335
+ * and credentials with a reflected origin is the one CORS combination that
336
+ * could ever be dangerous. This is what lets one daemon be fronted
337
+ * by `brane.organelle.dev`, a staging hostname, a fork's own deployment, or
338
+ * its own bundle, with nothing to configure. Which origin a device actually
339
+ * runs is recorded on its token and shown in the Devices list, not gated
340
+ * here (SECURITY.md, "Authentication"). */
341
+ export function setCorsHeaders(req, res) {
342
+ if (req.headers.origin === undefined)
343
+ return;
344
+ res.setHeader("Access-Control-Allow-Origin", "*");
345
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
346
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
347
+ // How long the browser may reuse this preflight's answer. Without it
348
+ // Chrome caches for 5 seconds, which on a fast link is indistinguishable
349
+ // from caching forever and on a slow one is indistinguishable from not
350
+ // caching at all: nearly every request pays its own extra round trip.
351
+ // Measured at 741ms RTT that was fatal rather than merely slow — see the
352
+ // note on `apiUrl` in expo/lib/api.ts. The bodiless requests avoid the
353
+ // preflight entirely now; this is for the ones that carry JSON and can't.
354
+ // A day is the practical ceiling (Chrome caps at 2h, Firefox 24h), and
355
+ // the answer being cached is safe: it does not authenticate anything,
356
+ // it only says which methods and headers a page may use.
357
+ res.setHeader("Access-Control-Max-Age", "86400");
377
358
  }
378
359
  /** Below this, compressing costs more than it saves — a gzip header is 20-odd
379
360
  * bytes and the CPU is not free. Above it, JSON of brane's shape compresses
@@ -616,9 +597,11 @@ export function isLoopbackRequest(req) {
616
597
  * list can tell rows apart. */
617
598
  export function authenticateRequest(ring, presented, req, braneDir, onTrust, now = Date.now()) {
618
599
  const userAgent = req.headers["user-agent"];
600
+ const origin = req.headers.origin;
619
601
  const seen = {
620
602
  userAgent: typeof userAgent === "string" ? userAgent : null,
621
603
  remoteAddr: connectionLabel(req.socket),
604
+ origin: typeof origin === "string" ? origin : null,
622
605
  };
623
606
  if (!presented && isUnixConnection(req.socket)) {
624
607
  const refusal = tokenlessRefusal(req, braneDir);
@@ -794,7 +777,6 @@ export async function startServer(config) {
794
777
  return refusal;
795
778
  };
796
779
  const braneId = getBraneId(db);
797
- const appDomain = getConfig("app_domain", db) ?? "brane.organelle.dev";
798
780
  let actualPort = port; // updated after bind when port is 0
799
781
  let actualHttpPort = 0; // HTTP fallback port (set when httpServer binds)
800
782
  // TLS cert lifecycle. CertManager owns provisioning, renewal, fs.watch,
@@ -1175,7 +1157,7 @@ export async function startServer(config) {
1175
1157
  // response — including the early auth and error exits — can see whether
1176
1158
  // the client accepts gzip.
1177
1159
  trackRequest(res, req);
1178
- setCorsHeaders(req, res, appDomain, actualPort, actualHttpPort);
1160
+ setCorsHeaders(req, res);
1179
1161
  // Handle CORS preflight
1180
1162
  if (req.method === "OPTIONS") {
1181
1163
  res.writeHead(204);
@@ -1300,6 +1282,12 @@ export async function startServer(config) {
1300
1282
  // Listening on the address over HTTPS, but the HTTP fallback (the
1301
1283
  // local/LAN path) is not up there yet.
1302
1284
  fallback_missing: [...needFallback],
1285
+ // The hostname that serves the shared app: where `brane meld` points
1286
+ // a QR, and where a daemon's own bundle offers to hop *out* to
1287
+ // (Isolate's way back). The app reads it from here rather than
1288
+ // carrying a host of its own, so a fork's daemon names the fork's
1289
+ // shell (src/shared/view-url.ts).
1290
+ app_domain: statusAppDomain(db),
1303
1291
  });
1304
1292
  return;
1305
1293
  }
@@ -1394,16 +1382,34 @@ export async function startServer(config) {
1394
1382
  const narrativeId = archiveMatch[1];
1395
1383
  parseBody(req).then((body) => {
1396
1384
  const { archived } = JSON.parse(body);
1385
+ // A running narrative can't be archived: hiding a live agent from the
1386
+ // list is exactly what a rogue one would want. See archive-gate.ts.
1387
+ if (archived) {
1388
+ const row = db.prepare("SELECT status FROM narratives WHERE id = ?").get(narrativeId);
1389
+ if (!row) {
1390
+ sendJson(res, 404, { error: `No narrative ${narrativeId}` });
1391
+ return;
1392
+ }
1393
+ const refusal = archiveRefusal({
1394
+ status: row.status,
1395
+ mcpConnected: mcpConnectedNids(mcpClients).has(narrativeId),
1396
+ tmuxLive: liveTmuxSessions().has(narrativeId),
1397
+ });
1398
+ if (refusal) {
1399
+ sendJson(res, 409, { error: `Can't archive ${narrativeId}: ${refusal}. End it first.` });
1400
+ return;
1401
+ }
1402
+ }
1397
1403
  db.prepare("UPDATE narratives SET archived = ? WHERE id = ?").run(archived ? 1 : 0, narrativeId);
1398
1404
  // An archived narrative's agent token is a credential nobody needs:
1399
- // revoke it, unless its session is still running (archiving is not a
1400
- // way to cut off a live agent; revoking from the Devices list is).
1401
- // Unarchiving brings nothing back; a revive mints a fresh token.
1402
- if (archived && !liveTmuxSessions().has(narrativeId)) {
1405
+ // revoke it. Its session is over, or the gate above would have
1406
+ // refused. Unarchiving brings nothing back; a revive mints a fresh
1407
+ // token.
1408
+ if (archived) {
1403
1409
  const agentRow = liveAgentTokenId(db, narrativeId);
1404
1410
  if (agentRow) {
1405
1411
  db.prepare("UPDATE tokens SET revoked_at = ? WHERE kind = 'agent' AND owner_nid = ? AND revoked_at IS NULL").run(Date.now(), narrativeId);
1406
- // A connected external's bridge goes now, not at its next redial.
1412
+ // Any socket still holding it goes now, not at its next redial.
1407
1413
  closeSocketsOf(viewerSockets, [agentRow]);
1408
1414
  }
1409
1415
  }
@@ -2016,7 +2022,7 @@ export async function startServer(config) {
2016
2022
  // Route: POST /api/spawn
2017
2023
  if (path === "/api/spawn" && req.method === "POST") {
2018
2024
  parseBody(req).then(async (body) => {
2019
- const { agent, role, model, re, fold_path, extra_args } = JSON.parse(body);
2025
+ const { agent, role, model, re, fold_path, extra_args, terminal_size } = JSON.parse(body);
2020
2026
  if (!agent) {
2021
2027
  sendJson(res, 400, { error: "agent is required" });
2022
2028
  return;
@@ -2035,7 +2041,11 @@ export async function startServer(config) {
2035
2041
  extraArgs = extra_args;
2036
2042
  }
2037
2043
  await ensureTmux();
2038
- const result = doSpawn({ db, agent, role, model, re, foldPath: fold_path, extraArgs, terminalSize: lastTerminalSize ?? undefined });
2044
+ // Start at the requesting viewer's grid when it sent one: a session
2045
+ // resized right after launch redraws at each width, and the stale
2046
+ // frames land in the scrollback the mirror shows.
2047
+ const terminalSize = parseTerminalSize(terminal_size) ?? lastTerminalSize ?? undefined;
2048
+ const result = doSpawn({ db, agent, role, model, re, foldPath: fold_path, extraArgs, terminalSize });
2039
2049
  recordLauncher(db, result.narrativeId, auth.record.id);
2040
2050
  const connectedNids = mcpConnectedNids(mcpClients);
2041
2051
  sendJson(res, 200, {
@@ -2060,7 +2070,7 @@ export async function startServer(config) {
2060
2070
  // Route: POST /api/run
2061
2071
  if (path === "/api/run" && req.method === "POST") {
2062
2072
  parseBody(req).then(async (body) => {
2063
- const { command, fold_path } = JSON.parse(body);
2073
+ const { command, fold_path, terminal_size } = JSON.parse(body);
2064
2074
  if (!command || !Array.isArray(command) || command.length === 0) {
2065
2075
  sendJson(res, 400, { error: "command is required (array of strings)" });
2066
2076
  return;
@@ -2070,7 +2080,8 @@ export async function startServer(config) {
2070
2080
  return;
2071
2081
  }
2072
2082
  await ensureTmux();
2073
- const result = doRun({ db, command, foldPath: fold_path, terminalSize: lastTerminalSize ?? undefined });
2083
+ const terminalSize = parseTerminalSize(terminal_size) ?? lastTerminalSize ?? undefined;
2084
+ const result = doRun({ db, command, foldPath: fold_path, terminalSize });
2074
2085
  recordLauncher(db, result.narrativeId, auth.record.id);
2075
2086
  sendJson(res, 200, {
2076
2087
  narrative: {
@@ -2612,13 +2623,10 @@ export async function startServer(config) {
2612
2623
  }
2613
2624
  if (auth.retired)
2614
2625
  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
- }
2626
+ // No Origin check: the token above is the credential, and the origin it
2627
+ // was presented from is now on its row for the Devices list to show
2628
+ // (`authenticateRequest`). Every browser sends one on an upgrade, so
2629
+ // this is the request that names which copy of the app a device runs.
2622
2630
  // WS: /ws/control — single real-time channel
2623
2631
  if (path === "/ws/control") {
2624
2632
  controlWss.handleUpgrade(req, socket, head, (ws) => {
@@ -2796,7 +2804,6 @@ export async function startServer(config) {
2796
2804
  console.warn("[socket] not listening: this machine's shell (the brane CLI outside an agent session) and new external MCP clients " +
2797
2805
  "cannot reach this daemon; agents and signed-in devices still can");
2798
2806
  }
2799
- console.log(`app domain: ${appDomain}`);
2800
2807
  console.log(`pid: ${process.pid}`);
2801
2808
  // Eagerly load the embedding model so first search is fast
2802
2809
  preloadEmbeddingModel();
@@ -6146,6 +6153,9 @@ function handleTerminalSocket(ws, nid) {
6146
6153
  case "mouse":
6147
6154
  sendMouseToTmux(nid, msg);
6148
6155
  break;
6156
+ case "clear_history":
6157
+ clearTerminalHistory(nid);
6158
+ break;
6149
6159
  case "refresh": {
6150
6160
  // Client requests a clean full frame. The client has just wiped its
6151
6161
  // <pre> DOM, so if we wait for `forceCapture()` alone the capture
@@ -6224,6 +6234,19 @@ function fitWindowToViewers(nid) {
6224
6234
  cap.appliedSize = { cols, rows };
6225
6235
  resizeTerminal(nid, cols, rows);
6226
6236
  }
6237
+ // The operator's "Clear scrollback": drop the pane's tmux history and leave
6238
+ // the live screen alone. Scrollback is where a TUI's stale redraws pile up —
6239
+ // Claude's trust dialog drawn at three widths before the session settled, its
6240
+ // full-width rules now wider than the pane — and the mirror shows all of it
6241
+ // (`capture-pane -S -`). Only ever on an explicit request, since it throws
6242
+ // that history away. The forced capture sends the shorter frame at once; the
6243
+ // client trims to `totalLines`, so the stale lines go with it.
6244
+ function clearTerminalHistory(nid) {
6245
+ execFile(resolveTmux(), ["clear-history", "-t", nid], { timeout: 2000 }, (err) => {
6246
+ if (!err)
6247
+ terminalCaptures.get(nid)?.forceCapture();
6248
+ });
6249
+ }
6227
6250
  function resizeTerminal(nid, cols, rows) {
6228
6251
  if (!cols || !rows || cols < 1 || rows < 1)
6229
6252
  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
@@ -72,11 +74,14 @@ export function narrativeMenuItems({ role, status, mcpConnected, archived = fals
72
74
  ? { icon: "🐦‍🔥", label: "Revive", onPress: onRevive }
73
75
  : { icon: "🫠", label: "End", onPress: onEnd },
74
76
  ]),
75
- {
76
- icon: archived ? "📂" : "🗄️",
77
- label: archived ? "Unarchive" : "Archive",
78
- onPress: onArchiveToggle,
79
- },
77
+ // Archive puts away a narrative that is over, so it waits for the session
78
+ // to end, by the same test that offers Revive. Unarchive is offered on any
79
+ // archived narrative, whatever its state.
80
+ ...(archived
81
+ ? [{ icon: "📂", label: "Unarchive", onPress: onArchiveToggle }]
82
+ : isEnded
83
+ ? [{ icon: "🗄️", label: "Archive", onPress: onArchiveToggle }]
84
+ : []),
80
85
  ];
81
86
  }
82
87
  //# sourceMappingURL=narrative-menu.js.map
@@ -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