rechrome 1.39.1 → 1.41.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
@@ -386,14 +386,30 @@ and the host check the size, and check the bytes against the type (PNG/JPEG/GIF/
386
386
  signatures; UTF-8 SVG without DTD/entities). These checks identify the format; they
387
387
  don't sanitize SVG or scan for malware. The host never renders the file.
388
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:
389
+ **On by default:** uploads are on for every listener, shared ones too. The client
390
+ reads the file and sends only its bytes, and the host never reads a path for an
391
+ upload, so a link holder can only upload files they already have. Each upload is
392
+ logged in the daemon log with its name, size, sha256 and the page's origin. The
393
+ host can turn them off for a shared listener:
391
394
 
392
395
  ```bash
393
- rech listener set <listener> --uploads # --no-uploads turns them off again
396
+ rech listener set <listener> --no-uploads # writes "uploads": false; --uploads turns them on again
394
397
  ```
395
398
 
396
- That writes an `uploads` object to the listener's entry in `~/.rechrome/listeners.json`.
399
+ **Files already on the host (opt-in):** off by default. The host can let a shared
400
+ listener's links upload files that are already on the host, but only from
401
+ directories it names:
402
+
403
+ ```bash
404
+ rech listener set <listener> --host-upload-dir ~/shots # repeatable; --no-host-uploads turns it off
405
+ rech upload --host /Users/me/shots/a.png # on the client: sends the path, not bytes
406
+ ```
407
+
408
+ The host resolves symlinks and refuses anything outside those directories, anything
409
+ that isn't a regular file, and anything over the size cap. The log line also records
410
+ the host path.
411
+
412
+ An `uploads` object in the listener's entry in `~/.rechrome/listeners.json` sets limits.
397
413
  The host can tune it by hand, lowering the byte limit or narrowing the types, but not
398
414
  going above the 10 MiB cap or adding other types:
399
415
 
package/fill-secret.js CHANGED
@@ -7,6 +7,8 @@ import { homedir } from "os";
7
7
  import { join } from "path";
8
8
  var SECRET_MASK = "***";
9
9
  var SECRET_MASK_TTL_MS = 15 * 60000;
10
+ var MIN_MASK_LENGTH = 6;
11
+ var maskable = (value) => !!value && value.length >= MIN_MASK_LENGTH;
10
12
  var FILL_SECRET_USAGE = `Usage: rech fill-secret <ref> (--from-env VAR | --from-stdin | --totp-from-env VAR | --totp-from-stdin)
11
13
  [--env-file <path>] [--allow-domain <glob>]... [--submit]
12
14
  Fills <ref> (from \`rech snapshot\`) with a secret the daemon never logs or echoes.
@@ -211,7 +213,7 @@ class SecretMasker {
211
213
  this.now = now;
212
214
  }
213
215
  add(value, ttlMs = this.ttlMs) {
214
- if (value)
216
+ if (maskable(value))
215
217
  this.values.set(value, this.now() + ttlMs);
216
218
  }
217
219
  get active() {
@@ -378,6 +380,7 @@ function maskPasswordLines(text) {
378
380
  }
379
381
  export {
380
382
  FILL_SECRET_USAGE,
383
+ MIN_MASK_LENGTH,
381
384
  SECRET_MASK,
382
385
  SECRET_MASK_TTL_MS,
383
386
  SecretMasker,
@@ -390,6 +393,7 @@ export {
390
393
  hasSnapshot,
391
394
  hostAllowed,
392
395
  maskPasswordLines,
396
+ maskable,
393
397
  parseDotenv,
394
398
  parseFillSecretArgs,
395
399
  parseFillSecretWire,
package/fill-secret.ts CHANGED
@@ -19,6 +19,12 @@ import { join } from "path";
19
19
 
20
20
  export const SECRET_MASK = "***";
21
21
  export const SECRET_MASK_TTL_MS = 15 * 60_000;
22
+ /** Shorter values are never masked: masking "1" everywhere would turn every 1 in every output
23
+ * into ***, corrupting ids and refs for the daemon's lifetime. A TOTP code (6 digits) still is.
24
+ * A short value is kept out of fill-secret's own reply by dropping the echoed code that holds it,
25
+ * and out of its error message by an exact replace; anything the page itself shows is not masked. */
26
+ export const MIN_MASK_LENGTH = 6;
27
+ export const maskable = (value: string | undefined | null): value is string => !!value && value.length >= MIN_MASK_LENGTH;
22
28
 
23
29
  // ---------- client side ----------
24
30
 
@@ -233,7 +239,7 @@ export function buildFillCode(ref: string, value: string, allowDomains: string[]
233
239
  export class SecretMasker {
234
240
  private values = new Map<string, number>();
235
241
  constructor(private ttlMs = SECRET_MASK_TTL_MS, private now = () => Date.now()) {}
236
- add(value: string, ttlMs = this.ttlMs) { if (value) this.values.set(value, this.now() + ttlMs); }
242
+ add(value: string, ttlMs = this.ttlMs) { if (maskable(value)) this.values.set(value, this.now() + ttlMs); }
237
243
  get active(): boolean { this.prune(); return this.values.size > 0; }
238
244
  mask(text: string): string {
239
245
  if (!text) return text;
package/listeners.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // @bun
2
2
  // scoped-upload.ts
3
+ import { basename, join, isAbsolute, sep } from "path";
3
4
  var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
4
5
  var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
5
6
  var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
6
7
  function validateUploadPolicy(value) {
7
8
  const p = value;
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..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
9
+ if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes", "hostDirs"].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))) || p.hostDirs !== undefined && (!Array.isArray(p.hostDirs) || !p.hostDirs.length || p.hostDirs.some((d) => typeof d !== "string" || !isAbsolute(d) || d.includes("\x00") || d === sep)))
10
+ throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}), contentTypes (${UPLOAD_TYPES.join(", ")}) and hostDirs (absolute directories, not /)`);
10
11
  }
11
12
  var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
12
13
  (no flag) the file chooser a click opened, else the page's only <input type=file>
@@ -14,16 +15,17 @@ var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this
14
15
  --drop <ref|selector> a drag-and-drop zone
15
16
  --paste [ref|selector] an editor that takes pasted images (default: the focused element)
16
17
  --name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
18
+ --host <path> a file already on the host (only where its listener allows that directory)
17
19
  Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
18
20
 
19
21
  // listeners.ts
20
22
  import { isIP } from "net";
21
23
  import { createHash, randomBytes } from "crypto";
22
24
  import { homedir } from "os";
23
- import { join } from "path";
25
+ import { join as join2 } from "path";
24
26
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
25
27
  var MAX_RETIRED_PORTS = 32;
26
- var LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
28
+ var LISTENERS_FILE = join2(homedir(), ".rechrome", "listeners.json");
27
29
  var isLoopback = (host) => host === "::1" || /^127\./.test(host);
28
30
  var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
29
31
  var profileOutputPrefix = (profile) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
@@ -72,7 +74,7 @@ function validateListeners(value) {
72
74
  } else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some((p) => typeof p !== "string" || !p.trim() || p.includes("\x00") || p === "*")) {
73
75
  throw new Error("Listener profiles must be a nonempty list of registered profile keys");
74
76
  }
75
- if (l.uploads !== undefined)
77
+ if (l.uploads !== undefined && l.uploads !== false)
76
78
  validateUploadPolicy(l.uploads);
77
79
  const address = listenerAddress(l);
78
80
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
@@ -110,7 +112,7 @@ async function writeListeners(config) {
110
112
  config.retiredPorts = retired;
111
113
  else
112
114
  delete config.retiredPorts;
113
- await mkdir(join(homedir(), ".rechrome"), { recursive: true });
115
+ await mkdir(join2(homedir(), ".rechrome"), { recursive: true });
114
116
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
115
117
  await writeFile(temp, JSON.stringify(config, null, 2) + `
116
118
  `, { mode: 384 });
@@ -126,7 +128,7 @@ function authorizeProfileRequest(listener, body) {
126
128
  if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
127
129
  throw new Error("Profile override does not match request identity");
128
130
  const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
129
- if (args[0] === "upload" && listener.uploads === undefined)
131
+ if (args[0] === "upload" && listener.uploads === false)
130
132
  throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
131
133
  if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0]))
132
134
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
package/listeners.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from "path";
6
6
  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
- export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy };
9
+ export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false };
10
10
  // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
11
11
  // saved for a removed listener gets "connection refused", not some other listener's answer.
12
12
  export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
@@ -58,7 +58,9 @@ export function validateListeners(value: unknown): ListenerConfig {
58
58
  } else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some(p => typeof p !== "string" || !p.trim() || p.includes("\0") || p === "*")) {
59
59
  throw new Error("Listener profiles must be a nonempty list of registered profile keys");
60
60
  }
61
- if (l.uploads !== undefined) validateUploadPolicy(l.uploads);
61
+ // Uploads are on unless set to false: the client sends file bytes it already has, and the
62
+ // host never reads a path for them.
63
+ if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
62
64
  const address = listenerAddress(l);
63
65
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
64
66
  names.add(l.name); addresses.add(address); keys.add(l.key);
@@ -103,7 +105,7 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
103
105
  if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
104
106
  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
107
  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)
108
+ if (args[0] === "upload" && listener.uploads === false)
107
109
  throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
108
110
  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
111
  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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.39.1",
3
+ "version": "1.41.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -23,6 +23,9 @@
23
23
  "listeners.js",
24
24
  "fill-secret.ts",
25
25
  "fill-secret.js",
26
+ "redact.ts",
27
+ "redact.js",
28
+ "secret-rules",
26
29
  "extension",
27
30
  "rechrome.js",
28
31
  "rechrome.ts",
@@ -50,7 +53,7 @@
50
53
  "vendor-cli": "bash scripts/vendor-cli.sh",
51
54
  "prepare": "bash scripts/vendor-cli.sh --prepare",
52
55
  "check-pack": "bash scripts/check-pack.sh",
53
- "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bash scripts/check-pack.sh"
56
+ "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bun build redact.ts --target=bun --outfile=redact.js && bash scripts/check-pack.sh"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/bun": "latest",
package/rechrome.js CHANGED
@@ -5,9 +5,10 @@ import { readListeners, writeListeners as writeListenersFile, listenerAddress, i
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
7
7
  import { readExtensionTokenFromProfile } from "./extension-token.js";
8
+ import { redact } from "./redact.js";
8
9
  import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
9
10
  import { createHash, randomBytes } from "crypto";
10
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
11
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
11
12
  import { hostname, homedir, networkInterfaces } from "os";
12
13
  import { isIPv4 } from "net";
13
14
  import { join, basename, dirname, resolve } from "path";
@@ -16,7 +17,7 @@ import { createRequire } from "node:module";
16
17
  import { spawn as cpSpawn } from "child_process";
17
18
  import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
18
19
  import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
19
- import { DEFAULT_SIGNAL, WEBRTC, createShare, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
20
+ import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
20
21
  import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
21
22
  import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
22
23
  import { startSignalRelay } from "./signal-relay.js";
@@ -642,7 +643,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
642
643
  const envRaw = await file(globalEnvFile).text().catch(() => "");
643
644
  const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
644
645
  const content = [...lines, newLine, ""].join("\n");
645
- await Bun.write(globalEnvFile, content);
646
+ await writeAtomic(globalEnvFile, content, 0o600); // holds the management key
646
647
  }
647
648
  process.env[ENV_KEY] = url;
648
649
  return url;
@@ -1779,7 +1780,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1779
1780
  if (!picked.startsWith(SETUP)) return picked;
1780
1781
  const dir = picked.slice(SETUP.length);
1781
1782
  console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1782
- await setup({ profile: dir });
1783
+ await setup({ profile: dir, skipShare: true });
1783
1784
  const after = await readTokenRegistry();
1784
1785
  const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1785
1786
  if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
@@ -2188,7 +2189,13 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2188
2189
  }
2189
2190
  const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
2190
2191
  // The daemon already masks the value; this is a second line of defence for an older daemon.
2191
- const scrub = (s: string) => secret && s ? s.replaceAll(secret, SECRET_MASK) : s;
2192
+ // Then any secret-shaped string (API keys, tokens) in page text, unless RECH_REDACT=0.
2193
+ const scrub = (s: string) => {
2194
+ if (!s) return s;
2195
+ if (secret) s = s.replaceAll(secret, SECRET_MASK);
2196
+ if (process.env.RECH_REDACT === "0") return s;
2197
+ try { return redact(s).text; } catch { return s; } // missing rules must not swallow output
2198
+ };
2192
2199
  const { files, existingSession } = served;
2193
2200
  const status = served.status, stdout = scrub(served.stdout), stderr = scrub(served.stderr);
2194
2201
  if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
@@ -2240,9 +2247,8 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2240
2247
  // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
2241
2248
  // .env.local (secrets), so it is git-ignored as a whole when first created.
2242
2249
  const dlDir = join(identity.dataDir, "output");
2250
+ await ensureProjectDir(identity.dataDir);
2243
2251
  mkdirSync(dlDir, { recursive: true });
2244
- const gitignorePath = join(identity.dataDir, ".gitignore");
2245
- if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
2246
2252
  for (const name of files) {
2247
2253
  const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
2248
2254
  headers: { Authorization: `Bearer ${parseUrl(url).key}` },
@@ -2731,7 +2737,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
2731
2737
  const envRaw = await file(globalEnvFile).text().catch(() => "");
2732
2738
  const lines = envRaw.trimEnd().split("\n");
2733
2739
  const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
2734
- await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
2740
+ await writeAtomic(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"), 0o600);
2735
2741
 
2736
2742
  const home = HOME;
2737
2743
  const bunBin = Bun.which("bun") ?? process.execPath;
@@ -3339,7 +3345,7 @@ async function rotateKey(name: string): Promise<void> {
3339
3345
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3340
3346
  }
3341
3347
 
3342
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
3348
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void> {
3343
3349
  const config = await requireListeners();
3344
3350
  let listener = config.listeners.find(l => l.name === name);
3345
3351
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3347,11 +3353,23 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3347
3353
  if (opts.uploads !== undefined) {
3348
3354
  if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
3349
3355
  // {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
3350
- if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
3356
+ if (opts.uploads) listener.uploads = listener.uploads || {}; else listener.uploads = false;
3357
+ }
3358
+ if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3359
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener; host-path uploads are set on shared listeners.`);
3360
+ if (listener.uploads === false) throw new Error(`Uploads are off on "${name}"; turn them on first: rech listener set ${name} --uploads`);
3361
+ const policy = listener.uploads ?? {};
3362
+ if (opts.noHostUploads) delete policy.hostDirs;
3363
+ else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
3364
+ listener.uploads = policy;
3351
3365
  }
3352
3366
  await writeListeners(config);
3353
3367
  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`);
3368
+ if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3369
+ const dirs = (listener.uploads || {}).hostDirs;
3370
+ console.log(dirs?.length ? `${name}: host-path uploads allowed from ${dirs.join(", ")} (rech upload --host <path>; each is logged)` : `${name}: host-path uploads off`);
3371
+ }
3372
+ 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`);
3355
3373
  }
3356
3374
 
3357
3375
  const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
@@ -3406,9 +3424,7 @@ async function warnSecretFile(path: string | undefined): Promise<void> {
3406
3424
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
3407
3425
  async function saveProjectUrl(url: string): Promise<string> {
3408
3426
  const { dataDir } = await getClientIdentity();
3409
- mkdirSync(dataDir, { recursive: true });
3410
- const gitignore = join(dataDir, ".gitignore");
3411
- if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
3427
+ await ensureProjectDir(dataDir);
3412
3428
  return writeEnvUrl(join(dataDir, ".env.local"), url);
3413
3429
  }
3414
3430
 
@@ -3418,11 +3434,46 @@ async function saveGlobalUrl(url: string): Promise<string> {
3418
3434
  return writeEnvUrl(GLOBAL_ENV_FILE, url);
3419
3435
  }
3420
3436
 
3437
+ /** Write a file whole or not at all: a temp file beside it, then rename (atomic on one filesystem). */
3438
+ export async function writeAtomic(path: string, data: string, mode?: number): Promise<void> {
3439
+ // A symlinked .env.local stays a link: write the file it points to.
3440
+ // Follow links by hand: realpath fails on a dangling one, whose target we still create.
3441
+ for (let hops = 0; hops < 8; hops++) {
3442
+ let target: string;
3443
+ try { target = readlinkSync(path); } catch { break; } // not a link, or not there yet
3444
+ path = resolve(dirname(path), target);
3445
+ }
3446
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
3447
+ try {
3448
+ await writeFile(tmp, data, mode === undefined ? {} : { mode });
3449
+ if (mode !== undefined) await chmod(tmp, mode);
3450
+ renameSync(tmp, path);
3451
+ } catch (error) { try { unlinkSync(tmp); } catch { /* never written */ } throw error; }
3452
+ }
3453
+
3454
+ export const PROJECT_DIR_README = `# .rechrome/
3455
+
3456
+ rech's state for this project (like .git/). It ignores itself: see .gitignore.
3457
+
3458
+ - .env.local RECHROME_URL for this project. Holds a key: keep it private.
3459
+ - output/ files rech commands saved (screenshots, snapshots, downloads).
3460
+
3461
+ Safe to delete; rech recreates it. More: https://github.com/snomiao/rechrome
3462
+ `;
3463
+
3464
+ /** Create <project>/.rechrome with its .gitignore ("*") and README, so a key in it is never committed. */
3465
+ export async function ensureProjectDir(dir: string): Promise<void> {
3466
+ mkdirSync(dir, { recursive: true });
3467
+ const gitignore = join(dir, ".gitignore");
3468
+ if (!existsSync(gitignore)) await writeAtomic(gitignore, "*\n");
3469
+ const readme = join(dir, "README.md");
3470
+ if (!existsSync(readme)) await writeAtomic(readme, PROJECT_DIR_README);
3471
+ }
3472
+
3421
3473
  /** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
3422
3474
  async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3423
3475
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3424
- await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3425
- await chmod(envPath, 0o600);
3476
+ await writeAtomic(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), 0o600);
3426
3477
  await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
3427
3478
  return envPath;
3428
3479
  }
@@ -3644,18 +3695,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3644
3695
  const scheme = previous.protocol;
3645
3696
  let url = `${scheme}://${management.key}@${listenerAddress(management)}${normalizePrefix(management.prefix)}`;
3646
3697
  const { host, port, protocol, key: serveKey } = parseUrl(url);
3698
+ // Connect the profile locally first; who else may reach it is asked at the end (setupShareStep),
3699
+ // once the bridge works. --listen still picks the exposure up front, for scripts.
3647
3700
  let desiredBind = management.host;
3648
3701
  if (opts.listen) desiredBind = chooseListenAddress(listenChoices, opts.listen);
3649
- else if (isTTY) {
3650
- console.log("\n Where should this profile be accessible? (Other profiles keep their listeners.)");
3651
- listenChoices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label} — ${choice.address}`));
3652
- while (true) {
3653
- const answer = (await ask(" Choice [1]: ", "1")).trim();
3654
- const choice = /^\d+$/.test(answer) ? listenChoices[Number(answer) - 1] : undefined;
3655
- if (choice) { desiredBind = choice.address; break; }
3656
- console.log(" Enter one of the numbers shown above.");
3657
- }
3658
- }
3659
3702
  const pingManagement = () => fetch(serviceUrl(url, "ping"), {
3660
3703
  headers: { Authorization: `Bearer ${serveKey}` }, signal: AbortSignal.timeout(2000),
3661
3704
  }).then(async r => r.ok && (await r.json()).multiListener === true).catch(() => false);
@@ -3894,7 +3937,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3894
3937
  )).trim();
3895
3938
  if (saveChoice !== "4") {
3896
3939
  const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
3897
- if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
3940
+ if (saveChoice === "2") await ensureProjectDir(join(process.cwd(), ".rechrome"));
3898
3941
  const existedBefore = await file(globalEnvPath).exists();
3899
3942
  const existing = await file(globalEnvPath).text().catch(() => "");
3900
3943
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
@@ -3902,7 +3945,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3902
3945
  const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
3903
3946
  if (rechIdx >= 0) lines[rechIdx] = newLine;
3904
3947
  else lines.push(newLine);
3905
- await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
3948
+ await writeAtomic(globalEnvPath, lines.join("\n").trim() + "\n", 0o600);
3906
3949
  console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
3907
3950
  }
3908
3951
 
@@ -3938,6 +3981,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3938
3981
  guide?.close();
3939
3982
  console.log(` Saved token for ${extraDisplay}`);
3940
3983
  }
3984
+ if (bridgeVerified && isTTY && !opts.listen && !opts.skipShare) await setupShareStep(ask, profileEmail);
3941
3985
  rl?.close();
3942
3986
  envWatcher?.close();
3943
3987
  if (bridgeVerified)
@@ -3946,6 +3990,70 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3946
3990
  console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
3947
3991
  }
3948
3992
 
3993
+ const SIGNAL_SELFHOST_DOC = "https://github.com/snomiao/rechrome/blob/main/docs/signal-selfhost.md";
3994
+
3995
+ /** The menu of setupShareStep: existing listeners first, then the new kinds. */
3996
+ export function shareMenu(listeners: Listener[], choices: ListenChoice[]): { key: string; label: string; listener?: string; kind?: "lan" | "tailscale" | "webrtc" | "webrtc-own" }[] {
3997
+ const menu: ReturnType<typeof shareMenu> = [{ key: "1", label: "Don't add sharing (local access is always on; existing shares stay: rech share ls)" }];
3998
+ for (const l of listeners.filter(l => l.profiles !== "*"))
3999
+ menu.push({ key: String(menu.length + 1), label: `Existing listener "${l.name}" (${listenerAddress(l)}${l.publicUrl ? `, ${l.publicUrl}` : ""})`, listener: l.name });
4000
+ const has = (k: string) => choices.some(c => c.kind === k);
4001
+ if (has("lan")) menu.push({ key: String(menu.length + 1), label: "New: LAN (machines on this network)", kind: "lan" });
4002
+ if (has("tailscale")) menu.push({ key: String(menu.length + 1), label: "New: Tailscale only (your tailnet)", kind: "tailscale" });
4003
+ menu.push({ key: String(menu.length + 1), label: "New: WebRTC via rechrome.pages.dev (works across networks, nothing to run)", kind: "webrtc" });
4004
+ menu.push({ key: String(menu.length + 1), label: "New: WebRTC via your own signal server (and TURN)", kind: "webrtc-own" });
4005
+ return menu;
4006
+ }
4007
+
4008
+ /** setup's last step: once the profile works here, offer to share it with other machines. */
4009
+ async function setupShareStep(ask: (q: string, def?: string) => Promise<string>, profile: string): Promise<void> {
4010
+ const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
4011
+ const choices = await detectListenChoices();
4012
+ const menu = shareMenu(listeners, choices);
4013
+ console.log(`\n○ Share ${profile} with other machines?`);
4014
+ for (const m of menu) console.log(` ${m.key}. ${m.label}`);
4015
+ let pick: (typeof menu)[number] | undefined;
4016
+ while (!pick) {
4017
+ const answer = (await ask(" Choice [1]: ", "1")).trim();
4018
+ pick = menu.find(m => m.key === answer);
4019
+ if (!pick) console.log(" Enter one of the numbers shown above.");
4020
+ }
4021
+ try {
4022
+ if (pick.key === "1") { console.log("✓ Not shared. Later: rech share new " + JSON.stringify(profile)); return; }
4023
+ if (pick.listener) {
4024
+ const config = await requireListeners();
4025
+ allowProfiles(config, pick.listener, [profile]);
4026
+ await writeListeners(config);
4027
+ await printProfileUri(profile, pick.listener);
4028
+ return;
4029
+ }
4030
+ if (pick.kind === "tailscale") {
4031
+ const dns = (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "");
4032
+ if (!dns?.endsWith(".js.net")) console.log(` ! Tailscale's hostname is ${dns ? `"${dns}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
4033
+ else console.log(` Tailscale hostname: ${dns}`);
4034
+ }
4035
+ let signal: string | undefined;
4036
+ if (pick.kind === "webrtc-own") {
4037
+ console.log(`\n Run a signal server, then paste its URL here:`);
4038
+ console.log(` 1. Read ${SIGNAL_SELFHOST_DOC}`);
4039
+ console.log(` (A: Cloudflare Workers is the easiest; C: Tailscale Funnel needs no domain).`);
4040
+ console.log(` 2. Deploy it and open its URL in a browser to check that it answers.`);
4041
+ console.log(` 3. For TURN (strict NATs), set CF_TURN_KEY_ID and CF_TURN_API_TOKEN in the daemon's env (see "TURN" in that doc).`);
4042
+ while (!signal) {
4043
+ const raw = (await ask(" Signal server URL (empty: cancel): ")).trim();
4044
+ if (!raw) { console.log(" Not shared. Later: rech share new <profile> --signal <url>"); return; }
4045
+ try { signal = normalizeSignal(raw); } catch (error) { console.log(` ${error instanceof Error ? error.message : error}`); continue; }
4046
+ const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
4047
+ if (!ok) console.log(` ! ${signal} did not answer; the share is made anyway, check the server before sending the link.`);
4048
+ }
4049
+ }
4050
+ const via = pick.kind === "webrtc" || pick.kind === "webrtc-own" ? WEBRTC : pick.kind!;
4051
+ await shareNew([profile], { via: [via], ...(signal ? { signal } : {}) });
4052
+ } catch (error) {
4053
+ console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share new ${JSON.stringify(profile)}.`);
4054
+ }
4055
+ }
4056
+
3949
4057
  // ---- shares: invite links, devices, transports (see shares.js) ----------------------------
3950
4058
 
3951
4059
  const fmtDate = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 16).replace("T", " ") : "-";
@@ -4322,7 +4430,7 @@ async function status(): Promise<void> {
4322
4430
  }
4323
4431
 
4324
4432
 
4325
- export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
4433
+ export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean; /** Called from `rech share`, which shares the profile itself. */ skipShare?: boolean };
4326
4434
  export type RechHandlers = {
4327
4435
  serve(): Promise<void> | void;
4328
4436
  status(): Promise<void>;
@@ -4339,7 +4447,7 @@ export type RechHandlers = {
4339
4447
  allowListener(name: string, profiles: string[]): Promise<void>;
4340
4448
  denyListener(name: string, profiles: string[]): Promise<void>;
4341
4449
  rotateKey(name: string): Promise<void>;
4342
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
4450
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
4343
4451
  setup(opts: SetupOptions): Promise<void>;
4344
4452
  tray(action?: string): Promise<void>;
4345
4453
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4485,9 +4593,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4485
4593
  .positional("name", { type: "string", demandOption: true })
4486
4594
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
4487
4595
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
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 } : {}) }))
4596
+ .option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
4597
+ .option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
4598
+ .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4599
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
4600
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}) }))
4491
4601
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4492
4602
  a => handlers.listenerPort(a.name))
4493
4603
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),