rechrome 1.38.1 → 1.38.3

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/listeners.js CHANGED
@@ -15,6 +15,7 @@ import { createHash, randomBytes } from "crypto";
15
15
  import { homedir } from "os";
16
16
  import { join } from "path";
17
17
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
18
+ var MAX_RETIRED_PORTS = 32;
18
19
  var LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
19
20
  var isLoopback = (host) => host === "::1" || /^127\./.test(host);
20
21
  var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
@@ -73,6 +74,8 @@ function validateListeners(value) {
73
74
  addresses.add(address);
74
75
  keys.add(l.key);
75
76
  }
77
+ if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
78
+ throw new Error("listeners.json retiredPorts must be a list of ports");
76
79
  return config;
77
80
  }
78
81
  async function readListeners() {
@@ -86,15 +89,27 @@ async function readListeners() {
86
89
  }
87
90
  return validateListeners(JSON.parse(raw));
88
91
  }
92
+ function retirePorts(previous, next) {
93
+ const inUse = new Set(next.listeners.map((l) => l.port));
94
+ const freed = (previous?.listeners ?? []).map((l) => l.port).filter((p) => !inUse.has(p));
95
+ const merged = [...previous?.retiredPorts ?? [], ...next.retiredPorts ?? [], ...freed].filter((p) => !inUse.has(p));
96
+ return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
97
+ }
89
98
  async function writeListeners(config) {
90
99
  validateListeners(config);
100
+ const previous = await readListeners().catch(() => null);
101
+ const retired = retirePorts(previous, config);
102
+ if (retired.length)
103
+ config.retiredPorts = retired;
104
+ else
105
+ delete config.retiredPorts;
91
106
  await mkdir(join(homedir(), ".rechrome"), { recursive: true });
92
107
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
93
108
  await writeFile(temp, JSON.stringify(config, null, 2) + `
94
109
  `, { mode: 384 });
95
110
  await rename(temp, LISTENERS_FILE);
96
111
  }
97
- var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret"]);
112
+ var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
98
113
  function authorizeProfileRequest(listener, body) {
99
114
  if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a) => typeof a !== "string"))
100
115
  throw new Error("Scoped requests require identity.key, identity.profile and string args");
@@ -106,7 +121,7 @@ function authorizeProfileRequest(listener, body) {
106
121
  const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
107
122
  if (!(args[0] === "upload" && listener.uploads !== undefined) && !SCOPED_COMMANDS.has(args[0]))
108
123
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
109
- if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
124
+ if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
110
125
  throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
111
126
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
112
127
  throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
@@ -219,6 +234,7 @@ export {
219
234
  profileOutputPrefix,
220
235
  readListeners,
221
236
  resolveAllowedProfile,
237
+ retirePorts,
222
238
  rotateListenerKey,
223
239
  serviceUrl,
224
240
  setPublicUrl,
package/listeners.ts CHANGED
@@ -7,7 +7,10 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
7
 
8
8
  // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
9
9
  export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy };
10
- export type ListenerConfig = { version: 1; listeners: Listener[] };
10
+ // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
11
+ // saved for a removed listener gets "connection refused", not some other listener's answer.
12
+ export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
13
+ const MAX_RETIRED_PORTS = 32;
11
14
  export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
12
15
  export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
13
16
  export const listenerAddress = (l: Listener) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
@@ -60,6 +63,8 @@ export function validateListeners(value: unknown): ListenerConfig {
60
63
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
61
64
  names.add(l.name); addresses.add(address); keys.add(l.key);
62
65
  }
66
+ if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
67
+ throw new Error("listeners.json retiredPorts must be a list of ports");
63
68
  return config;
64
69
  }
65
70
 
@@ -70,8 +75,19 @@ export async function readListeners(): Promise<ListenerConfig | null> {
70
75
  return validateListeners(JSON.parse(raw));
71
76
  }
72
77
 
78
+ /** `previous`'s retired ports plus the ports its listeners used that `next` no longer does. */
79
+ export function retirePorts(previous: ListenerConfig | null, next: ListenerConfig): number[] {
80
+ const inUse = new Set(next.listeners.map(l => l.port));
81
+ const freed = (previous?.listeners ?? []).map(l => l.port).filter(p => !inUse.has(p));
82
+ const merged = [...(previous?.retiredPorts ?? []), ...(next.retiredPorts ?? []), ...freed].filter(p => !inUse.has(p));
83
+ return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
84
+ }
85
+
73
86
  export async function writeListeners(config: ListenerConfig): Promise<void> {
74
87
  validateListeners(config);
88
+ const previous = await readListeners().catch(() => null);
89
+ const retired = retirePorts(previous, config);
90
+ if (retired.length) config.retiredPorts = retired; else delete config.retiredPorts;
75
91
  await mkdir(join(homedir(), ".rechrome"), { recursive: true });
76
92
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
77
93
  await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
@@ -80,7 +96,7 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
80
96
 
81
97
  // Restricted listeners deliberately exclude arbitrary code, host filesystem access,
82
98
  // attachment/config overrides, and commands that manage all browser sessions.
83
- const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret"]);
99
+ const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
84
100
  export function authorizeProfileRequest(listener: Listener, body: any): string {
85
101
  if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a: unknown) => typeof a !== "string")) throw new Error("Scoped requests require identity.key, identity.profile and string args");
86
102
  const profile = body.identity.profile;
@@ -88,7 +104,7 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
88
104
  if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile) throw new Error("Profile override does not match request identity");
89
105
  const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
90
106
  if (!(args[0] === "upload" && listener.uploads !== undefined) && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
91
- if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
107
+ if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
92
108
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank") throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
93
109
  // Upload is handled by the byte-only receiver; the caller cannot supply host paths.
94
110
  if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.38.1",
3
+ "version": "1.38.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.js";
3
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.js";
3
+ import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.js";
4
4
 
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
@@ -1504,21 +1504,34 @@ export function staleLinkHint(url: string, listeners: Listener[], source?: strin
1504
1504
  try { parsed = parseUrl(url); } catch { return null; }
1505
1505
  const loopback = (host: string) => isLoopback(host) || host === "localhost";
1506
1506
  const urlHost = parsed.host.replace(/^\[|\]$/g, "");
1507
- const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || l.host === "0.0.0.0" || loopback(l.host) && loopback(urlHost)));
1507
+ // Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
1508
+ const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
1509
+ const direct = !!at || loopback(urlHost);
1510
+ const viaProxy = (l: Listener) => {
1511
+ if (!l.publicUrl) return false;
1512
+ const pub = parseUrl(l.publicUrl);
1513
+ return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
1514
+ };
1515
+ const proxied = listeners.find(viaProxy);
1508
1516
  const owner = listeners.find(l => l.key === parsed.key);
1509
- if (!at && !owner && !loopback(urlHost)) return null; // another machine's daemon: only its owner knows
1510
- if (owner && owner === at && normalizePrefix(owner.prefix) === parsed.prefix) return null;
1517
+ if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
1511
1518
  const profile = parsed.profileDirectory;
1519
+ const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
1520
+ // Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
1521
+ const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
1522
+ if (owner && reachesOwner && allows(owner)) return null;
1512
1523
  let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
1513
1524
  if (profile) {
1514
1525
  try {
1515
- const name = owner && owner.profiles !== "*" ? owner.name : chooseShareListener(profile, listeners);
1516
- fix = `rech share ${JSON.stringify(profile)} --listener ${name}${owner?.publicUrl || listeners.find(l => l.name === name)?.publicUrl ? "" : " --local"} --save`;
1526
+ const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
1527
+ fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
1517
1528
  } catch { /* no single listener shares it: the default creates a fresh link */ }
1518
1529
  }
1519
- const lines = [owner
1520
- ? `this link's listener "${owner.name}" moved: it now serves ${listenerAddress(owner)}${normalizePrefix(owner.prefix)}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1521
- : `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`];
1530
+ const lines = [!owner
1531
+ ? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
1532
+ : !reachesOwner
1533
+ ? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1534
+ : `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
1522
1535
  if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
1523
1536
  lines.push(`Replace it with a current link: ${fix}`);
1524
1537
  return lines.join("\n ");
@@ -1530,13 +1543,19 @@ async function localStaleLinkHint(url: string): Promise<string | null> {
1530
1543
  return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
1531
1544
  }
1532
1545
 
1533
- /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1534
- function freeListenerPort(listeners: Listener[]): number {
1535
- const used = new Set(listeners.map(l => l.port));
1536
- for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1537
- if (used.has(port)) continue;
1538
- try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1539
- }
1546
+ const loopbackPortIsFree = (port: number) => {
1547
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
1548
+ };
1549
+
1550
+ /**
1551
+ * A loopback port no listener uses and nothing else is bound to, from the management port + 2.
1552
+ * A removed listener's port is skipped: reusing it would route that listener's saved links to
1553
+ * this one, a confusing wrong answer instead of a plain "connection refused".
1554
+ */
1555
+ export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
1556
+ const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
1557
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
1558
+ for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
1540
1559
  throw new Error("No free port for the share-all listener; pass one with rech listener add");
1541
1560
  }
1542
1561
 
@@ -1558,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
1558
1577
  }
1559
1578
 
1560
1579
  /**
1561
- * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1562
- * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1563
- * it reuses a listener with exactly this set, or creates one with its own key.
1580
+ * `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
1581
+ * them to that listener (e.g. one that already has a proxy route) and never removes any:
1582
+ * that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
1583
+ * creates one with its own key.
1564
1584
  */
1565
1585
  async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1566
1586
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
@@ -1573,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1573
1593
  const config = await requireListeners();
1574
1594
  const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1575
1595
  const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1576
- return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1596
+ return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1577
1597
  }
1578
1598
 
1579
1599
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1580
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1600
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
1581
1601
  const config = await requireListeners();
1582
1602
  const name = opts.listener ?? opts.defaultName;
1583
1603
  let listener = config.listeners.find(l => l.name === name);
@@ -1586,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1586
1606
  let changes = "";
1587
1607
  if (!listener) {
1588
1608
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1589
- listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1609
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1590
1610
  config.listeners.push(listener);
1591
1611
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1592
1612
  } else {
1593
1613
  const before = listener.profiles as string[];
1614
+ // Merging (a listener named with --listener) only adds: profiles shared there for someone
1615
+ // else stay. Removing one is explicit: rech listener deny.
1616
+ if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
1594
1617
  const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1595
1618
  listener.profiles = snapshot;
1596
1619
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
@@ -3386,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3386
3409
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3387
3410
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3388
3411
  await chmod(envPath, 0o600);
3412
+ await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
3389
3413
  return envPath;
3390
3414
  }
3391
3415
 
3416
+ /** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
3417
+ export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
3418
+
3419
+ async function readSavedLinks(): Promise<string[]> {
3420
+ const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
3421
+ return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
3422
+ }
3423
+
3424
+ async function recordSavedLink(envPath: string): Promise<void> {
3425
+ const path = resolve(envPath), known = await readSavedLinks();
3426
+ if (known.includes(path)) return;
3427
+ mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
3428
+ await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
3429
+ }
3430
+
3431
+ /** The RECHROME_URL an env file sets, or null. */
3432
+ function savedUrlIn(envPath: string): string | null {
3433
+ try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
3434
+ }
3435
+
3436
+ /**
3437
+ * One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
3438
+ * a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
3439
+ * saved says so, with the fix, instead of the link failing later in some other project.
3440
+ * Links that were already stale stay quiet: this change didn't break them.
3441
+ */
3442
+ export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
3443
+ return saved.flatMap(({ path, url }) => {
3444
+ if (staleLinkHint(url, before)) return [];
3445
+ const hint = staleLinkHint(url, after, path);
3446
+ return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
3447
+ });
3448
+ }
3449
+
3450
+ /** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
3451
+ async function writeListeners(config: ListenerConfig): Promise<void> {
3452
+ const before = await readListeners().catch(() => null);
3453
+ await writeListenersFile(config);
3454
+ if (!before) return;
3455
+ const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
3456
+ for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
3457
+ }
3458
+
3392
3459
  /**
3393
3460
  * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3394
3461
  * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
@@ -3881,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
3881
3948
  const address = chooseListenAddress(await detectListenChoices(), selector);
3882
3949
  const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3883
3950
  if (existing) return existing.name;
3884
- const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3951
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
3885
3952
  config.listeners.push(listener);
3886
3953
  await writeListeners(config);
3887
3954
  if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
@@ -4099,9 +4166,30 @@ const selfCommand = () => [process.execPath, Bun.main];
4099
4166
  */
4100
4167
  const bridgedUrls = new Set<string>();
4101
4168
 
4169
+ /**
4170
+ * RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
4171
+ * IP), which refuses management-only commands. Same user, same daemon: go through the loopback
4172
+ * management listener instead. The session is unchanged, since it is keyed by identity, not by
4173
+ * listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
4174
+ */
4175
+ export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
4176
+ const { key, host, port, protocol } = parseUrl(url);
4177
+ const own = config?.listeners.find(l => l.key === key);
4178
+ const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
4179
+ if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
4180
+ if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
4181
+ const source = new URL(url);
4182
+ const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
4183
+ next.search = source.search;
4184
+ const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
4185
+ fragment.set("key", management.key);
4186
+ next.hash = fragment.toString();
4187
+ return next.toString();
4188
+ }
4189
+
4102
4190
  export async function transportUrl(url: string): Promise<string> {
4103
4191
  const parts = shareLinkParts(url);
4104
- if (!parts?.webrtc) return url;
4192
+ if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
4105
4193
  const info = await spawnBridge(parts.token, parts.origin, selfCommand());
4106
4194
  const bridged = new URL(`http://127.0.0.1:${info.port}/`);
4107
4195
  new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
package/rechrome.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.ts";
3
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.ts";
3
+ import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.ts";
4
4
 
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
@@ -1504,21 +1504,34 @@ export function staleLinkHint(url: string, listeners: Listener[], source?: strin
1504
1504
  try { parsed = parseUrl(url); } catch { return null; }
1505
1505
  const loopback = (host: string) => isLoopback(host) || host === "localhost";
1506
1506
  const urlHost = parsed.host.replace(/^\[|\]$/g, "");
1507
- const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || l.host === "0.0.0.0" || loopback(l.host) && loopback(urlHost)));
1507
+ // Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
1508
+ const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
1509
+ const direct = !!at || loopback(urlHost);
1510
+ const viaProxy = (l: Listener) => {
1511
+ if (!l.publicUrl) return false;
1512
+ const pub = parseUrl(l.publicUrl);
1513
+ return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
1514
+ };
1515
+ const proxied = listeners.find(viaProxy);
1508
1516
  const owner = listeners.find(l => l.key === parsed.key);
1509
- if (!at && !owner && !loopback(urlHost)) return null; // another machine's daemon: only its owner knows
1510
- if (owner && owner === at && normalizePrefix(owner.prefix) === parsed.prefix) return null;
1517
+ if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
1511
1518
  const profile = parsed.profileDirectory;
1519
+ const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
1520
+ // Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
1521
+ const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
1522
+ if (owner && reachesOwner && allows(owner)) return null;
1512
1523
  let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
1513
1524
  if (profile) {
1514
1525
  try {
1515
- const name = owner && owner.profiles !== "*" ? owner.name : chooseShareListener(profile, listeners);
1516
- fix = `rech share ${JSON.stringify(profile)} --listener ${name}${owner?.publicUrl || listeners.find(l => l.name === name)?.publicUrl ? "" : " --local"} --save`;
1526
+ const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
1527
+ fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
1517
1528
  } catch { /* no single listener shares it: the default creates a fresh link */ }
1518
1529
  }
1519
- const lines = [owner
1520
- ? `this link's listener "${owner.name}" moved: it now serves ${listenerAddress(owner)}${normalizePrefix(owner.prefix)}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1521
- : `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`];
1530
+ const lines = [!owner
1531
+ ? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
1532
+ : !reachesOwner
1533
+ ? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1534
+ : `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
1522
1535
  if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
1523
1536
  lines.push(`Replace it with a current link: ${fix}`);
1524
1537
  return lines.join("\n ");
@@ -1530,13 +1543,19 @@ async function localStaleLinkHint(url: string): Promise<string | null> {
1530
1543
  return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
1531
1544
  }
1532
1545
 
1533
- /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1534
- function freeListenerPort(listeners: Listener[]): number {
1535
- const used = new Set(listeners.map(l => l.port));
1536
- for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1537
- if (used.has(port)) continue;
1538
- try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1539
- }
1546
+ const loopbackPortIsFree = (port: number) => {
1547
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
1548
+ };
1549
+
1550
+ /**
1551
+ * A loopback port no listener uses and nothing else is bound to, from the management port + 2.
1552
+ * A removed listener's port is skipped: reusing it would route that listener's saved links to
1553
+ * this one, a confusing wrong answer instead of a plain "connection refused".
1554
+ */
1555
+ export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
1556
+ const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
1557
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
1558
+ for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
1540
1559
  throw new Error("No free port for the share-all listener; pass one with rech listener add");
1541
1560
  }
1542
1561
 
@@ -1558,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
1558
1577
  }
1559
1578
 
1560
1579
  /**
1561
- * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1562
- * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1563
- * it reuses a listener with exactly this set, or creates one with its own key.
1580
+ * `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
1581
+ * them to that listener (e.g. one that already has a proxy route) and never removes any:
1582
+ * that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
1583
+ * creates one with its own key.
1564
1584
  */
1565
1585
  async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1566
1586
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
@@ -1573,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1573
1593
  const config = await requireListeners();
1574
1594
  const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1575
1595
  const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1576
- return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1596
+ return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1577
1597
  }
1578
1598
 
1579
1599
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1580
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1600
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
1581
1601
  const config = await requireListeners();
1582
1602
  const name = opts.listener ?? opts.defaultName;
1583
1603
  let listener = config.listeners.find(l => l.name === name);
@@ -1586,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1586
1606
  let changes = "";
1587
1607
  if (!listener) {
1588
1608
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1589
- listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1609
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1590
1610
  config.listeners.push(listener);
1591
1611
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1592
1612
  } else {
1593
1613
  const before = listener.profiles as string[];
1614
+ // Merging (a listener named with --listener) only adds: profiles shared there for someone
1615
+ // else stay. Removing one is explicit: rech listener deny.
1616
+ if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
1594
1617
  const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1595
1618
  listener.profiles = snapshot;
1596
1619
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
@@ -3386,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3386
3409
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3387
3410
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3388
3411
  await chmod(envPath, 0o600);
3412
+ await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
3389
3413
  return envPath;
3390
3414
  }
3391
3415
 
3416
+ /** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
3417
+ export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
3418
+
3419
+ async function readSavedLinks(): Promise<string[]> {
3420
+ const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
3421
+ return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
3422
+ }
3423
+
3424
+ async function recordSavedLink(envPath: string): Promise<void> {
3425
+ const path = resolve(envPath), known = await readSavedLinks();
3426
+ if (known.includes(path)) return;
3427
+ mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
3428
+ await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
3429
+ }
3430
+
3431
+ /** The RECHROME_URL an env file sets, or null. */
3432
+ function savedUrlIn(envPath: string): string | null {
3433
+ try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
3434
+ }
3435
+
3436
+ /**
3437
+ * One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
3438
+ * a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
3439
+ * saved says so, with the fix, instead of the link failing later in some other project.
3440
+ * Links that were already stale stay quiet: this change didn't break them.
3441
+ */
3442
+ export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
3443
+ return saved.flatMap(({ path, url }) => {
3444
+ if (staleLinkHint(url, before)) return [];
3445
+ const hint = staleLinkHint(url, after, path);
3446
+ return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
3447
+ });
3448
+ }
3449
+
3450
+ /** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
3451
+ async function writeListeners(config: ListenerConfig): Promise<void> {
3452
+ const before = await readListeners().catch(() => null);
3453
+ await writeListenersFile(config);
3454
+ if (!before) return;
3455
+ const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
3456
+ for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
3457
+ }
3458
+
3392
3459
  /**
3393
3460
  * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3394
3461
  * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
@@ -3881,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
3881
3948
  const address = chooseListenAddress(await detectListenChoices(), selector);
3882
3949
  const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3883
3950
  if (existing) return existing.name;
3884
- const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3951
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
3885
3952
  config.listeners.push(listener);
3886
3953
  await writeListeners(config);
3887
3954
  if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
@@ -4099,9 +4166,30 @@ const selfCommand = () => [process.execPath, Bun.main];
4099
4166
  */
4100
4167
  const bridgedUrls = new Set<string>();
4101
4168
 
4169
+ /**
4170
+ * RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
4171
+ * IP), which refuses management-only commands. Same user, same daemon: go through the loopback
4172
+ * management listener instead. The session is unchanged, since it is keyed by identity, not by
4173
+ * listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
4174
+ */
4175
+ export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
4176
+ const { key, host, port, protocol } = parseUrl(url);
4177
+ const own = config?.listeners.find(l => l.key === key);
4178
+ const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
4179
+ if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
4180
+ if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
4181
+ const source = new URL(url);
4182
+ const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
4183
+ next.search = source.search;
4184
+ const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
4185
+ fragment.set("key", management.key);
4186
+ next.hash = fragment.toString();
4187
+ return next.toString();
4188
+ }
4189
+
4102
4190
  export async function transportUrl(url: string): Promise<string> {
4103
4191
  const parts = shareLinkParts(url);
4104
- if (!parts?.webrtc) return url;
4192
+ if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
4105
4193
  const info = await spawnBridge(parts.token, parts.origin, selfCommand());
4106
4194
  const bridged = new URL(`http://127.0.0.1:${info.port}/`);
4107
4195
  new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
package/serve.js CHANGED
@@ -800,7 +800,9 @@ export async function serve() {
800
800
  // #fragment, which browsers never send, so the page is static and the key never reaches here.
801
801
  if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
802
802
  return new Response(landingPage(), { headers: LANDING_HEADERS });
803
- if (reqUrl.pathname !== "/run") return new Response("rech server\n");
803
+ // Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
804
+ // reused by another with a different prefix) must fail loudly, not look like a live daemon.
805
+ if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
804
806
  const denied = authCheck(req, key);
805
807
  if (denied) return denied;
806
808
  markActivity(); // a real command: this serve is not idle
package/serve.ts CHANGED
@@ -800,7 +800,9 @@ export async function serve() {
800
800
  // #fragment, which browsers never send, so the page is static and the key never reaches here.
801
801
  if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
802
802
  return new Response(landingPage(), { headers: LANDING_HEADERS });
803
- if (reqUrl.pathname !== "/run") return new Response("rech server\n");
803
+ // Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
804
+ // reused by another with a different prefix) must fail loudly, not look like a live daemon.
805
+ if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
804
806
  const denied = authCheck(req, key);
805
807
  if (denied) return denied;
806
808
  markActivity(); // a real command: this serve is not idle