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
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { CANONICAL_TABS,
|
|
3
|
+
import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
|
|
4
4
|
// These pin the URL contract that the Isolate menu item depends on. It opens
|
|
5
|
-
// the same narrative on the *other* origin —
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// the same narrative on the *other* origin — a shared app, or a daemon's own
|
|
6
|
+
// bundle — and the tab layout and open file live in sessionStorage, which is
|
|
7
|
+
// origin-scoped. So the query string is the only thing that crosses, and every
|
|
8
|
+
// assertion below is really asking the same question: does the view you were
|
|
9
|
+
// looking at survive the hop?
|
|
10
|
+
//
|
|
11
|
+
// The app carries no host of its own: "isolated" is `location.host` equal to
|
|
12
|
+
// the connection's host, and the way out of a daemon's bundle is whatever the
|
|
13
|
+
// daemon reports as its app host. So the shared host here is deliberately not
|
|
14
|
+
// brane's; any host serving the bundle is a shared app.
|
|
10
15
|
const CONN = { host: "chalk.tail1234.ts.net", token: "tok-abc123" };
|
|
11
16
|
const NID = "claude-0b9okmy2";
|
|
17
|
+
/** The page's host while on a shared app. */
|
|
18
|
+
const SHARED_SPA_HOST = "shell.example.test";
|
|
19
|
+
/** What the daemon reports as its `app_domain`: where a daemon's own bundle
|
|
20
|
+
* hops out to, which need not be the shared app the operator came from. */
|
|
21
|
+
const APP_HOST = "app.example.test";
|
|
12
22
|
/** Every subset of the tab set, as a Set — 8 of them. */
|
|
13
23
|
function pinPowerset() {
|
|
14
24
|
const out = [];
|
|
@@ -146,13 +156,19 @@ describe("parseActiveTab / parsePinnedTabs", () => {
|
|
|
146
156
|
}
|
|
147
157
|
});
|
|
148
158
|
});
|
|
149
|
-
describe("
|
|
150
|
-
it("
|
|
151
|
-
assert.equal(
|
|
152
|
-
|
|
153
|
-
|
|
159
|
+
describe("isIsolated", () => {
|
|
160
|
+
it("is true only when the page is served by the daemon it is connected to", () => {
|
|
161
|
+
assert.equal(isIsolated(CONN.host, CONN.host), true);
|
|
162
|
+
assert.equal(isIsolated("CHALK.tail1234.TS.NET", CONN.host), true, "hosts are case-insensitive");
|
|
163
|
+
for (const host of [SHARED_SPA_HOST, "brane.organelle.dev", "main.brane.organelle.dev", "localhost:8081", "127.0.0.1:8081"]) {
|
|
164
|
+
assert.equal(isIsolated(host, CONN.host), false, host);
|
|
154
165
|
}
|
|
155
166
|
});
|
|
167
|
+
it("counts the port: a daemon's bundle is reached on its port", () => {
|
|
168
|
+
assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:7777"), true);
|
|
169
|
+
assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:8888"), false);
|
|
170
|
+
assert.equal(isIsolated("192.168.1.20", "192.168.1.20:7777"), false);
|
|
171
|
+
});
|
|
156
172
|
});
|
|
157
173
|
/** Parse a built URL back into something assertable. */
|
|
158
174
|
/** A built URL, taken apart the way the app takes it apart: `params` is the
|
|
@@ -234,7 +250,8 @@ describe("connectionFragment", () => {
|
|
|
234
250
|
for (const c of CASES) {
|
|
235
251
|
const view = params(c);
|
|
236
252
|
const url = new URL(buildIsolateUrl({
|
|
237
|
-
|
|
253
|
+
host: CONN.host,
|
|
254
|
+
appHost: APP_HOST,
|
|
238
255
|
protocol: "https:",
|
|
239
256
|
search: "?scrollToSeq=42",
|
|
240
257
|
path: `/${NID}`,
|
|
@@ -258,14 +275,17 @@ describe("connectionFragment", () => {
|
|
|
258
275
|
describe("buildIsolateUrl", () => {
|
|
259
276
|
it("targets the shared SPA when leaving a daemon, and melds it back", () => {
|
|
260
277
|
const { origin, pathname, params, conn } = open(buildIsolateUrl({
|
|
261
|
-
|
|
278
|
+
host: CONN.host,
|
|
279
|
+
appHost: APP_HOST,
|
|
262
280
|
protocol: "https:",
|
|
263
281
|
search: "?token=old-token",
|
|
264
282
|
path: `/${NID}`,
|
|
265
283
|
conn: CONN,
|
|
266
284
|
view: viewParams("files", new Set(["terminal"]), "src/cli.ts", 4, 9),
|
|
267
285
|
}));
|
|
268
|
-
|
|
286
|
+
// Out of a daemon's bundle, the target is what the daemon reported, not
|
|
287
|
+
// any host the app knows.
|
|
288
|
+
assert.equal(origin, `https://${APP_HOST}`);
|
|
269
289
|
assert.equal(pathname, `/${NID}`);
|
|
270
290
|
// The connection rides in the fragment, never the query string: a query
|
|
271
291
|
// string is sent to the SPA's host, a fragment is not.
|
|
@@ -280,7 +300,7 @@ describe("buildIsolateUrl", () => {
|
|
|
280
300
|
});
|
|
281
301
|
it("targets the daemon's own bundle when leaving the SPA, with no host param", () => {
|
|
282
302
|
const { origin, pathname, params, conn } = open(buildIsolateUrl({
|
|
283
|
-
|
|
303
|
+
host: SHARED_SPA_HOST,
|
|
284
304
|
protocol: "https:",
|
|
285
305
|
search: `?host=${CONN.host}&token=${CONN.token}`,
|
|
286
306
|
path: `/${NID}`,
|
|
@@ -299,7 +319,7 @@ describe("buildIsolateUrl", () => {
|
|
|
299
319
|
it("follows the page's scheme to the daemon, so plain http stays http", () => {
|
|
300
320
|
for (const [protocol, scheme] of [["https:", "https"], ["http:", "http"]]) {
|
|
301
321
|
const { origin } = open(buildIsolateUrl({
|
|
302
|
-
|
|
322
|
+
host: SHARED_SPA_HOST,
|
|
303
323
|
protocol,
|
|
304
324
|
search: "",
|
|
305
325
|
path: `/${NID}`,
|
|
@@ -308,39 +328,59 @@ describe("buildIsolateUrl", () => {
|
|
|
308
328
|
}));
|
|
309
329
|
assert.equal(origin, `${scheme}://${CONN.host}`, protocol);
|
|
310
330
|
}
|
|
311
|
-
// The
|
|
331
|
+
// The hosted shell is https-only, whatever the daemon's bundle you left
|
|
332
|
+
// was served over (a LAN session is plain http).
|
|
312
333
|
const { origin } = open(buildIsolateUrl({
|
|
313
|
-
|
|
334
|
+
host: "192.168.1.20:7777",
|
|
314
335
|
protocol: "http:",
|
|
315
336
|
search: "",
|
|
316
337
|
path: `/${NID}`,
|
|
317
|
-
conn: CONN,
|
|
338
|
+
conn: { host: "192.168.1.20:7777", token: CONN.token },
|
|
339
|
+
appHost: APP_HOST,
|
|
318
340
|
view: viewParams("chat", new Set()),
|
|
319
341
|
}));
|
|
320
|
-
assert.equal(origin, `https://${
|
|
342
|
+
assert.equal(origin, `https://${APP_HOST}`);
|
|
343
|
+
});
|
|
344
|
+
it("has nowhere to go from a daemon's bundle that reported no app host, and says so", () => {
|
|
345
|
+
// A daemon from before `app_domain` rode in /api/status, or one the
|
|
346
|
+
// status probe could not reach: the menu hides Isolate rather than
|
|
347
|
+
// handing out a link to a host the app made up.
|
|
348
|
+
for (const appHost of [undefined, null, ""]) {
|
|
349
|
+
assert.equal(buildIsolateUrl({ host: CONN.host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, appHost, view: {} }), null, String(appHost));
|
|
350
|
+
}
|
|
351
|
+
// From a shared app the way *in* needs no app host at all.
|
|
352
|
+
assert.ok(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
|
|
353
|
+
});
|
|
354
|
+
it("treats any host serving the bundle as a shared app, and hops in to the daemon from it", () => {
|
|
355
|
+
for (const host of ["brane.organelle.dev", "main.brane.organelle.dev", "fork.example", "localhost:8081"]) {
|
|
356
|
+
const { origin, conn } = open(buildIsolateUrl({ host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
|
|
357
|
+
assert.equal(origin, `https://${CONN.host}`, host);
|
|
358
|
+
assert.equal(conn.get("host"), null, host);
|
|
359
|
+
}
|
|
321
360
|
});
|
|
322
361
|
it("never carries the departing origin's credentials", () => {
|
|
323
362
|
// A stale host/token in the search string is the danger case: the SPA's
|
|
324
363
|
// meld params must not follow you onto the daemon's own origin, where the
|
|
325
364
|
// host is wrong and the URL would carry a second copy of the token.
|
|
326
|
-
for (const
|
|
365
|
+
for (const host of [SHARED_SPA_HOST, CONN.host]) {
|
|
327
366
|
const { params, conn } = open(buildIsolateUrl({
|
|
328
|
-
|
|
367
|
+
host,
|
|
368
|
+
appHost: APP_HOST,
|
|
329
369
|
protocol: "https:",
|
|
330
370
|
search: "?host=stale.example.com&token=stale-token&tab=files",
|
|
331
371
|
path: `/${NID}`,
|
|
332
372
|
conn: CONN,
|
|
333
373
|
view: viewParams("files", new Set()),
|
|
334
374
|
}));
|
|
335
|
-
assert.deepEqual(params.getAll("host"), [],
|
|
336
|
-
assert.deepEqual(params.getAll("token"), [],
|
|
337
|
-
assert.notEqual(conn.get("host"), "stale.example.com",
|
|
338
|
-
assert.deepEqual(conn.getAll("token"), [CONN.token],
|
|
375
|
+
assert.deepEqual(params.getAll("host"), [], host);
|
|
376
|
+
assert.deepEqual(params.getAll("token"), [], host);
|
|
377
|
+
assert.notEqual(conn.get("host"), "stale.example.com", host);
|
|
378
|
+
assert.deepEqual(conn.getAll("token"), [CONN.token], host);
|
|
339
379
|
}
|
|
340
380
|
});
|
|
341
381
|
it("keeps params it doesn't model, like the scroll target", () => {
|
|
342
382
|
const { params } = open(buildIsolateUrl({
|
|
343
|
-
|
|
383
|
+
host: SHARED_SPA_HOST,
|
|
344
384
|
protocol: "https:",
|
|
345
385
|
search: "?scrollToSeq=42&scrollTo=1787255699631&theme=dark",
|
|
346
386
|
path: `/${NID}`,
|
|
@@ -355,7 +395,7 @@ describe("buildIsolateUrl", () => {
|
|
|
355
395
|
// This is why the URL alone isn't enough: what's in the address bar can be
|
|
356
396
|
// older than what you're looking at.
|
|
357
397
|
const { params } = open(buildIsolateUrl({
|
|
358
|
-
|
|
398
|
+
host: SHARED_SPA_HOST,
|
|
359
399
|
protocol: "https:",
|
|
360
400
|
search: "?tab=chat&pinned=chat&path=old/file.ts&line=1",
|
|
361
401
|
path: `/${NID}`,
|
|
@@ -369,7 +409,7 @@ describe("buildIsolateUrl", () => {
|
|
|
369
409
|
});
|
|
370
410
|
it("lets live state at its default clear a stale param", () => {
|
|
371
411
|
const { params } = open(buildIsolateUrl({
|
|
372
|
-
|
|
412
|
+
host: SHARED_SPA_HOST,
|
|
373
413
|
protocol: "https:",
|
|
374
414
|
search: "?tab=files&pinned=terminal&path=old/file.ts&line=1-9",
|
|
375
415
|
path: `/${NID}`,
|
|
@@ -384,7 +424,7 @@ describe("buildIsolateUrl", () => {
|
|
|
384
424
|
// The restore path deliberately skips router.setParams, so this is the
|
|
385
425
|
// case the whole overlay exists for: an empty search string, a full view.
|
|
386
426
|
const { params } = open(buildIsolateUrl({
|
|
387
|
-
|
|
427
|
+
host: SHARED_SPA_HOST,
|
|
388
428
|
protocol: "https:",
|
|
389
429
|
search: "",
|
|
390
430
|
path: `/${NID}`,
|
|
@@ -398,15 +438,15 @@ describe("buildIsolateUrl", () => {
|
|
|
398
438
|
});
|
|
399
439
|
it("accepts a search string with or without its leading ?", () => {
|
|
400
440
|
const view = viewParams("files", new Set(), "a.ts");
|
|
401
|
-
const withQ = open(buildIsolateUrl({
|
|
402
|
-
const without = open(buildIsolateUrl({
|
|
441
|
+
const withQ = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "?scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
|
|
442
|
+
const without = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
|
|
403
443
|
assert.equal(withQ.params.get("scrollToSeq"), "7");
|
|
404
444
|
assert.deepEqual([...withQ.params], [...without.params]);
|
|
405
445
|
});
|
|
406
446
|
it("encodes paths that would otherwise break the URL", () => {
|
|
407
447
|
for (const path of ["docs/a file with spaces.md", "src/c#sharp.ts", "a/b/c/d.ts", "notes/100% done.md", "x?y=z.ts", "π/unicode.ts"]) {
|
|
408
448
|
const url = buildIsolateUrl({
|
|
409
|
-
|
|
449
|
+
host: SHARED_SPA_HOST,
|
|
410
450
|
protocol: "https:",
|
|
411
451
|
search: "",
|
|
412
452
|
path: `/${NID}`,
|
|
@@ -419,9 +459,9 @@ describe("buildIsolateUrl", () => {
|
|
|
419
459
|
it("preserves every view in the grid, in both directions", () => {
|
|
420
460
|
for (const c of CASES) {
|
|
421
461
|
const view = params(c);
|
|
422
|
-
for (const
|
|
462
|
+
for (const host of [SHARED_SPA_HOST, CONN.host]) {
|
|
423
463
|
const { params: got, conn } = open(buildIsolateUrl({
|
|
424
|
-
|
|
464
|
+
host, appHost: APP_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view,
|
|
425
465
|
}));
|
|
426
466
|
assert.equal(got.get("tab"), view.tab ?? null, label(c));
|
|
427
467
|
assert.equal(got.get("pinned"), view.pinned ?? null, label(c));
|
|
@@ -439,7 +479,7 @@ describe("buildIsolateUrl", () => {
|
|
|
439
479
|
for (const c of CASES) {
|
|
440
480
|
const view = params(c);
|
|
441
481
|
const out = new URL(buildIsolateUrl({
|
|
442
|
-
|
|
482
|
+
host: SHARED_SPA_HOST,
|
|
443
483
|
protocol: "https:",
|
|
444
484
|
search: "?scrollToSeq=42",
|
|
445
485
|
path: `/${NID}`,
|
|
@@ -447,8 +487,10 @@ describe("buildIsolateUrl", () => {
|
|
|
447
487
|
view,
|
|
448
488
|
}));
|
|
449
489
|
const back = new URL(buildIsolateUrl({
|
|
450
|
-
|
|
490
|
+
host: out.host,
|
|
451
491
|
protocol: out.protocol,
|
|
492
|
+
// The daemon's bundle is told where out is, as /api/status would.
|
|
493
|
+
appHost: APP_HOST,
|
|
452
494
|
search: out.search,
|
|
453
495
|
path: `/${NID}`,
|
|
454
496
|
conn: CONN,
|
|
@@ -456,7 +498,9 @@ describe("buildIsolateUrl", () => {
|
|
|
456
498
|
// the screen does when it mounts.
|
|
457
499
|
view: viewParams(parseActiveTab(out.searchParams.get("tab") ?? undefined), parsePinnedTabs(out.searchParams.get("pinned") ?? undefined), out.searchParams.get("path") ?? undefined, parseLineSpec(out.searchParams.get("line")).line, parseLineSpec(out.searchParams.get("line")).lineEnd),
|
|
458
500
|
}));
|
|
459
|
-
|
|
501
|
+
// Back out lands where the daemon says, which is the page we left only
|
|
502
|
+
// if its app_domain names it; here it names APP_HOST.
|
|
503
|
+
assert.equal(back.origin, `https://${APP_HOST}`, label(c));
|
|
460
504
|
assert.equal(back.pathname, `/${NID}`, label(c));
|
|
461
505
|
assert.equal(back.searchParams.get("scrollToSeq"), "42", label(c));
|
|
462
506
|
const conn = new URLSearchParams(back.hash.slice(1));
|
|
@@ -477,7 +521,7 @@ describe("buildMeldUrl", () => {
|
|
|
477
521
|
// has never heard of that daemon.
|
|
478
522
|
it("stays on the shared SPA when that's where you are", () => {
|
|
479
523
|
const url = new URL(buildMeldUrl({
|
|
480
|
-
|
|
524
|
+
host: SHARED_SPA_HOST,
|
|
481
525
|
protocol: "https:",
|
|
482
526
|
search: "",
|
|
483
527
|
path: `/${NID}`,
|
|
@@ -500,7 +544,8 @@ describe("buildMeldUrl", () => {
|
|
|
500
544
|
});
|
|
501
545
|
it("stays on the daemon's own bundle when that's where you are", () => {
|
|
502
546
|
const url = new URL(buildMeldUrl({
|
|
503
|
-
|
|
547
|
+
host: CONN.host,
|
|
548
|
+
appHost: APP_HOST,
|
|
504
549
|
protocol: "https:",
|
|
505
550
|
search: "",
|
|
506
551
|
path: `/${NID}`,
|
|
@@ -516,9 +561,10 @@ describe("buildMeldUrl", () => {
|
|
|
516
561
|
});
|
|
517
562
|
it("goes the opposite way from Isolate, from either origin", () => {
|
|
518
563
|
// The whole difference between the two menu items, stated once.
|
|
519
|
-
for (const
|
|
564
|
+
for (const host of [SHARED_SPA_HOST, CONN.host]) {
|
|
520
565
|
const opts = {
|
|
521
|
-
|
|
566
|
+
host,
|
|
567
|
+
appHost: APP_HOST,
|
|
522
568
|
protocol: "https:",
|
|
523
569
|
search: "",
|
|
524
570
|
path: `/${NID}`,
|
|
@@ -527,16 +573,16 @@ describe("buildMeldUrl", () => {
|
|
|
527
573
|
};
|
|
528
574
|
const meld = new URL(buildMeldUrl(opts));
|
|
529
575
|
const isolate = new URL(buildIsolateUrl(opts));
|
|
530
|
-
assert.equal(meld.
|
|
531
|
-
assert.notEqual(isolate.
|
|
576
|
+
assert.equal(meld.host, host, `meld stays put from ${host}`);
|
|
577
|
+
assert.notEqual(isolate.host, host, `isolate flips from ${host}`);
|
|
532
578
|
}
|
|
533
579
|
});
|
|
534
580
|
it("carries the same view Isolate would, over the whole grid", () => {
|
|
535
581
|
for (const c of CASES) {
|
|
536
582
|
const view = params(c);
|
|
537
|
-
for (const
|
|
583
|
+
for (const host of [SHARED_SPA_HOST, CONN.host]) {
|
|
538
584
|
const url = new URL(buildMeldUrl({
|
|
539
|
-
|
|
585
|
+
host, appHost: APP_HOST, protocol: "https:", search: "?scrollToSeq=42", path: `/${NID}`, conn: CONN, view,
|
|
540
586
|
}));
|
|
541
587
|
assert.equal(url.searchParams.get("scrollToSeq"), "42", label(c));
|
|
542
588
|
assert.equal(new URLSearchParams(url.hash.slice(1)).get("token"), CONN.token, label(c));
|
|
@@ -550,7 +596,7 @@ describe("buildMeldUrl", () => {
|
|
|
550
596
|
it("hands out an http link when the page itself is http", () => {
|
|
551
597
|
// A LAN session without TLS must not emit https links the phone can't load.
|
|
552
598
|
const url = new URL(buildMeldUrl({
|
|
553
|
-
|
|
599
|
+
host: "192.168.1.20:7777",
|
|
554
600
|
protocol: "http:",
|
|
555
601
|
search: "",
|
|
556
602
|
path: `/${NID}`,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CONFIG_DEFAULTS } from "../commands/config.js";
|
|
7
|
+
// The shared app's host is written in two places: the route the worker is
|
|
8
|
+
// deployed to (worker/wrangler.toml) and the default `app_domain` that
|
|
9
|
+
// `brane meld` points a QR at and a daemon's bundle hops out to. A fork that
|
|
10
|
+
// changes one and not the other melds phones to a host that serves nothing,
|
|
11
|
+
// so they are pinned together here.
|
|
12
|
+
//
|
|
13
|
+
// This reads *source*, which the npm tarball does not carry, so it skips when
|
|
14
|
+
// the source is absent rather than failing `brane doctor` on every install.
|
|
15
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
const WRANGLER = join(root, "worker", "wrangler.toml");
|
|
17
|
+
describe("the worker's route", () => {
|
|
18
|
+
it("is CONFIG_DEFAULTS.app_domain, as a custom domain, with no workers.dev alias", (t) => {
|
|
19
|
+
if (!existsSync(WRANGLER))
|
|
20
|
+
return t.skip("source is not present (an installed package)");
|
|
21
|
+
const toml = readFileSync(WRANGLER, "utf8").replace(/#.*$/gm, "");
|
|
22
|
+
const routes = [...toml.matchAll(/\{\s*pattern\s*=\s*"([^"]+)"\s*,\s*custom_domain\s*=\s*true\s*\}/g)].map((m) => m[1]);
|
|
23
|
+
assert.deepEqual(routes, [CONFIG_DEFAULTS.app_domain]);
|
|
24
|
+
assert.match(toml, /^workers_dev\s*=\s*false\s*$/m);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
//# sourceMappingURL=wrangler-route.test.js.map
|
package/dist/tmux.js
CHANGED
|
@@ -171,6 +171,17 @@ function download(url, dest, redirects = 0) {
|
|
|
171
171
|
export function tmuxQuote(word) {
|
|
172
172
|
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
173
173
|
}
|
|
174
|
+
/** A request's `terminal_size`, if it is a usable grid: the size the
|
|
175
|
+
* requesting viewer's pane last measured, which a new session should start
|
|
176
|
+
* at. Anything else — absent, non-integer, out of range — is null, and the
|
|
177
|
+
* caller falls back to the last size applied to any session. */
|
|
178
|
+
export function parseTerminalSize(value) {
|
|
179
|
+
if (!value || typeof value !== "object")
|
|
180
|
+
return null;
|
|
181
|
+
const { cols, rows } = value;
|
|
182
|
+
const ok = (n) => Number.isInteger(n) && n >= 1 && n <= 10000;
|
|
183
|
+
return ok(cols) && ok(rows) ? { cols, rows } : null;
|
|
184
|
+
}
|
|
174
185
|
/** The `new-session` line `newTmuxSession` sends, as tmux will parse it. */
|
|
175
186
|
export function newSessionLine(opts) {
|
|
176
187
|
const parts = ["new-session", "-d", "-s", tmuxQuote(opts.name), "-c", tmuxQuote(opts.cwd)];
|
package/docker-entrypoint.sh
CHANGED
|
@@ -109,8 +109,18 @@ fi
|
|
|
109
109
|
# Clear stale daemon meta from the persisted volume — PIDs from a previous
|
|
110
110
|
# container life are never valid in a new container, but may collide with
|
|
111
111
|
# a live PID here (e.g., tailscaled) and trip the "already running" check.
|
|
112
|
+
#
|
|
113
|
+
# Not the ports, though. Every melded device's URL and every fold's hook
|
|
114
|
+
# config names them, so the daemon comes back on the ones it had, as it
|
|
115
|
+
# does after any restart; clearing them moved both on every boot. The main
|
|
116
|
+
# port's row still goes (the fold registration below waits for the daemon
|
|
117
|
+
# to write it back), so it is read first and handed back with --port. The
|
|
118
|
+
# HTTP fallback port's row stays: the daemon only ever takes it as a hint
|
|
119
|
+
# for what to bind, and nothing reads it while daemon_tls is unset.
|
|
120
|
+
PREV_PORT=$(gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
121
|
+
"SELECT value FROM meta WHERE key = 'daemon_port';" 2>/dev/null || true)
|
|
112
122
|
gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
113
|
-
"DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','
|
|
123
|
+
"DELETE FROM meta WHERE key IN ('daemon_pid','daemon_token','daemon_port','daemon_version','daemon_tls','daemon_socket');" \
|
|
114
124
|
2>/dev/null || true
|
|
115
125
|
|
|
116
126
|
# Register the default workspace as a fold (idempotent) once the daemon is up.
|
|
@@ -138,4 +148,7 @@ gosu brane sqlite3 /home/brane/.brane/brane.db \
|
|
|
138
148
|
# with EADDRNOTAVAIL. Binding broadly is safe here — Fly's only reachable
|
|
139
149
|
# networks are Tailscale and the private 6PN, both of which the userspace
|
|
140
150
|
# IP filter already restricts.
|
|
141
|
-
|
|
151
|
+
# --port: the port the last boot had (above). If something else holds it
|
|
152
|
+
# now, the daemon exits before writing its row back, so the next boot finds
|
|
153
|
+
# none and lets the OS pick.
|
|
154
|
+
exec gosu brane brane daemon start --foreground --bind 0.0.0.0 ${PREV_PORT:+--port "$PREV_PORT"} "$@"
|
package/docs/daemon-api.md
CHANGED
|
@@ -24,10 +24,10 @@ until someone lists it. An agent also may not revive or prompt a
|
|
|
24
24
|
|
|
25
25
|
| Method | Path | Purpose |
|
|
26
26
|
|--------|------|---------|
|
|
27
|
-
| `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`) |
|
|
27
|
+
| `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
|
|
28
28
|
| `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
|
|
29
29
|
| `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
|
|
30
|
-
| `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative |
|
|
30
|
+
| `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative; archiving a running one is a 409 (`src/daemon/archive-gate.ts`) |
|
|
31
31
|
| `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
|
|
32
32
|
| `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
|
|
33
33
|
| `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
|
|
@@ -36,9 +36,9 @@ until someone lists it. An agent also may not revive or prompt a
|
|
|
36
36
|
| `GET` | `/api/folds?archived=1` | List registered fold directories (includes brane_id; archived hidden unless asked) |
|
|
37
37
|
| `POST` | `/api/folds` | Register a fold (`{ path, brane_id? }`) — idempotent; see [Folds API](#folds-api) for who may register what |
|
|
38
38
|
| `PATCH` | `/api/folds/archive` | Soft-hide/unhide a fold (`{ path, archived }`) — never deletes the row |
|
|
39
|
-
| `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args? }`) — used by both the web app and `brane spawn` |
|
|
39
|
+
| `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }`) — used by both the web app and `brane spawn` |
|
|
40
40
|
| `GET` | `/api/spawn-history` | Recent distinct `launch_args`, most-recent first (spawn dialog quick-fill) |
|
|
41
|
-
| `POST` | `/api/run` | Run non-agent command (`{ command, fold_path }`) |
|
|
41
|
+
| `POST` | `/api/run` | Run non-agent command (`{ command, fold_path, terminal_size? }`) |
|
|
42
42
|
| `GET` | `/api/run-history` | Commands utility sessions have run before (by basename), most-recent first — the spawn dialog's Utility list; derived from `narratives`, nothing stored |
|
|
43
43
|
| `POST` | `/api/revive/:nid` | Revive a dead agent session (`{ prompt?, no_prompt?, agent? }`) — see [Revive API](#revive-api) |
|
|
44
44
|
| `POST` | `/api/search` | Semantic search over narrative history (`{ query, limit?, brane_ids? }`) |
|
|
@@ -85,7 +85,7 @@ Control WebSocket commands (client → server):
|
|
|
85
85
|
|
|
86
86
|
## Spawn API
|
|
87
87
|
|
|
88
|
-
`POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args? }` and returns the new narrative object with `mcp_connected` status. The fold picker modal lets the operator choose which registered fold to spawn into.
|
|
88
|
+
`POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }` and returns the new narrative object with `mcp_connected` status. `terminal_size` (`{ cols, rows }`) is the grid the requesting viewer's terminal pane last measured; the session starts at it rather than at the last size applied to any session, so its first screen is not redrawn through a resize — see [Launch residue in scrollback](terminal-mirroring.md#launch-residue-in-scrollback). `/api/run` takes it too. The fold picker modal lets the operator choose which registered fold to spawn into. The dialog's Agent list is the built-in adapters plus an "Other…" entry that takes any agent name, as `brane spawn <agent>` does: the daemon accepts any `[A-Za-z0-9_-]+` name it can find with `which`, and an agent without an adapter gets tmux, MCP config and mirroring but no session resumption. What is typed there splits like the args box, so the first word is the agent and the rest lead its `extra_args`.
|
|
89
89
|
|
|
90
90
|
This is the **only** spawn path. The web UI calls it, and so does `brane spawn` — the CLI resolves a daemon (starting one if needed), POSTs here with `fold_path` set from `cwd` and `-- <args>` as `extra_args`, and then does only terminal-shaped work with the response: `--meld`'s QR code and `--attach`. It does not open the database or create tmux sessions itself, so tmux bootstrap (`ensureTmux`), terminal sizing, and MCP config writing happen once, daemon-side, identically for both callers. Two consequences follow from that and are intended: the agent binary is resolved against the *daemon's* `PATH`, and MCP config files point at the checkout the *daemon* runs from — not the CLI you invoked.
|
|
91
91
|
|
package/docs/mcp.md
CHANGED
|
@@ -66,4 +66,4 @@ Core tools available via MCP:
|
|
|
66
66
|
|
|
67
67
|
Narratives have persistent `summary` and `title` columns. Agents set them via the `meta` parameter on `narrate`. The daemon broadcasts metadata changes over the control WebSocket as `type: "meta"` messages. The Expo app renders the summary as a subtitle below the narrative ID.
|
|
68
68
|
|
|
69
|
-
Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database.
|
|
69
|
+
Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database. Only a narrative whose session is over can be archived: the daemon answers 409 while its status isn't `ended`, an agent is on its bridge, or its tmux session is up (`src/daemon/archive-gate.ts`), so archiving can't hide a running agent. Unarchiving is never refused.
|
package/docs/releasing.md
CHANGED
|
@@ -6,15 +6,15 @@ with a short-lived OIDC token ([npm trusted
|
|
|
6
6
|
publishing](https://docs.npmjs.com/trusted-publishers)). **Agents never run
|
|
7
7
|
`npm publish`**, from a checkout or anywhere else.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
`npm publish` is not passed `--provenance`, and needs no flag for it.
|
|
10
|
+
Trusted publishing attests a build by itself when the repository is public
|
|
11
|
+
and leaves attestation out when it is private, where `--provenance` would
|
|
12
|
+
fail the publish on a 422. 0.3.0 and 0.3.1 were published while the
|
|
13
|
+
repository was still private, so they carry **no provenance attestation**;
|
|
14
|
+
every release after the repository goes public will, with no workflow
|
|
15
|
+
change. Check `gh repo view --json visibility` before a release's notes
|
|
16
|
+
say which it is: the 0.3.1 CHANGELOG entry in its tarball claims
|
|
17
|
+
provenance it does not have.
|
|
18
18
|
|
|
19
19
|
The trigger is a **published GitHub release**, not a tag push, so the release
|
|
20
20
|
notes and the publish are one act and a stray tag cannot publish anything.
|
|
@@ -51,3 +51,56 @@ where it was.
|
|
|
51
51
|
`src/cli.ts` reads its version from `package.json` at startup, so
|
|
52
52
|
`brane --version` reports whatever was published rather than a string that
|
|
53
53
|
has to be bumped by hand.
|
|
54
|
+
|
|
55
|
+
## The app ships with the release
|
|
56
|
+
|
|
57
|
+
The shared app (the `brane` worker in `worker/wrangler.toml`, at
|
|
58
|
+
`app_domain`'s default host) serves the web app of the version npm calls
|
|
59
|
+
`latest`, and nothing else. A merge to `main` deploys nothing: `ci.yml` only
|
|
60
|
+
tests.
|
|
61
|
+
|
|
62
|
+
- After an ordinary release publishes, `release.yml`'s `deploy-app` job runs
|
|
63
|
+
`.github/workflows/deploy-app.yml` with the release's version. A pre-release
|
|
64
|
+
deploys nothing.
|
|
65
|
+
- `deploy-app.yml` does not build. It waits until the registry has the version
|
|
66
|
+
and names it `latest` (right after a publish either can lag by up to a
|
|
67
|
+
minute), unpacks `npm pack <name>@<version>`, checks the tarball's name,
|
|
68
|
+
version and `expo/dist/index.html`, runs the precache check, and
|
|
69
|
+
`wrangler deploy`s that `expo/dist`. The shared app is therefore the same
|
|
70
|
+
bytes every daemon of that version installs and serves itself, by
|
|
71
|
+
construction rather than by a second build that is supposed to match.
|
|
72
|
+
- It is its own job because a publish cannot be undone and a deploy can be
|
|
73
|
+
repeated: a Cloudflare failure is a re-run of that job, never a second
|
|
74
|
+
release.
|
|
75
|
+
- **Redeploy or roll back** by dispatching `deploy-app.yml` from the Actions
|
|
76
|
+
tab with a version. It refuses a version that is not `latest`, so a rollback
|
|
77
|
+
is `npm dist-tag add brane@<version> latest` (the operator's, like everything
|
|
78
|
+
on the registry) and then the dispatch. The dispatch checks out the branch it
|
|
79
|
+
is run from, so an old version's bundle is deployed with *that branch's*
|
|
80
|
+
`wrangler.toml` and precache check, not its tag's: the route is where the
|
|
81
|
+
app lives whatever its version. A version that isn't `latest` is refused
|
|
82
|
+
only after the registry-lag retries, about four minutes in.
|
|
83
|
+
- **A `wrangler.toml` change is first exercised by the next release's
|
|
84
|
+
deploy.** A merge deploys nothing and `wrangler deploy --dry-run` doesn't
|
|
85
|
+
talk to Cloudflare, so watch that release's `deploy-app` job. Declaring the
|
|
86
|
+
custom domain needs zone permissions on `CLOUDFLARE_API_TOKEN`, not only
|
|
87
|
+
Workers ones; if the job fails there, the fix is the token and a re-run, and
|
|
88
|
+
the publish before it stands.
|
|
89
|
+
|
|
90
|
+
**Staging is Isolate.** A daemon running `main` (after `/rrr`) serves its own
|
|
91
|
+
bundle, built from that checkout, so Isolate on it shows everything merged
|
|
92
|
+
since the last release, together, before a release promotes it to the shared
|
|
93
|
+
app. Every daemon serves its own version's app the same way, which is also
|
|
94
|
+
what an older install has: `brane meld --local`, or Isolate, gives it the app
|
|
95
|
+
it shipped with. Between releases, then, a daemon running `main` is newer than
|
|
96
|
+
the shared app, the ordinary case of an older app on a newer daemon.
|
|
97
|
+
|
|
98
|
+
The deploy names no host and no package: the package name comes from
|
|
99
|
+
`package.json`, and the route is declared in `worker/wrangler.toml` as a
|
|
100
|
+
custom domain, with `workers_dev = false` so it is the only place the shared
|
|
101
|
+
app is served and the Cloudflare dashboard holds nothing the repository
|
|
102
|
+
doesn't. That route is `CONFIG_DEFAULTS.app_domain`, the host `brane meld`
|
|
103
|
+
points at, and `src/test/wrangler-route.test.ts` keeps the two equal. A fork
|
|
104
|
+
that publishes under its own name and serves its own app changes `name` in
|
|
105
|
+
`package.json`, `name` and the route in `wrangler.toml`, and
|
|
106
|
+
`CONFIG_DEFAULTS.app_domain`.
|
|
@@ -209,3 +209,35 @@ left a hairline between lit rows. Each row starts on a fractional pixel, and
|
|
|
209
209
|
each layer anti-aliased its own edge onto the shared boundary pixel. A
|
|
210
210
|
full-cell gradient with hard stops (linear for bars, conic for quadrants)
|
|
211
211
|
has the span's own edges, and the browser snaps those to whole pixels.
|
|
212
|
+
|
|
213
|
+
## Launch residue in scrollback
|
|
214
|
+
|
|
215
|
+
The mirror shows the pane's whole scrollback (`capture-pane -S -`), so
|
|
216
|
+
anything a TUI drew and abandoned before the session settled is on screen for
|
|
217
|
+
good. The case that prompted this section: a new fold opened under a stack of
|
|
218
|
+
yellow `────` rules and three copies of "Accessing workspace". It was Claude
|
|
219
|
+
Code's folder-trust dialog, redrawn at each width the window passed through:
|
|
220
|
+
first created at `lastTerminalSize` (the last size applied to *any* session),
|
|
221
|
+
then fitted to the viewer. Every stale frame went up into history, reflowed,
|
|
222
|
+
its full-width rules now wider than the pane. The live screen was clean; the
|
|
223
|
+
input box was pushed off the bottom only because the history above it was
|
|
224
|
+
tall and wide.
|
|
225
|
+
|
|
226
|
+
Two things address it, and neither touches the live screen:
|
|
227
|
+
|
|
228
|
+
- **Start at the viewer's size.** The app remembers the grid its terminal pane
|
|
229
|
+
last measured (`lastTerminalSize()`, `expo/lib/terminal.ts`) and sends it as
|
|
230
|
+
`terminal_size` on `POST /api/spawn` and `POST /api/run`. The daemon starts
|
|
231
|
+
the tmux session at that grid when it is usable (`parseTerminalSize`,
|
|
232
|
+
`src/tmux.ts`, pinned by `src/test/terminal-size.test.ts`) and falls back to
|
|
233
|
+
`lastTerminalSize` otherwise, which is still what `brane spawn` from a shell
|
|
234
|
+
gets. A session that launches at the size it is watched at isn't resized
|
|
235
|
+
through a TUI's first screen.
|
|
236
|
+
- **Clear scrollback, on request.** The 🦾 menu's last row, `Clear 🧽`, sends
|
|
237
|
+
`{ type: "clear_history" }` on the terminal socket; the daemon runs `tmux
|
|
238
|
+
clear-history` on the pane and forces a capture. The client trims to the
|
|
239
|
+
frame's `totalLines`, so the stale lines go with the next frame. It discards
|
|
240
|
+
history, so it is only ever the operator's explicit action, never automatic.
|
|
241
|
+
|
|
242
|
+
A fold Claude has never been trusted in still shows the dialog once. Accepting
|
|
243
|
+
it on the operator's behalf was considered and left for the operator to decide.
|
package/docs/token-rotation.md
CHANGED
|
@@ -208,8 +208,10 @@ Cline's per-session files) meant no harness's plumbing had to change.
|
|
|
208
208
|
nothing. A connected external client (no tmux session) is not adopted,
|
|
209
209
|
and lands on a fresh `external-*` narrative once, at the upgrade.
|
|
210
210
|
Adopted agent rows don't trigger the plaintext-token warning.
|
|
211
|
-
- **Archiving revokes** a narrative's agent token
|
|
212
|
-
|
|
211
|
+
- **Archiving revokes** a narrative's agent token. Only an ended narrative
|
|
212
|
+
can be archived (the daemon answers 409 otherwise; see
|
|
213
|
+
`src/daemon/archive-gate.ts`), so there is never a live session to cut
|
|
214
|
+
off. Unarchiving brings nothing back; a revive mints a new one.
|
|
213
215
|
- **An external goes with its dialer, always.** An external's token
|
|
214
216
|
records the token that dialed it (`minted_by`); it is that token's
|
|
215
217
|
credential in another form, since one `/ws/mcp` dial mints it, so
|
|
@@ -319,6 +321,16 @@ device. Less obvious than it looks:
|
|
|
319
321
|
both prefer the daemon's Unix socket, recorded as `daemon.sock` since it
|
|
320
322
|
has no address (`connectionLabel`); one token used by any of them is not
|
|
321
323
|
a copy.
|
|
324
|
+
- **Which app a device runs is recorded too** (`app_origin`,
|
|
325
|
+
`last_app_origin`): the request's `Origin`, first and latest, shown as
|
|
326
|
+
"via main.brane.organelle.dev", or "via mac.tail1234.ts.net:47443" for a
|
|
327
|
+
daemon's own bundle. Browsers omit `Origin` on same-origin GETs, so the
|
|
328
|
+
first request that names a device often cannot name its app; the store
|
|
329
|
+
writes the first `Origin` it is given, which in practice is the control
|
|
330
|
+
socket's upgrade, and never overwrites it. It is a label, not a gate:
|
|
331
|
+
the token is the credential and the daemon accepts any origin
|
|
332
|
+
(`SECURITY.md`, Authentication), which is what lets a staging hostname
|
|
333
|
+
or a fork's deployment front the same daemon with nothing to configure.
|
|
322
334
|
- **The address is the only field a token holder cannot choose.** The
|
|
323
335
|
User-Agent and the label are both free text from whoever holds the
|
|
324
336
|
token. So the app never uses the label as a row's title — a row with a
|
|
@@ -404,10 +416,18 @@ should guard revoking, as it will guard adding a device, is open: see
|
|
|
404
416
|
- A revoke does not cut a streaming response already in flight (the Files
|
|
405
417
|
search and `POST /api/absorb`, both NDJSON). Each is bounded work, not a
|
|
406
418
|
listener, and ends on its own.
|
|
407
|
-
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
419
|
+
- There is no origin check, by design: every request must present a token,
|
|
420
|
+
and the origin it came from is recorded on the token rather than judged.
|
|
421
|
+
A passkey verifier will need its own exact origin list
|
|
422
|
+
([Passkeys](passkeys.md)); the origins recorded per token are where one
|
|
423
|
+
would come from.
|
|
424
|
+
- A token is not bound to the origin it was first used from. Pinning it
|
|
425
|
+
(refusing a different `Origin` afterwards) was considered and left out:
|
|
426
|
+
Isolate carries the same token between the hosted shell and a daemon's
|
|
427
|
+
own bundle ([View URLs](view-urls.md)), so the daemon's own origin would need
|
|
428
|
+
an exemption, and since `?token=` is taken on any method a pin would
|
|
429
|
+
protect only reads. If a phished link ever needs more than revocation,
|
|
430
|
+
this is the lever (claude-wh3d2hz2:20, point 4).
|
|
411
431
|
- Fold registration treats a loopback peer as the operator's shell, so a
|
|
412
432
|
forwarder to 127.0.0.1 (`tailscale serve`, an SSH tunnel) would make a
|
|
413
433
|
remote caller look local.
|