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.
- package/AGENTS.md +11 -6
- package/CHANGELOG.md +56 -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/archive-gate.js +11 -0
- package/dist/daemon/server.js +112 -89
- 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 +16 -11
- package/dist/shared/view-url.js +39 -20
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/archive-gate.test.js +22 -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 +30 -3
- 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 +5 -5
- package/docs/mcp.md +1 -1
- package/docs/releasing.md +62 -9
- package/docs/terminal-mirroring.md +32 -0
- package/docs/token-rotation.md +26 -6
- package/docs/view-urls.md +32 -7
- package/docs/web-app.md +2 -2
- package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-807575a48b8a2f18f8bffe53c8b35fbd.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/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
if (archived
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
package/dist/shared/view-url.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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(
|
|
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-
|
|
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(
|
|
214
|
+
return buildViewUrl(isIsolated(opts.host, opts.conn.host) ? "daemon" : "shared", opts);
|
|
196
215
|
}
|
|
197
216
|
//# sourceMappingURL=view-url.js.map
|