brane 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +56 -4
  3. package/CONTRIBUTING.md +12 -2
  4. package/DESIGN.md +7 -4
  5. package/README.md +2 -2
  6. package/SECURITY.md +8 -0
  7. package/dist/commands/config.js +17 -0
  8. package/dist/commands/meld.js +38 -10
  9. package/dist/commands/spawn.js +11 -4
  10. package/dist/commands/token.js +2 -1
  11. package/dist/daemon/archive-gate.js +11 -0
  12. package/dist/daemon/server.js +112 -89
  13. package/dist/db/schema.js +10 -0
  14. package/dist/db/tokens.js +43 -2
  15. package/dist/shared/app-origin.js +26 -0
  16. package/dist/shared/narrative-menu.js +16 -11
  17. package/dist/shared/view-url.js +39 -20
  18. package/dist/test/app-host-literal.test.js +53 -0
  19. package/dist/test/archive-gate.test.js +22 -0
  20. package/dist/test/cors-preflight.test.js +28 -21
  21. package/dist/test/hook-config.test.js +38 -1
  22. package/dist/test/meld.test.js +59 -1
  23. package/dist/test/narrative-menu.test.js +30 -3
  24. package/dist/test/remote-meld.test.js +13 -0
  25. package/dist/test/terminal-size.test.js +36 -0
  26. package/dist/test/token-devices.test.js +59 -0
  27. package/dist/test/view-url.test.js +93 -47
  28. package/dist/test/wrangler-route.test.js +27 -0
  29. package/dist/tmux.js +11 -0
  30. package/docker-entrypoint.sh +15 -2
  31. package/docs/daemon-api.md +5 -5
  32. package/docs/mcp.md +1 -1
  33. package/docs/releasing.md +62 -9
  34. package/docs/terminal-mirroring.md +32 -0
  35. package/docs/token-rotation.md +26 -6
  36. package/docs/view-urls.md +32 -7
  37. package/docs/web-app.md +2 -2
  38. package/expo/dist/_expo/static/js/web/{entry-e56efc1291dca7258ea00b8108b033a4.js → entry-807575a48b8a2f18f8bffe53c8b35fbd.js} +47 -45
  39. package/expo/dist/index.html +1 -1
  40. package/expo/dist/sw.js +2 -2
  41. package/package.json +1 -1
  42. package/wiki/shared/index.md +5 -0
@@ -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
@@ -0,0 +1,22 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { archiveRefusal } from "../daemon/archive-gate.js";
4
+ const ended = { status: "ended", mcpConnected: false, tmuxLive: false };
5
+ describe("archiveRefusal", () => {
6
+ it("allows an ended narrative with nothing running", () => {
7
+ assert.equal(archiveRefusal(ended), null);
8
+ });
9
+ it("refuses a narrative whose status is not ended", () => {
10
+ assert.ok(archiveRefusal({ ...ended, status: "active" }));
11
+ assert.ok(archiveRefusal({ ...ended, status: null }));
12
+ });
13
+ it("refuses an ended row with an agent still on the bridge", () => {
14
+ // The stale row the menu also treats as running: hiding it would hide a
15
+ // live agent.
16
+ assert.match(archiveRefusal({ ...ended, mcpConnected: true }) ?? "", /connected/);
17
+ });
18
+ it("refuses an ended row whose tmux session is still up", () => {
19
+ assert.match(archiveRefusal({ ...ended, tmuxLive: true }) ?? "", /running/);
20
+ });
21
+ });
22
+ //# sourceMappingURL=archive-gate.test.js.map
@@ -12,37 +12,44 @@ function exchange(origin) {
12
12
  };
13
13
  return { req, res, headers };
14
14
  }
15
- const APP_DOMAIN = "brane.organelle.dev";
16
- describe("CORS preflight caching", () => {
17
- test("an allowed origin is told how long it may reuse the answer", () => {
15
+ describe("CORS: the token is the credential, not the origin", () => {
16
+ test("any origin is answered the same way, and no origin is reflected", () => {
17
+ // The daemon does not care which hostname serves the app: the hosted
18
+ // shell, a staging hostname, a fork's deployment, its own bundle. A page
19
+ // with no token gets a 401 whatever its origin, and a page with one has
20
+ // the standing of curl with that token. So the answer is `*` for all of
21
+ // them — never the request's own origin echoed back, which with
22
+ // credentials would be the one dangerous combination.
23
+ for (const origin of ["https://brane.organelle.dev", "https://main.brane.organelle.dev", "https://fork.example", "http://localhost:8081"]) {
24
+ const { req, res, headers } = exchange(origin);
25
+ setCorsHeaders(req, res);
26
+ assert.equal(headers.get("access-control-allow-origin"), "*", origin);
27
+ assert.match(headers.get("access-control-allow-methods") ?? "", /GET/);
28
+ assert.match(headers.get("access-control-allow-headers") ?? "", /Authorization/);
29
+ }
30
+ });
31
+ test("credentials are never allowed", () => {
32
+ // Brane sets no cookies, so the header would grant nothing — except that
33
+ // a browser refuses `*` for a credentialed request, which is the guard
34
+ // worth keeping. Sending `Allow-Credentials: true` would turn it off.
35
+ const { req, res, headers } = exchange("https://brane.organelle.dev");
36
+ setCorsHeaders(req, res);
37
+ assert.equal(headers.get("access-control-allow-credentials"), undefined);
38
+ });
39
+ test("a preflight answer may be reused for at least an hour", () => {
18
40
  // Without this header Chrome caches a preflight for 5 seconds, which on a
19
41
  // fast link is indistinguishable from caching forever and on a slow one is
20
42
  // indistinguishable from not caching at all — every request pays its own
21
43
  // extra round trip. At 741ms RTT that was fatal rather than merely slow.
22
- const { req, res, headers } = exchange(`https://${APP_DOMAIN}`);
23
- setCorsHeaders(req, res, APP_DOMAIN, 8080);
44
+ const { req, res, headers } = exchange("https://brane.organelle.dev");
45
+ setCorsHeaders(req, res);
24
46
  const maxAge = headers.get("access-control-max-age");
25
47
  assert.ok(maxAge, "preflight answers must be cacheable");
26
48
  assert.ok(Number(maxAge) >= 3600, `max-age ${maxAge} is too short to spare a slow link repeated preflights`);
27
49
  });
28
- test("the rest of the CORS contract is unchanged", () => {
29
- const { req, res, headers } = exchange(`https://${APP_DOMAIN}`);
30
- setCorsHeaders(req, res, APP_DOMAIN, 8080);
31
- assert.equal(headers.get("access-control-allow-origin"), `https://${APP_DOMAIN}`);
32
- assert.match(headers.get("access-control-allow-methods") ?? "", /GET/);
33
- assert.match(headers.get("access-control-allow-headers") ?? "", /Authorization/);
34
- assert.equal(headers.get("access-control-allow-credentials"), "true");
35
- });
36
- test("a disallowed origin is told nothing at all", () => {
37
- // Caching must not become a way to hand headers to an origin that would
38
- // otherwise have been refused.
39
- const { req, res, headers } = exchange("https://evil.example.com");
40
- setCorsHeaders(req, res, APP_DOMAIN, 8080);
41
- assert.equal(headers.size, 0);
42
- });
43
50
  test("a request with no Origin gets no CORS headers", () => {
44
51
  const { req, res, headers } = exchange();
45
- setCorsHeaders(req, res, APP_DOMAIN, 8080);
52
+ setCorsHeaders(req, res);
46
53
  assert.equal(headers.size, 0);
47
54
  });
48
55
  });
@@ -2,9 +2,10 @@ import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { mkdtempSync, readFileSync, existsSync, writeFileSync, mkdirSync, statSync, chmodSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { join } from "node:path";
5
+ import { join, resolve } from "node:path";
6
6
  import { createTestDb } from "./helpers.js";
7
7
  import { ensureClaudeHookConfig, ensureGooseHookConfig, ensureClineHookConfig, ensureHookConfig, } from "../commands/spawn.js";
8
+ import { reviveNarrative } from "../daemon/server.js";
8
9
  const HOOK_PATH = "/api/hooks/tool-use";
9
10
  function seedDaemon(db, port, httpPort) {
10
11
  const rows = [
@@ -259,4 +260,40 @@ describe("ensureHookConfig", () => {
259
260
  assert.doesNotThrow(() => ensureHookConfig(db, "goose", "/dev/null/nope", "claude-nid00006"));
260
261
  });
261
262
  });
263
+ describe("reviveNarrative", () => {
264
+ // A daemon that comes back on another port leaves every fold's hook config
265
+ // naming the dead one. Fresh spawns rewrote it; a same-agent revive didn't,
266
+ // so on Fly, where every boot moved the port, revived sessions failed every
267
+ // tool call's hook (claude-nkrks0of:23).
268
+ it("repoints a stale hook config before it launches anything", (t) => {
269
+ // With no `claude` reachable the revive fails at resolving the agent,
270
+ // after the hook config and before any token or tmux session exists.
271
+ // resolveAgentCommand's fallback is node_modules/.bin two levels above
272
+ // the entry script; if one is there, this would launch a real agent.
273
+ const fallback = resolve(process.argv[1], "../..", "node_modules", ".bin", "claude");
274
+ if (existsSync(fallback))
275
+ return t.skip(`${fallback} exists`);
276
+ const db = createTestDb();
277
+ seedDaemon(db, "4321", "46347");
278
+ const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
279
+ const stale = `http://127.0.0.1:39287${HOOK_PATH}`;
280
+ mkdirSync(join(fold, ".claude"));
281
+ writeFileSync(join(fold, ".claude", "settings.local.json"), JSON.stringify({
282
+ hooks: { PostToolUse: [{ matcher: ".*", hooks: [{ type: "http", url: stale }] }] },
283
+ }));
284
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES ('claude-rev00001', 'brane-test', 'claude', 'worker', 'ended', ?, ?)").run(fold, Date.now());
285
+ const path = process.env.PATH;
286
+ process.env.PATH = mkdtempSync(join(tmpdir(), "brane-empty-path-"));
287
+ try {
288
+ assert.throws(() => reviveNarrative(db, "claude-rev00001"), /Agent command not found: claude/);
289
+ }
290
+ finally {
291
+ process.env.PATH = path;
292
+ }
293
+ const settings = readSettings(fold);
294
+ for (const event of ["PreToolUse", "PostToolUse"]) {
295
+ assert.equal(settings.hooks[event][0].hooks[0].url, `http://127.0.0.1:46347${HOOK_PATH}`, event);
296
+ }
297
+ });
298
+ });
262
299
  //# sourceMappingURL=hook-config.test.js.map
@@ -1,7 +1,9 @@
1
1
  import { describe, it, before, after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { createTestDb } from "./helpers.js";
4
- import { buildMeldUrl } from "../commands/meld.js";
4
+ import { buildMeldUrl, meldLink } from "../commands/meld.js";
5
+ import { cleanAppHost } from "../shared/app-origin.js";
6
+ import { statusAppDomain } from "../daemon/server.js";
5
7
  function setTls(db, value) {
6
8
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_tls', ?)").run(value ? "true" : "false");
7
9
  }
@@ -73,6 +75,33 @@ describe("buildMeldUrl", () => {
73
75
  assert.equal(r.tlsDegraded, false);
74
76
  assert.ok(r.daemonHost.startsWith("127.0.0.1"));
75
77
  });
78
+ it("points at the app_domain config, which --app overrides, and a literal never decides", () => {
79
+ setTls(db, true);
80
+ // Default: the shipped default, brane's own shell.
81
+ db.prepare("DELETE FROM meta WHERE key = 'config.app_domain'").run();
82
+ assert.equal(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).appHost, "brane.organelle.dev");
83
+ // `brane config set app_domain …`: a staging hostname, or a fork's shell.
84
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', 'main.brane.organelle.dev')").run();
85
+ const configured = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
86
+ assert.equal(configured.appHost, "main.brane.organelle.dev");
87
+ assert.equal(new URL(configured.url).host, "main.brane.organelle.dev");
88
+ assert.equal(new URLSearchParams(new URL(configured.url).hash.slice(1)).get("host"), `${TS_HOST}:${daemon.port}`);
89
+ // --app, for one QR.
90
+ const once = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, app: "https://fork.example/" });
91
+ assert.equal(once.appHost, "fork.example");
92
+ assert.equal(new URL(once.url).origin, "https://fork.example");
93
+ // --local ignores it: the QR is the daemon's own.
94
+ const local = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, local: true, app: "fork.example" });
95
+ assert.equal(new URL(local.url).host, `${TS_HOST}:${daemon.port}`);
96
+ db.prepare("DELETE FROM meta WHERE key = 'config.app_domain'").run();
97
+ });
98
+ it("meldLink falls back to the shipped default for a reply that names no app host", () => {
99
+ // A remote brane from before appHost was part of `--connection`'s reply.
100
+ const url = meldLink({ daemonHost: "h.ts.net:8443", scheme: "https", useLocal: false }, "tok");
101
+ assert.equal(new URL(url).host, "brane.organelle.dev");
102
+ const named = meldLink({ daemonHost: "h.ts.net:8443", scheme: "https", useLocal: false, appHost: "fork.example" }, "tok");
103
+ assert.equal(new URL(named).host, "fork.example");
104
+ });
76
105
  it("does not flag tlsDegraded when advertising a custom --host", () => {
77
106
  // A custom host that isn't the Tailscale hostname isn't a degraded TLS
78
107
  // case — the operator chose to advertise something else.
@@ -82,4 +111,33 @@ describe("buildMeldUrl", () => {
82
111
  assert.equal(r.isLoopbackFallback, false);
83
112
  });
84
113
  });
114
+ describe("cleanAppHost", () => {
115
+ it("takes a bare host, or the host of a URL, lower-cased, port kept", () => {
116
+ assert.equal(cleanAppHost("brane.organelle.dev"), "brane.organelle.dev");
117
+ assert.equal(cleanAppHost(" Main.Brane.Organelle.dev "), "main.brane.organelle.dev");
118
+ assert.equal(cleanAppHost("https://fork.example/"), "fork.example");
119
+ assert.equal(cleanAppHost("http://localhost:8081/claude-abc?x=1#y"), "localhost:8081");
120
+ assert.equal(cleanAppHost("fork.example/path"), "fork.example");
121
+ });
122
+ });
123
+ describe("statusAppDomain", () => {
124
+ it("reports the config reduced to a host, as meld reads it, so Isolate and the QR agree", () => {
125
+ const db = createTestDb();
126
+ // With a cert, so the QR is the shared app's and names the host.
127
+ setTls(db, true);
128
+ assert.equal(statusAppDomain(db), "brane.organelle.dev");
129
+ // Stored as a URL by `brane config`: the app must not build https://https://…
130
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', 'https://staging.example/')").run();
131
+ assert.equal(statusAppDomain(db), "staging.example");
132
+ assert.equal(statusAppDomain(db), buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).appHost);
133
+ // Blanked: means the default, on both sides, never a QR for https:///.
134
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('config.app_domain', ' ')").run();
135
+ assert.equal(statusAppDomain(db), "brane.organelle.dev");
136
+ const blank = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
137
+ assert.equal(blank.appHost, "brane.organelle.dev");
138
+ assert.equal(new URL(blank.url).host, "brane.organelle.dev");
139
+ assert.equal(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, app: "" }).appHost, "brane.organelle.dev");
140
+ db.close();
141
+ });
142
+ });
85
143
  //# sourceMappingURL=meld.test.js.map
@@ -69,7 +69,6 @@ describe("narrativeMenuItems", () => {
69
69
  "Recolor",
70
70
  "Twin",
71
71
  "End",
72
- "Archive",
73
72
  ]);
74
73
  });
75
74
  it("swaps End for Revive once the session is over", () => {
@@ -93,8 +92,23 @@ describe("narrativeMenuItems", () => {
93
92
  assert.ok(labels({ role: "absorbed", status: "ended" }).includes("Revive"));
94
93
  });
95
94
  it("flips the archive item both ways", () => {
96
- assert.ok(labels({ archived: false }).includes("Archive"));
97
- assert.ok(labels({ archived: true }).includes("Unarchive"));
95
+ assert.ok(labels({ status: "ended", archived: false }).includes("Archive"));
96
+ assert.ok(labels({ status: "ended", archived: true }).includes("Unarchive"));
97
+ });
98
+ it("offers Archive only once the session has ended", () => {
99
+ const running = labels({ status: "active", mcpConnected: true });
100
+ assert.ok(!running.includes("Archive") && !running.includes("Unarchive"));
101
+ // The same stale-row test as Revive: an ended row with an agent still on
102
+ // the bridge is running, and isn't archivable yet.
103
+ assert.ok(!labels({ status: "ended", mcpConnected: true }).includes("Archive"));
104
+ assert.ok(labels({ status: "ended" }).includes("Archive"));
105
+ });
106
+ it("always offers Unarchive on an archived narrative", () => {
107
+ for (const status of ["active", "ended", null]) {
108
+ for (const mcpConnected of [true, false]) {
109
+ assert.ok(labels({ archived: true, status, mcpConnected }).includes("Unarchive"), `status: ${status}, mcpConnected: ${mcpConnected}`);
110
+ }
111
+ }
98
112
  });
99
113
  it("flips isolate by where the app is served from", () => {
100
114
  assert.ok(labels({ isIsolated: false }).includes("Isolate"));
@@ -134,4 +148,17 @@ describe("revivesAsTwin", () => {
134
148
  }
135
149
  });
136
150
  });
151
+ describe("narrativeMenuItems — Isolate", () => {
152
+ it("reads Isolate on a shared app and Unisolate on a daemon's own bundle", () => {
153
+ assert.ok(labels().includes("Isolate"));
154
+ assert.ok(labels({ isIsolated: true }).includes("Unisolate"));
155
+ });
156
+ it("is left out when there is nowhere to go, and nothing else moves", () => {
157
+ // A daemon's own bundle whose daemon reported no app host: offering the
158
+ // item would mean a link to a host the app made up.
159
+ const without = labels({ isIsolated: true, canIsolate: false });
160
+ assert.ok(!without.includes("Isolate") && !without.includes("Unisolate"));
161
+ assert.deepEqual(without, labels({ isIsolated: true }).filter((l) => l !== "Unisolate"));
162
+ });
163
+ });
137
164
  //# sourceMappingURL=narrative-menu.test.js.map
@@ -90,6 +90,19 @@ describe("the remote daemon's address", () => {
90
90
  const loopback = readMeldReply(JSON.stringify({ ready: true, ...c, daemonHost: "127.0.0.1:8443", isLoopbackFallback: true }));
91
91
  assert.deepEqual(loopback, { kind: "wait", reason: "the remote machine has no Tailscale name yet" });
92
92
  });
93
+ it("follows the remote daemon's app host, end to end, and ignores one that is not a string", () => {
94
+ // What `brane meld --connection` prints on a remote whose app_domain
95
+ // names a fork's shell, read back by `--remote` and turned into the QR.
96
+ const reply = readMeldReply(JSON.stringify({ ready: true, ...c, appHost: "https://Fork.example/" }));
97
+ assert.equal(reply.kind, "address");
98
+ if (reply.kind !== "address")
99
+ return;
100
+ assert.equal(reply.connection.appHost, "fork.example");
101
+ assert.equal(new URL(meldLink(reply.connection, "tok")).host, "fork.example");
102
+ // A remote that sends something other than a string names no shell.
103
+ const odd = readMeldReply(JSON.stringify({ ready: true, ...c, appHost: 42 }));
104
+ assert.equal(odd.kind === "address" && odd.connection.appHost, undefined);
105
+ });
93
106
  it("calls an answer with no JSON line an older brane, which ran a plain meld", () => {
94
107
  assert.deepEqual(readMeldReply("\nScan this QR code to meld:\n█▀▀▀▀▀█\n"), { kind: "old" });
95
108
  assert.deepEqual(readMeldReply('{"daemonHost": 1}'), { kind: "old" });
@@ -0,0 +1,36 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseTerminalSize, newSessionLine } from "../tmux.js";
4
+ // The grid a spawn or run request carries: the size the requesting viewer's
5
+ // pane last measured, so the session starts at the width it will be watched at
6
+ // and a TUI's launch screen isn't redrawn — and left in scrollback — at each
7
+ // width it is resized through.
8
+ test("parseTerminalSize accepts a positive integer grid", () => {
9
+ assert.deepEqual(parseTerminalSize({ cols: 80, rows: 24 }), { cols: 80, rows: 24 });
10
+ });
11
+ test("parseTerminalSize drops extra fields", () => {
12
+ assert.deepEqual(parseTerminalSize({ cols: 120, rows: 40, dpr: 2 }), { cols: 120, rows: 40 });
13
+ });
14
+ test("parseTerminalSize rejects anything that isn't a usable grid", () => {
15
+ for (const bad of [
16
+ undefined,
17
+ null,
18
+ "80x24",
19
+ [80, 24],
20
+ {},
21
+ { cols: 80 },
22
+ { cols: 0, rows: 24 },
23
+ { cols: 80, rows: -1 },
24
+ { cols: 80.5, rows: 24 },
25
+ { cols: "80", rows: "24" },
26
+ { cols: 80, rows: 100000 },
27
+ ]) {
28
+ assert.equal(parseTerminalSize(bad), null, JSON.stringify(bad));
29
+ }
30
+ });
31
+ test("a parsed size reaches new-session as -x/-y", () => {
32
+ const size = parseTerminalSize({ cols: 93, rows: 41 });
33
+ const line = newSessionLine({ name: "claude-abc", cwd: "/tmp", size, command: "claude" });
34
+ assert.match(line, / -x 93 -y 41 /);
35
+ });
36
+ //# sourceMappingURL=terminal-size.test.js.map
@@ -182,6 +182,65 @@ describe("describeUserAgent", () => {
182
182
  });
183
183
  }
184
184
  });
185
+ describe("which app a device runs", () => {
186
+ const MAIN = "https://main.brane.organelle.dev";
187
+ const APEX = "https://brane.organelle.dev";
188
+ const origins = (db, id) => db.prepare("SELECT app_origin, last_app_origin FROM tokens WHERE id = ?").get(id);
189
+ it("is recorded from the first request that carries an Origin, and the first is never overwritten", () => {
190
+ const { ring, phone, db } = setup();
191
+ // A same-origin GET carries no Origin: the device is named, the app not yet.
192
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
193
+ assert.deepEqual(origins(db, phone.id), { app_origin: null, last_app_origin: null });
194
+ // The control socket's upgrade carries one. Inside the once-a-minute
195
+ // throttle of the last-seen stamp, so the first write must carry both.
196
+ ring.authenticate("phone", T0 + 2, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN });
197
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
198
+ // The same token from another copy of the app: the first stands, the latest moves.
199
+ ring.authenticate("phone", T0 + 2 + 60_000, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: APEX });
200
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: APEX });
201
+ // A request with no Origin after that leaves the latest alone.
202
+ ring.authenticate("phone", T0 + 2 + 120_000, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2" });
203
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: APEX });
204
+ });
205
+ it("is listed, and shown as a host", () => {
206
+ const { ring, store, local, phone, db } = setup();
207
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN });
208
+ const listed = listTokensRoute(store, local, T0 + 2).body.tokens.find((t) => t.id === phone.id);
209
+ assert.equal(listed.app_origin, MAIN);
210
+ assert.match(formatTokenList([listed]), /via main\.brane\.organelle\.dev/);
211
+ // A daemon's own bundle is an origin like any other, port included.
212
+ ring.authenticate("laptop", T0 + 1, { userAgent: MAC_CHROME, remoteAddr: "100.101.1.3", origin: "https://mac.tail1234.ts.net:47443" });
213
+ assert.match(formatTokenList(listTokensRoute(store, local, T0 + 2).body.tokens), /via mac\.tail1234\.ts\.net:47443/);
214
+ assert.equal(origins(db, local.id).app_origin, null);
215
+ });
216
+ it("is stored as text a list can show: control characters become spaces, and it is capped", () => {
217
+ const { ring, phone, db } = setup();
218
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, origin: "https://evil.example\r\nX-Forged: row" + "a".repeat(300) });
219
+ const { app_origin } = origins(db, phone.id);
220
+ assert.ok(app_origin && !/[\r\n]/.test(app_origin));
221
+ assert.ok(app_origin.length <= 256);
222
+ });
223
+ it("treats the opaque origin `null` as no origin, so it never names a device", () => {
224
+ // A sandboxed iframe or a file:// page sends the literal `Origin: null`.
225
+ // Stored, it would read "via null" for good, since the first origin is
226
+ // never overwritten; so it must not take the slot a real one would fill.
227
+ const { ring, phone, db } = setup();
228
+ ring.authenticate("phone", T0 + 1, { userAgent: IPHONE_SAFARI, origin: "null" });
229
+ assert.deepEqual(origins(db, phone.id), { app_origin: null, last_app_origin: null });
230
+ ring.authenticate("phone", T0 + 2, { userAgent: IPHONE_SAFARI, origin: MAIN });
231
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
232
+ ring.authenticate("phone", T0 + 2 + 60_000, { userAgent: IPHONE_SAFARI, origin: "null" });
233
+ assert.deepEqual(origins(db, phone.id), { app_origin: MAIN, last_app_origin: MAIN });
234
+ });
235
+ it("a rotation's replacement takes the origin of the request that confirms it", () => {
236
+ const { store } = setup();
237
+ const replacement = store.insert(hashToken("fresh"), "device", "phone", T0 + 3, {
238
+ used: true,
239
+ seen: { userAgent: IPHONE_SAFARI, remoteAddr: "100.101.1.2", origin: MAIN },
240
+ });
241
+ assert.equal(store.list(T0 + 4).find((t) => t.id === replacement.id)?.app_origin, MAIN);
242
+ });
243
+ });
185
244
  describe("formatTokenList", () => {
186
245
  it("puts the id first, and marks this machine, the requester and an unscanned QR", () => {
187
246
  const text = formatTokenList([