rechrome 1.38.2 → 1.39.0

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/README.md CHANGED
@@ -359,56 +359,59 @@ cp .env.example .env.local
359
359
  >
360
360
  > Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
361
361
 
362
- ### Small icon uploads from stdin
362
+ ### Uploading files
363
363
 
364
- Uploads are disabled by default on every listener. The host owner can add an
365
- `uploads` object to the intended listener entry in `~/.rechrome/listeners.json`:
364
+ `rech upload <file>` puts a local image into the page: a file picker, an
365
+ `<input type=file>` (hidden ones too), a drag-and-drop zone, or an editor that
366
+ takes pasted images. The file is read on **your** machine and only its bytes are
367
+ sent, so it works the same against a remote host.
366
368
 
367
- ```json
368
- "uploads": {
369
- "maxBytes": 2097152,
370
- "contentTypes": ["image/png", "image/svg+xml"]
371
- }
369
+ ```bash
370
+ rech upload ./shot.png # the chooser a click opened, else the page's only file input
371
+ rech upload ./shot.png --into e42 # a file input, or the button that opens the picker (clicks it)
372
+ rech upload ./shot.png --into 'input#avatar'
373
+ rech upload ./shot.png --drop e17 # a drag-and-drop zone
374
+ rech upload ./shot.png --paste e9 # an editor that takes pasted images (default: the focused element)
375
+ rech upload --name shot.png < ./shot.png # bytes from stdin instead of a path
372
376
  ```
373
377
 
374
- The object enables uploads; omitting it disables them. `uploads: {}` uses the
375
- same defaults shown above. The owner can lower the byte limit or narrow the type
376
- list, but cannot exceed the hard **2 MiB** limit or enable other types. Do not
377
- replace the listener's other fields or share its key. Install the updated daemon
378
- code before enabling this policy; configuration changes then use the usual
379
- listener reload mechanism.
378
+ Targets are snapshot refs (`rech snapshot`) or selectors, the same as `rech click`.
379
+ The usual flow for a site's "add image" button is `rech snapshot`, then
380
+ `rech upload ./shot.png --into <the button's ref>`: rech clicks it and fills the file
381
+ chooser that opens. Run the steps in one session (`-s=<name>`) when they span
382
+ several commands.
380
383
 
381
- On the **client machine**, open the website's file chooser with `rech click`, then
382
- send the icon through stdin in the same profile/session:
384
+ **Types and size:** PNG, JPEG, GIF, WebP and SVG, up to **10 MiB**. Both the client
385
+ and the host check the size, and check the bytes against the type (PNG/JPEG/GIF/WebP
386
+ signatures; UTF-8 SVG without DTD/entities). These checks identify the format; they
387
+ don't sanitize SVG or scan for malware. The host never renders the file.
388
+
389
+ **Turning uploads on:** they are always on through the local management listener.
390
+ On a shared (scoped) listener they are **off** until the host turns them on:
383
391
 
384
392
  ```bash
385
- rech --profile work -s=slack upload --name app-icon.png < ./app-icon.png
386
- cat ./app-icon.svg | rech --profile work -s=slack upload --name app-icon.svg
393
+ rech listener set <listener> --uploads # --no-uploads turns them off again
394
+ ```
395
+
396
+ That writes an `uploads` object to the listener's entry in `~/.rechrome/listeners.json`.
397
+ The host can tune it by hand, lowering the byte limit or narrowing the types, but not
398
+ going above the 10 MiB cap or adding other types:
399
+
400
+ ```json
401
+ "uploads": { "maxBytes": 2097152, "contentTypes": ["image/png", "image/jpeg"] }
387
402
  ```
388
403
 
389
- `--content-type image/png` or `--content-type image/svg+xml` may be specified;
390
- otherwise the type is inferred from `--name`. This command is stdin-only: there
391
- is no positional file path. The shell reads the local file; only its bytes and
392
- metadata are sent to the listener. Client and server both enforce the byte cap.
393
- The server checks PNG signatures or UTF-8 SVG markup (rejecting DTD/entities),
394
- and checks the type against the listener policy. These checks identify the file
395
- format; they do not sanitize SVG or scan for malware. The host never renders the
396
- file or resolves anything referenced inside it.
397
-
398
- The receiver writes a random `.png`/`.svg` filename with mode 0600 inside a private
399
- 0700 temporary directory, outside the downloadable output tree. It passes only
400
- that generated path to the existing file-chooser command and removes the directory
401
- on success, error, or timeout. Neither filenames nor payload fields can select a
402
- host file. A crash or forced process kill can interrupt cleanup; the files remain
403
- private OS temporary files. Permission modes apply on POSIX hosts.
404
-
405
- Each upload produces one `upload:` JSON audit record with listener, profile,
406
- original name, byte size, SHA-256, page origin observed immediately before upload,
407
- and result. It contains no file contents, credentials, page path, query, or fragment.
408
- Requests rejected before inspection have null values for unavailable fields.
409
- An HTTP(S) origin must be determined before an upload is attempted. As with other
410
- browser commands, avoid concurrent tab navigation in the same session. Scoped
411
- `eval`, `run-code`, host paths, and CLI option overrides remain unavailable.
404
+ **On the host:** the bytes are written under their original file name inside a fresh
405
+ private temporary directory (0700, file 0600), outside the downloadable output tree,
406
+ and the directory is removed afterwards (on success, error or timeout). Neither the
407
+ name, the target nor any other payload field can select a host file or inject code:
408
+ the drop/paste/input steps run as a server-built script that embeds them as JSON
409
+ data. Each upload writes one `upload:` JSON audit record: listener, profile,
410
+ original name, size, SHA-256, delivery mode, the page origin just before the
411
+ upload, and the result. It never contains file contents, credentials, or the page's
412
+ path, query or fragment. An HTTP(S) origin must be known before an upload is
413
+ attempted. Scoped `eval`, `run-code`, host paths and CLI option overrides stay
414
+ unavailable; enabling uploads doesn't change that.
412
415
 
413
416
  ### Remote access
414
417
 
@@ -475,7 +478,7 @@ git clone https://github.com/snomiao/rechrome.git
475
478
  cd rechrome
476
479
  bun install # `prepare` also builds vendor/ from vendor-src/, so the checkout has a working CLI
477
480
  bun link # makes this checkout the global rechrome / rech
478
- bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
481
+ bun run test # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
479
482
  ```
480
483
 
481
484
  To use a different playwright-cli, set `PLAYWRIGHT_CLI=<cmd>` in `.env.local` (for example a
package/listeners.js CHANGED
@@ -1,13 +1,20 @@
1
1
  // @bun
2
2
  // scoped-upload.ts
3
- var MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
4
- var MAX_REQUEST_BYTES = 3 * 1024 * 1024;
5
- var UPLOAD_TYPES = ["image/png", "image/svg+xml"];
3
+ var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
4
+ var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
5
+ var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
6
6
  function validateUploadPolicy(value) {
7
7
  const p = value;
8
8
  if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))))
9
- throw new Error("uploads must be an object with optional maxBytes (1..2097152) and contentTypes (image/png, image/svg+xml)");
10
- }
9
+ throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
10
+ }
11
+ var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
12
+ (no flag) the file chooser a click opened, else the page's only <input type=file>
13
+ --into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
14
+ --drop <ref|selector> a drag-and-drop zone
15
+ --paste [ref|selector] an editor that takes pasted images (default: the focused element)
16
+ --name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
17
+ Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
11
18
 
12
19
  // listeners.ts
13
20
  import { isIP } from "net";
@@ -109,7 +116,7 @@ async function writeListeners(config) {
109
116
  `, { mode: 384 });
110
117
  await rename(temp, LISTENERS_FILE);
111
118
  }
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"]);
119
+ 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"]);
113
120
  function authorizeProfileRequest(listener, body) {
114
121
  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"))
115
122
  throw new Error("Scoped requests require identity.key, identity.profile and string args");
@@ -119,9 +126,11 @@ function authorizeProfileRequest(listener, body) {
119
126
  if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
120
127
  throw new Error("Profile override does not match request identity");
121
128
  const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
122
- if (!(args[0] === "upload" && listener.uploads !== undefined) && !SCOPED_COMMANDS.has(args[0]))
129
+ if (args[0] === "upload" && listener.uploads === undefined)
130
+ throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
131
+ if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0]))
123
132
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
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] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
133
+ 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=")))))
125
134
  throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
126
135
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
127
136
  throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
package/listeners.ts CHANGED
@@ -96,15 +96,17 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
96
96
 
97
97
  // Restricted listeners deliberately exclude arbitrary code, host filesystem access,
98
98
  // attachment/config overrides, and commands that manage all browser sessions.
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"]);
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"]);
100
100
  export function authorizeProfileRequest(listener: Listener, body: any): string {
101
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");
102
102
  const profile = body.identity.profile;
103
103
  if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
104
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");
105
105
  const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
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");
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] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
106
+ if (args[0] === "upload" && listener.uploads === undefined)
107
+ throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
108
+ if (args[0] !== "upload" && !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");
109
+ 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");
108
110
  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");
109
111
  // Upload is handled by the byte-only receiver; the caller cannot supply host paths.
110
112
  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.2",
3
+ "version": "1.39.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -45,7 +45,8 @@
45
45
  "type": "module",
46
46
  "scripts": {
47
47
  "serve": "bun run rechrome.ts serve",
48
- "test": "bun test",
48
+ "test": "bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts",
49
+ "build-playwright": "bash scripts/build-playwright.sh",
49
50
  "vendor-cli": "bash scripts/vendor-cli.sh",
50
51
  "prepare": "bash scripts/vendor-cli.sh --prepare",
51
52
  "check-pack": "bash scripts/check-pack.sh",
package/rechrome.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.js";
2
+ import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.js";
3
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";
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2177
2177
  process.exit(2);
2178
2178
  }
2179
2179
  } else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
2180
- if (process.stdin.isTTY) throw new Error("upload reads stdin; redirect a PNG/SVG file with < icon.png");
2181
- ({ args, upload } = await prepareStdinUpload(args, process.stdin));
2180
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
2181
+ try {
2182
+ // A path argument is read here; without one the bytes come from stdin (never a terminal).
2183
+ ({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
2184
+ } catch (err) {
2185
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
2186
+ process.exit(2);
2187
+ }
2182
2188
  }
2183
2189
  const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
2184
2190
  // The daemon already masks the value; this is a second line of defence for an older daemon.
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
3333
3339
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3334
3340
  }
3335
3341
 
3336
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
3342
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
3337
3343
  const config = await requireListeners();
3338
- const listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
3344
+ let listener = config.listeners.find(l => l.name === name);
3345
+ if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
3346
+ if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
3347
+ if (opts.uploads !== undefined) {
3348
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
3349
+ // {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
3350
+ if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
3351
+ }
3339
3352
  await writeListeners(config);
3340
- console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3353
+ if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3354
+ if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${(listener.uploads?.maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
3341
3355
  }
3342
3356
 
3343
3357
  const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
@@ -4166,9 +4180,30 @@ const selfCommand = () => [process.execPath, Bun.main];
4166
4180
  */
4167
4181
  const bridgedUrls = new Set<string>();
4168
4182
 
4183
+ /**
4184
+ * RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
4185
+ * IP), which refuses management-only commands. Same user, same daemon: go through the loopback
4186
+ * management listener instead. The session is unchanged, since it is keyed by identity, not by
4187
+ * listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
4188
+ */
4189
+ export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
4190
+ const { key, host, port, protocol } = parseUrl(url);
4191
+ const own = config?.listeners.find(l => l.key === key);
4192
+ const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
4193
+ if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
4194
+ if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
4195
+ const source = new URL(url);
4196
+ const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
4197
+ next.search = source.search;
4198
+ const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
4199
+ fragment.set("key", management.key);
4200
+ next.hash = fragment.toString();
4201
+ return next.toString();
4202
+ }
4203
+
4169
4204
  export async function transportUrl(url: string): Promise<string> {
4170
4205
  const parts = shareLinkParts(url);
4171
- if (!parts?.webrtc) return url;
4206
+ if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
4172
4207
  const info = await spawnBridge(parts.token, parts.origin, selfCommand());
4173
4208
  const bridged = new URL(`http://127.0.0.1:${info.port}/`);
4174
4209
  new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
@@ -4304,7 +4339,7 @@ export type RechHandlers = {
4304
4339
  allowListener(name: string, profiles: string[]): Promise<void>;
4305
4340
  denyListener(name: string, profiles: string[]): Promise<void>;
4306
4341
  rotateKey(name: string): Promise<void>;
4307
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
4342
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
4308
4343
  setup(opts: SetupOptions): Promise<void>;
4309
4344
  tray(action?: string): Promise<void>;
4310
4345
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4446,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4446
4481
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
4447
4482
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
4448
4483
  a => handlers.denyListener(a.name, a.profiles))
4449
- .command("set <name>", "Record where a reverse proxy exposes a listener", y => y
4484
+ .command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
4450
4485
  .positional("name", { type: "string", demandOption: true })
4451
4486
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
4452
4487
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4453
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
4454
- a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
4488
+ .option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
4489
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
4490
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
4455
4491
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4456
4492
  a => handlers.listenerPort(a.name))
4457
4493
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
package/rechrome.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.ts";
2
+ import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.ts";
3
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";
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2177
2177
  process.exit(2);
2178
2178
  }
2179
2179
  } else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
2180
- if (process.stdin.isTTY) throw new Error("upload reads stdin; redirect a PNG/SVG file with < icon.png");
2181
- ({ args, upload } = await prepareStdinUpload(args, process.stdin));
2180
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
2181
+ try {
2182
+ // A path argument is read here; without one the bytes come from stdin (never a terminal).
2183
+ ({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
2184
+ } catch (err) {
2185
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
2186
+ process.exit(2);
2187
+ }
2182
2188
  }
2183
2189
  const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
2184
2190
  // The daemon already masks the value; this is a second line of defence for an older daemon.
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
3333
3339
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3334
3340
  }
3335
3341
 
3336
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
3342
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
3337
3343
  const config = await requireListeners();
3338
- const listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
3344
+ let listener = config.listeners.find(l => l.name === name);
3345
+ if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
3346
+ if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
3347
+ if (opts.uploads !== undefined) {
3348
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
3349
+ // {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
3350
+ if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
3351
+ }
3339
3352
  await writeListeners(config);
3340
- console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3353
+ if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3354
+ if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${(listener.uploads?.maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
3341
3355
  }
3342
3356
 
3343
3357
  const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
@@ -4166,9 +4180,30 @@ const selfCommand = () => [process.execPath, Bun.main];
4166
4180
  */
4167
4181
  const bridgedUrls = new Set<string>();
4168
4182
 
4183
+ /**
4184
+ * RECHROME_URL may address one of this machine's own scoped listeners directly (e.g. its tailnet
4185
+ * IP), which refuses management-only commands. Same user, same daemon: go through the loopback
4186
+ * management listener instead. The session is unchanged, since it is keyed by identity, not by
4187
+ * listener. HTTPS is left alone: the daemon's certificate doesn't name 127.0.0.1.
4188
+ */
4189
+ export function preferManagementListener(url: string, config: { listeners: Listener[] } | null): string {
4190
+ const { key, host, port, protocol } = parseUrl(url);
4191
+ const own = config?.listeners.find(l => l.key === key);
4192
+ const management = config?.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
4193
+ if (!key || !own || own.profiles === "*" || !management || protocol !== "http") return url;
4194
+ if (own.host !== host.replace(/^\[|\]$/g, "") || own.port !== port) return url;
4195
+ const source = new URL(url);
4196
+ const next = new URL(`http://${listenerAddress(management)}${normalizePrefix(management.prefix)}`);
4197
+ next.search = source.search;
4198
+ const fragment = new URLSearchParams(source.hash.slice(1).replace(/^\?/, ""));
4199
+ fragment.set("key", management.key);
4200
+ next.hash = fragment.toString();
4201
+ return next.toString();
4202
+ }
4203
+
4169
4204
  export async function transportUrl(url: string): Promise<string> {
4170
4205
  const parts = shareLinkParts(url);
4171
- if (!parts?.webrtc) return url;
4206
+ if (!parts?.webrtc) return preferManagementListener(url, await readListeners().catch(() => null));
4172
4207
  const info = await spawnBridge(parts.token, parts.origin, selfCommand());
4173
4208
  const bridged = new URL(`http://127.0.0.1:${info.port}/`);
4174
4209
  new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
@@ -4304,7 +4339,7 @@ export type RechHandlers = {
4304
4339
  allowListener(name: string, profiles: string[]): Promise<void>;
4305
4340
  denyListener(name: string, profiles: string[]): Promise<void>;
4306
4341
  rotateKey(name: string): Promise<void>;
4307
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
4342
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
4308
4343
  setup(opts: SetupOptions): Promise<void>;
4309
4344
  tray(action?: string): Promise<void>;
4310
4345
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4446,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4446
4481
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
4447
4482
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
4448
4483
  a => handlers.denyListener(a.name, a.profiles))
4449
- .command("set <name>", "Record where a reverse proxy exposes a listener", y => y
4484
+ .command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
4450
4485
  .positional("name", { type: "string", demandOption: true })
4451
4486
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
4452
4487
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4453
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
4454
- a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
4488
+ .option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
4489
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
4490
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
4455
4491
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4456
4492
  a => handlers.listenerPort(a.name))
4457
4493
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),