rechrome 1.38.3 → 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";
@@ -119,7 +126,9 @@ 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
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");
package/listeners.ts CHANGED
@@ -103,7 +103,9 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
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");
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");
107
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.38.3",
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…");
@@ -4325,7 +4339,7 @@ export type RechHandlers = {
4325
4339
  allowListener(name: string, profiles: string[]): Promise<void>;
4326
4340
  denyListener(name: string, profiles: string[]): Promise<void>;
4327
4341
  rotateKey(name: string): Promise<void>;
4328
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
4342
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
4329
4343
  setup(opts: SetupOptions): Promise<void>;
4330
4344
  tray(action?: string): Promise<void>;
4331
4345
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4467,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4467
4481
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
4468
4482
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
4469
4483
  a => handlers.denyListener(a.name, a.profiles))
4470
- .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
4471
4485
  .positional("name", { type: "string", demandOption: true })
4472
4486
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
4473
4487
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4474
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
4475
- 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 } : {}) }))
4476
4491
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4477
4492
  a => handlers.listenerPort(a.name))
4478
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…");
@@ -4325,7 +4339,7 @@ export type RechHandlers = {
4325
4339
  allowListener(name: string, profiles: string[]): Promise<void>;
4326
4340
  denyListener(name: string, profiles: string[]): Promise<void>;
4327
4341
  rotateKey(name: string): Promise<void>;
4328
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
4342
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
4329
4343
  setup(opts: SetupOptions): Promise<void>;
4330
4344
  tray(action?: string): Promise<void>;
4331
4345
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4467,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4467
4481
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
4468
4482
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
4469
4483
  a => handlers.denyListener(a.name, a.profiles))
4470
- .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
4471
4485
  .positional("name", { type: "string", demandOption: true })
4472
4486
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
4473
4487
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4474
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
4475
- 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 } : {}) }))
4476
4491
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4477
4492
  a => handlers.listenerPort(a.name))
4478
4493
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
package/scoped-upload.js CHANGED
@@ -2,20 +2,31 @@
2
2
  // scoped-upload.ts
3
3
  import { createHash, randomBytes } from "crypto";
4
4
  import { mkdtemp, writeFile, rm } from "fs/promises";
5
- import { join } from "path";
5
+ import { basename, join } from "path";
6
6
  import { tmpdir } from "os";
7
- var MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
8
- var MAX_REQUEST_BYTES = 3 * 1024 * 1024;
9
- var UPLOAD_TYPES = ["image/png", "image/svg+xml"];
7
+ var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
8
+ var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
9
+ var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
10
+ var EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml" };
11
+ var UPLOAD_MODES = ["auto", "chooser", "into", "drop", "paste"];
10
12
  function validateUploadPolicy(value) {
11
13
  const p = value;
12
14
  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))))
13
- throw new Error("uploads must be an object with optional maxBytes (1..2097152) and contentTypes (image/png, image/svg+xml)");
15
+ throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
14
16
  }
15
17
  function validName(name) {
16
18
  return typeof name === "string" && name.length > 0 && name.length <= 128 && name !== "." && name !== ".." && !name.startsWith("-") && !/[\\/:\x00-\x1f\x7f]/.test(name);
17
19
  }
20
+ function validTarget(target) {
21
+ return typeof target === "string" && target.length > 0 && target.length <= 512 && !/[\x00-\x1f\x7f]/.test(target);
22
+ }
18
23
  function matchesType(bytes, type) {
24
+ if (type === "image/jpeg")
25
+ return bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
26
+ if (type === "image/gif")
27
+ return bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6));
28
+ if (type === "image/webp")
29
+ return bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
19
30
  if (type === "image/png")
20
31
  return bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(8) === 13 && bytes.toString("ascii", 12, 16) === "IHDR";
21
32
  if (type === "image/svg+xml") {
@@ -32,39 +43,133 @@ function matchesType(bytes, type) {
32
43
  }
33
44
  return false;
34
45
  }
35
- async function prepareStdinUpload(args, input) {
46
+ var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
47
+ (no flag) the file chooser a click opened, else the page's only <input type=file>
48
+ --into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
49
+ --drop <ref|selector> a drag-and-drop zone
50
+ --paste [ref|selector] an editor that takes pasted images (default: the focused element)
51
+ --name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
52
+ Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
53
+ async function prepareUpload(args, stdin, readLocal = (path) => Bun.file(path).bytes()) {
36
54
  const sessions = args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a));
37
55
  const options = args.filter((a) => !sessions.includes(a));
38
56
  if (options.shift() !== "upload")
39
57
  throw new Error("Expected upload command");
40
58
  const fields = {};
59
+ let path, mode = "auto", target;
60
+ const setMode = (next) => {
61
+ if (mode !== "auto")
62
+ throw new Error(`Pick one of --into, --drop, --paste.`);
63
+ mode = next;
64
+ };
41
65
  while (options.length) {
42
66
  const option = options.shift();
43
- const match = option.match(/^--(name|content-type)(?:=(.*))?$/);
44
- if (!match || fields[match[1]] !== undefined)
45
- throw new Error("Usage: rech upload --name icon.png [--content-type image/png] < icon.png (stdin only; no path argument)");
46
- const value = match[2] ?? options.shift();
47
- if (!value || value.startsWith("--"))
48
- throw new Error(`Missing value for --${match[1]}`);
49
- fields[match[1]] = value;
67
+ const match = option.match(/^--(name|content-type|into|drop|paste|chooser)(?:=(.*))?$/);
68
+ if (!match) {
69
+ if (option.startsWith("-") || path !== undefined)
70
+ throw new Error(path !== undefined ? `One file per upload; unexpected ${option}` : `Unknown option ${option}`);
71
+ path = option;
72
+ continue;
73
+ }
74
+ const [, flag, inline] = match;
75
+ if (flag === "chooser") {
76
+ setMode("chooser");
77
+ continue;
78
+ }
79
+ const value = inline ?? (flag === "paste" && (!options[0] || options[0].startsWith("-")) ? "" : options.shift());
80
+ if (value === undefined || value === "" && flag !== "paste" || value.startsWith("--"))
81
+ throw new Error(`Missing value for --${flag}.`);
82
+ if (flag === "into" || flag === "drop" || flag === "paste") {
83
+ setMode(flag);
84
+ if (value)
85
+ target = value;
86
+ continue;
87
+ }
88
+ if (fields[flag] !== undefined)
89
+ throw new Error(`--${flag} given twice`);
90
+ fields[flag] = value;
50
91
  }
51
- if (!validName(fields.name))
52
- throw new Error("upload requires --name with a plain filename (no path)");
53
- const contentType = fields["content-type"] ?? (fields.name.toLowerCase().endsWith(".png") ? "image/png" : fields.name.toLowerCase().endsWith(".svg") ? "image/svg+xml" : "");
92
+ if (target !== undefined && !validTarget(target))
93
+ throw new Error("Upload target must be a snapshot ref (e12) or a selector");
94
+ if (path === undefined && !stdin)
95
+ throw new Error(`upload needs a file.`);
96
+ const name = fields.name ?? (path !== undefined ? basename(path) : undefined);
97
+ if (!validName(name))
98
+ throw new Error(path !== undefined ? "The file name must be a plain name; rename the file or pass --name" : "Reading stdin needs --name with a plain filename (no path)");
99
+ const contentType = fields["content-type"] ?? EXTENSION_TYPES[name.toLowerCase().split(".").pop() ?? ""] ?? "";
54
100
  if (!UPLOAD_TYPES.includes(contentType))
55
- throw new Error("Upload content type must be image/png or image/svg+xml");
56
- const chunks = [];
57
- let size = 0;
58
- for await (const chunk of input) {
59
- size += chunk.byteLength;
60
- if (size > MAX_UPLOAD_BYTES)
61
- throw new Error("Upload exceeds 2 MiB");
62
- chunks.push(Buffer.from(chunk));
101
+ throw new Error(`Upload content type must be one of ${UPLOAD_TYPES.join(", ")} (from the file extension, or --content-type)`);
102
+ let bytes;
103
+ if (path !== undefined) {
104
+ bytes = Buffer.from(await readLocal(path).catch((e) => {
105
+ throw new Error(`Cannot read ${path}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e}`);
106
+ }));
107
+ if (bytes.length > MAX_UPLOAD_BYTES)
108
+ throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
109
+ } else {
110
+ const chunks = [];
111
+ let size = 0;
112
+ for await (const chunk of stdin) {
113
+ size += chunk.byteLength;
114
+ if (size > MAX_UPLOAD_BYTES)
115
+ throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
116
+ chunks.push(Buffer.from(chunk));
117
+ }
118
+ bytes = Buffer.concat(chunks);
63
119
  }
64
- const bytes = Buffer.concat(chunks);
65
120
  if (!matchesType(bytes, contentType))
66
- throw new Error("Upload bytes do not match the content type (PNG or UTF-8 SVG without DTD/entities required)");
67
- return { args: ["upload", ...sessions], upload: { name: fields.name, contentType, data: bytes.toString("base64") } };
121
+ throw new Error(`Upload bytes are not ${contentType} (SVG must be UTF-8 without DTD/entities)`);
122
+ const upload = { name, contentType, data: bytes.toString("base64") };
123
+ if (mode !== "auto")
124
+ upload.mode = mode;
125
+ if (target !== undefined)
126
+ upload.target = target;
127
+ return { args: ["upload", ...sessions], upload };
128
+ }
129
+ var prepareStdinUpload = (args, input) => prepareUpload(args, input, () => Promise.reject(new Error("stdin only")));
130
+ var CLICKED = "rech-upload: clicked, filling the file chooser";
131
+ var locatorFor = (target) => `page.locator(${JSON.stringify(/^(f\d+)?e\d+$/.test(target) ? `aria-ref=${target}` : target)})`;
132
+ function uploadScript(mode, target, file, payload) {
133
+ const fileJson = JSON.stringify(file);
134
+ const bytes = `{ data: ${JSON.stringify(payload.data)}, name: ${JSON.stringify(payload.name)}, type: ${JSON.stringify(payload.contentType)} }`;
135
+ const makeTransfer = `({ data, name, type }) => { const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); const dt = new DataTransfer(); dt.items.add(new File([bytes], name, { type })); return dt; }`;
136
+ if (mode === "auto")
137
+ return `async page => {
138
+ const inputs = page.locator('input[type=file]');
139
+ const count = await inputs.count();
140
+ if (count !== 1) throw new Error(count ? count + ' file inputs on this page: pick one with --into <ref>' : 'No file chooser is open and the page has no file input. Name the upload button: rech upload <file> --into <ref>');
141
+ await inputs.setInputFiles(${fileJson});
142
+ return "Set on the page's file input";
143
+ }`;
144
+ if (mode === "into")
145
+ return `async page => {
146
+ const target = ${locatorFor(target)};
147
+ if (await target.evaluate(el => el instanceof HTMLInputElement && el.type === 'file')) {
148
+ await target.setInputFiles(${fileJson});
149
+ return 'Set on the file input';
150
+ }
151
+ // Not an input: click it. playwright-cli takes the file chooser that opens as its modal state,
152
+ // which the follow-up \`upload\` fills (see receiveUpload).
153
+ await target.click();
154
+ return ${JSON.stringify(CLICKED)};
155
+ }`;
156
+ if (mode === "drop")
157
+ return `async page => {
158
+ const target = ${locatorFor(target)};
159
+ const transfer = await page.evaluateHandle(${makeTransfer}, ${bytes});
160
+ for (const type of ['dragenter', 'dragover', 'drop']) await target.dispatchEvent(type, { dataTransfer: transfer });
161
+ return 'Dropped onto the target';
162
+ }`;
163
+ return `async page => {
164
+ ${target ? `const target = ${locatorFor(target)}; await target.focus(); const element = await target.elementHandle();` : `const element = await page.evaluateHandle(() => document.activeElement || document.body);`}
165
+ const handled = await element.evaluate((el, file) => {
166
+ const make = ${makeTransfer};
167
+ const event = new ClipboardEvent('paste', { clipboardData: make(file), bubbles: true, cancelable: true });
168
+ el.dispatchEvent(event);
169
+ return event.defaultPrevented;
170
+ }, ${bytes});
171
+ return handled ? 'Pasted; the page handled it' : 'Pasted, but nothing on the page handled the paste: try --drop or --into';
172
+ }`;
68
173
  }
69
174
  function currentPageOrigin(output) {
70
175
  const current = output.split(`
@@ -77,6 +182,9 @@ function currentPageOrigin(output) {
77
182
  throw new Error("Uploads require an HTTP(S) page");
78
183
  return parsed.origin;
79
184
  }
185
+ function cleanOutput(text, path, dir, name) {
186
+ return text.replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n*/g, "").replaceAll(path, name).replaceAll(dir, "<upload>");
187
+ }
80
188
  async function receiveUpload(options) {
81
189
  const record = { name: null, size: null, sha256: null, origin: null, result: "rejected" };
82
190
  let dir;
@@ -84,12 +192,14 @@ async function receiveUpload(options) {
84
192
  const p = options.payload;
85
193
  if (validName(p?.name))
86
194
  record.name = p.name;
195
+ if (UPLOAD_MODES.includes(p?.mode))
196
+ record.mode = p.mode;
87
197
  if (options.policy === undefined)
88
198
  throw new Error("Uploads are disabled on this listener");
89
199
  validateUploadPolicy(options.policy);
90
200
  if (options.args.length !== 1 || options.args[0] !== "upload")
91
- throw new Error("Upload takes stdin bytes, not host paths or CLI options");
92
- if (!p || !validName(p.name) || typeof p.contentType !== "string" || typeof p.data !== "string" || Object.keys(p).some((k) => !["name", "contentType", "data"].includes(k)))
201
+ throw new Error("Upload takes file bytes, not host paths or CLI options");
202
+ if (!p || !validName(p.name) || typeof p.contentType !== "string" || typeof p.data !== "string" || Object.keys(p).some((k) => !["name", "contentType", "data", "mode", "target"].includes(k)) || p.mode !== undefined && !UPLOAD_MODES.includes(p.mode) || p.target !== undefined && !validTarget(p.target) || (p.mode === "into" || p.mode === "drop") && p.target === undefined || (p.mode === "auto" || p.mode === "chooser") && p.target !== undefined)
93
203
  throw new Error("Invalid upload payload");
94
204
  const limit = options.policy.maxBytes ?? MAX_UPLOAD_BYTES;
95
205
  if (p.data.length > 4 * Math.ceil(limit / 3) || /[^A-Za-z0-9+/=]/.test(p.data))
@@ -106,16 +216,31 @@ async function receiveUpload(options) {
106
216
  throw new Error("Could not inspect the upload page");
107
217
  record.origin = currentPageOrigin(tabs.stdout);
108
218
  dir = await mkdtemp(join(tmpdir(), "rech-upload-"));
109
- const path = join(dir, randomBytes(16).toString("hex") + (p.contentType === "image/png" ? ".png" : ".svg"));
219
+ const path = join(dir, p.name);
110
220
  await writeFile(path, bytes, { mode: 384, flag: "wx" });
111
221
  record.result = "failed";
112
- const result = await options.run(["upload", path]);
113
- record.result = result.status === 0 && !/^### Error\b/m.test(result.stdout) ? "completed" : "failed";
222
+ const failed = (r) => r.status !== 0 || /^### Error\b/m.test(r.stdout);
223
+ const script = async (mode) => {
224
+ const file = join(dir, randomBytes(8).toString("hex") + ".js");
225
+ await writeFile(file, uploadScript(mode, p.target, path, p), { mode: 384, flag: "wx" });
226
+ return options.run(["run-code", "--filename", file]);
227
+ };
228
+ const mode = p.mode ?? "auto";
229
+ let result = mode === "auto" || mode === "chooser" ? await options.run(["upload", path]) : await script(mode);
230
+ const noChooser = (r) => /No file chooser visible|only be used when there is related modal state/.test(r.stdout + r.stderr);
231
+ if (mode === "auto" && failed(result) && noChooser(result))
232
+ result = await script("auto");
233
+ if (mode === "into" && !failed(result) && result.stdout.match(/^### Result\n(.*)$/m)?.[1]?.includes(CLICKED)) {
234
+ const filled = await options.run(["upload", path]);
235
+ result = noChooser(filled) ? { status: 1, stdout: "", stderr: `Clicking ${p.target} opened no file chooser. Point --into at the file input or the button that opens it (see rech snapshot).
236
+ ` } : filled;
237
+ }
238
+ record.result = failed(result) ? "failed" : "completed";
114
239
  return {
115
240
  ...result,
116
241
  status: record.result === "completed" ? 0 : 1,
117
- stdout: result.stdout.replaceAll(path, p.name),
118
- stderr: result.stderr.replaceAll(path, p.name)
242
+ stdout: cleanOutput(result.stdout, path, dir, p.name),
243
+ stderr: cleanOutput(result.stderr, path, dir, p.name)
119
244
  };
120
245
  } catch (error) {
121
246
  return { status: 1, stdout: "", stderr: (error instanceof Error ? error.message : "Upload failed") + `
@@ -142,7 +267,7 @@ async function readRequestJson(req) {
142
267
  break;
143
268
  size += value.byteLength;
144
269
  if (size > MAX_REQUEST_BYTES)
145
- throw new Error("Request exceeds 3 MiB");
270
+ throw new Error(`Request exceeds ${MAX_REQUEST_BYTES / 1024 / 1024} MiB`);
146
271
  chunks.push(value);
147
272
  }
148
273
  } catch (error) {
@@ -156,10 +281,14 @@ async function readRequestJson(req) {
156
281
  export {
157
282
  MAX_REQUEST_BYTES,
158
283
  MAX_UPLOAD_BYTES,
284
+ UPLOAD_MODES,
159
285
  UPLOAD_TYPES,
286
+ UPLOAD_USAGE,
160
287
  currentPageOrigin,
161
288
  prepareStdinUpload,
289
+ prepareUpload,
162
290
  readRequestJson,
163
291
  receiveUpload,
292
+ uploadScript,
164
293
  validateUploadPolicy
165
294
  };