brane 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -7
- package/CHANGELOG.md +37 -4
- package/CONTRIBUTING.md +12 -2
- package/DESIGN.md +7 -4
- package/README.md +2 -2
- package/SECURITY.md +8 -0
- package/dist/commands/config.js +17 -0
- package/dist/commands/meld.js +38 -10
- package/dist/commands/spawn.js +11 -4
- package/dist/commands/token.js +2 -1
- package/dist/daemon/server.js +88 -84
- package/dist/db/schema.js +10 -0
- package/dist/db/tokens.js +43 -2
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/narrative-menu.js +8 -6
- package/dist/shared/view-url.js +39 -20
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/cors-preflight.test.js +28 -21
- package/dist/test/hook-config.test.js +38 -1
- package/dist/test/meld.test.js +59 -1
- package/dist/test/narrative-menu.test.js +13 -0
- package/dist/test/remote-meld.test.js +13 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/token-devices.test.js +59 -0
- package/dist/test/view-url.test.js +93 -47
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tmux.js +11 -0
- package/docker-entrypoint.sh +15 -2
- package/docs/daemon-api.md +4 -4
- package/docs/releasing.md +59 -8
- package/docs/terminal-mirroring.md +32 -0
- package/docs/token-rotation.md +22 -4
- package/docs/view-urls.md +26 -6
- package/docs/web-app.md +1 -1
- package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-e94247f119fdcd3b424a1764233a41d2.js} +47 -45
- package/expo/dist/index.html +1 -1
- package/expo/dist/sw.js +2 -2
- package/package.json +1 -1
- package/wiki/shared/index.md +5 -0
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
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
|
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
|
|
@@ -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
|