rechrome 1.47.0 → 1.48.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/rechrome.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
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, parseIceUrl, iceLabel, rtcListeners, shareRtcListeners, defaultRtcListener, DEFAULT_RTC, DEFAULT_RTC_NOTE, LEGACY_RTC, type IceServer, type Listener, type ListenerConfig, type RtcListener } from "./listeners.js";
4
+ import { recordHoldMs } from "./record.js";
4
5
 
5
6
  import { file } from "bun";
6
7
  import yargs from "yargs";
7
8
  import { readExtensionTokenFromProfile } from "./extension-token.js";
8
9
  import { redact } from "./redact.js";
9
- import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
10
+ import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, READ_HEADERS_USAGE, parseReadHeadersArgs, readHeadersWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
10
11
  import { createHash, randomBytes } from "crypto";
11
12
  import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
12
13
  import { hostname, homedir, networkInterfaces } from "os";
@@ -17,7 +18,7 @@ import { createRequire } from "node:module";
17
18
  import { spawn as cpSpawn } from "child_process";
18
19
  import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
19
20
  import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
20
- import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
21
+ import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, REUSABLE_DEFAULT_TTL, REUSABLE_MAX_DEVICES, readShares, updateShares, type Share } from "./shares.js";
21
22
  import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
22
23
  import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
23
24
  import { startSignalRelay } from "./signal-relay.js";
@@ -1961,7 +1962,8 @@ async function callServe(
1961
1962
  // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
1962
1963
  // `secret` (fill-secret only) rides in its own field so it is never part of the logged args.
1963
1964
  body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}), ...(secret ? { secret } : {}) }),
1964
- signal: AbortSignal.timeout(70_000),
1965
+ // A timed `record --seconds N` holds the request for N seconds before it answers.
1966
+ signal: AbortSignal.timeout(70_000 + recordHoldMs(args)),
1965
1967
  }).catch(async (e) => {
1966
1968
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
1967
1969
  console.error(`[rech] ${e.message}`);
@@ -2261,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2261
2263
  console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
2262
2264
  process.exit(0);
2263
2265
  }
2266
+ if (args[cmdIdx] === "read-headers") {
2267
+ // read-headers writes a JSON evidence file (no bodies, credential header values redacted), so it
2268
+ // goes through the normal file-download path below, not the secretOut pipe. Validate and
2269
+ // normalize the args here for an early error and help text, then fall through.
2270
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_HEADERS_USAGE); process.exit(0); }
2271
+ try {
2272
+ const hwire = readHeadersWireArgs(parseReadHeadersArgs(args.slice(cmdIdx)));
2273
+ args = [...args.filter(a => a.startsWith("-s=")), ...hwire];
2274
+ } catch (err) {
2275
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_HEADERS_USAGE}`);
2276
+ process.exit(2);
2277
+ }
2278
+ }
2264
2279
  if (args[cmdIdx] === "fill-secret") {
2265
2280
  if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
2266
2281
  try {
@@ -2333,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2333
2348
  }
2334
2349
  process.stderr.write(stderr);
2335
2350
  }
2336
- if (stdout) process.stdout.write(stdout);
2351
+ // Wait for the write: process.exit() below would otherwise cut a pipe off at its buffer
2352
+ // (128 KiB), e.g. a large run-code result read through `| jq`. A reader that stops early
2353
+ // (`| head`) closes the pipe: that is EPIPE, not an error.
2354
+ if (stdout) await new Promise<void>((done, fail) => process.stdout.write(stdout, e => {
2355
+ if (!e || (e as NodeJS.ErrnoException).code === "EPIPE") done(); else fail(e);
2356
+ }));
2337
2357
 
2338
2358
  if (files?.length) {
2339
2359
  // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
@@ -3046,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
3046
3066
  }
3047
3067
  }
3048
3068
 
3069
+ // The Chromium revision pinned by the playwright-core that the relay (playwright-cli) runs, read
3070
+ // from its browsers.json. Provisioning must seed with that same build: the relay later reopens the
3071
+ // profile with it, and a profile written by a newer Chromium (e.g. 156, whose localStorage moved to
3072
+ // SQLite) loses the seeded token, so the extension mints a new one and the bridge never connects.
3073
+ export function relayChromiumRevision(cli: string = resolvePlaywrightCli()): string | null {
3074
+ try {
3075
+ const entry = cli.split(" ").pop()!;
3076
+ let dir = dirname(createRequire(entry).resolve("playwright-core/lib/tools/cli-client/program"));
3077
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++, dir = dirname(dir)) {
3078
+ const browsers = join(dir, "browsers.json");
3079
+ if (!existsSync(browsers)) continue;
3080
+ const chromium = JSON.parse(readFileSync(browsers, "utf8")).browsers?.find((b: { name?: string }) => b.name === "chromium");
3081
+ return chromium?.revision ? String(chromium.revision) : null;
3082
+ }
3083
+ } catch {}
3084
+ return null;
3085
+ }
3086
+
3049
3087
  // Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
3050
3088
  // Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
3051
3089
  // --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
3052
- function findChromiumForTesting(): string | null {
3090
+ function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
3053
3091
  // Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
3054
3092
  // `playwright install` doesn't always write to the env path, so check both.
3055
3093
  const bases = [
@@ -3062,6 +3100,8 @@ function findChromiumForTesting(): string | null {
3062
3100
  let revs: string[];
3063
3101
  try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
3064
3102
  catch { continue; }
3103
+ // The relay's own build first; the newest installed one only if it is missing.
3104
+ if (preferredRevision) revs = revs.filter(r => r === `chromium-${preferredRevision}`).concat(revs.filter(r => r !== `chromium-${preferredRevision}`));
3065
3105
  for (const rev of revs) {
3066
3106
  const root = join(base, rev);
3067
3107
  const candidates = process.platform === "darwin"
@@ -3509,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
3509
3549
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3510
3550
  }
3511
3551
 
3512
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
3552
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; readHeaders?: boolean; keepDownloads?: boolean }): Promise<void> {
3513
3553
  const config = await requireListeners();
3514
3554
  let listener = config.listeners.find(l => l.name === name);
3515
3555
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3535,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3535
3575
  if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
3536
3576
  if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
3537
3577
  }
3578
+ if (opts.readHeaders !== undefined) {
3579
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-headers is always available there.`);
3580
+ if (opts.readHeaders) listener.readHeaders = true; else delete listener.readHeaders;
3581
+ }
3538
3582
  if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
3539
3583
  await writeListeners(config);
3540
3584
  if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
3541
3585
  if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
3542
3586
  if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
3587
+ if (opts.readHeaders !== undefined) console.log(opts.readHeaders ? `${name}: read-headers on. Anyone with this link can capture response status + headers for any site its profiles reach; credential header values (cookie, authorization, tokens) and URL query values are redacted. Each capture is logged without those values` : `${name}: read-headers off`);
3543
3588
  if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3544
3589
  if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3545
3590
  const dirs = (listener.uploads || {}).hostDirs;
@@ -3714,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
3714
3759
  }
3715
3760
  }
3716
3761
 
3717
- type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean };
3762
+ type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean; sessions?: { name: string; device: string }[] };
3718
3763
 
3719
3764
  async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
3720
3765
  if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3721
3766
  // A bare rech1_ token means the default signal server.
3722
3767
  if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
3723
3768
  const share = shareLinkParts(url);
3724
- if (share && !shared) return connectShare(url, share, opts);
3769
+ if (share && !shared) {
3770
+ // node-datachannel's native threads can keep the process alive after every connection is
3771
+ // closed, so the command leaves explicitly: done, it must not hang on.
3772
+ try { await connectShare(url, share, opts); }
3773
+ catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }
3774
+ process.exit(0);
3775
+ }
3725
3776
  if (share && shared) return saveConnection(url, opts, shared);
3726
3777
  const parsed = parseUrl(url);
3727
3778
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
@@ -3782,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
3782
3833
  for (const p of shared.profiles) console.log(` ${p}`);
3783
3834
  console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
3784
3835
  } else if (shared.profiles.length === 1) console.log(`Profile: ${shared.profiles[0]}. Try: rech open https://example.com`);
3836
+ if (shared.sessions?.length) {
3837
+ console.log(`\nOpen sessions on this share:`);
3838
+ for (const x of shared.sessions) console.log(` ${x.name} (device ${x.device})`);
3839
+ }
3785
3840
  }
3786
3841
 
3787
3842
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -4329,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
4329
4384
  function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
4330
4385
  const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
4331
4386
  console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
4332
- console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
4387
+ if (share.reusable) {
4388
+ const until = share.inviteExpiresAt ? `until ${fmtDate(share.inviteExpiresAt)}` : "with no expiry";
4389
+ console.log(`Reusable invite: up to ${share.maxDevices} devices may redeem it ${until}, each getting its own credential (secret: anyone with it can join; kill it with rech share rotate ${share.label}):\n`);
4390
+ } else console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
4333
4391
  const width = Math.max(...links.map(l => l.via.length));
4334
4392
  for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
4335
4393
  if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
@@ -4341,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
4341
4399
  console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
4342
4400
  }
4343
4401
 
4344
- export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
4402
+ export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean; reusable?: boolean };
4345
4403
 
4346
4404
  /**
4347
4405
  * The transports named on the command line, in order: --via entries (comma-separated), then
@@ -4397,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
4397
4455
  const share = await updateShares(config => createShare(config, {
4398
4456
  label, profiles, via,
4399
4457
  expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
4400
- inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
4401
- maxDevices: opts.devices ?? 1, approve: opts.approve,
4458
+ inviteTtlMs: parseDuration(opts.inviteTtl ?? (opts.reusable ? REUSABLE_DEFAULT_TTL : "24h")),
4459
+ maxDevices: opts.devices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1), approve: opts.approve, reusable: opts.reusable,
4402
4460
  }));
4403
4461
  // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
4404
4462
  if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
@@ -4409,12 +4467,15 @@ async function shareList(): Promise<void> {
4409
4467
  const { shares } = await readShares();
4410
4468
  if (shares.length) {
4411
4469
  const now = Date.now();
4412
- const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
4470
+ const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
4413
4471
  for (const s of shares) {
4414
4472
  const pending = s.devices.filter(d => !d.approved).length;
4415
4473
  const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
4416
4474
  : inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
4417
- rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), status]);
4475
+ const invite = s.reusable
4476
+ ? `reusable, ${s.redeemed ?? 0} redeemed, ${s.inviteExpiresAt && now >= s.inviteExpiresAt ? "expired" : `until ${s.inviteExpiresAt ? fmtDate(s.inviteExpiresAt) : "never"}`}`
4477
+ : "single-use";
4478
+ rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), invite, status]);
4418
4479
  }
4419
4480
  printTable(rows);
4420
4481
  console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
@@ -4461,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
4461
4522
  const s = findShare(config, name);
4462
4523
  s.invite = newToken();
4463
4524
  s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
4464
- if (s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
4525
+ if (!s.reusable && s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
4526
+ if (s.reusable) s.redeemed = 0;
4465
4527
  return s;
4466
4528
  });
4467
4529
  console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
@@ -4550,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
4550
4612
  const saved = new URL(parts.origin);
4551
4613
  if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
4552
4614
  saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
4553
- await connect(saved.toString(), opts, { share, device, profiles, webrtc });
4615
+ const sessions = Array.isArray(ping?.sessions) ? ping.sessions as { name: string; device: string }[] : [];
4616
+ await connect(saved.toString(), opts, { share, device, profiles, webrtc, sessions });
4554
4617
  }
4555
4618
 
4556
4619
  // ---- WebRTC client bridge -------------------------------------------------------------------
@@ -4781,8 +4844,18 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4781
4844
  copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4782
4845
  rech read-cookie <name> <domain> --allow-domain <domain> | <command>
4783
4846
  copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
4784
- rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
4847
+ rech read-storage <local|session> <key> [json-path] --allow-domain <host> [--goto <url>] | <command>
4785
4848
  copy a localStorage/sessionStorage value into a pipe, never shown or logged
4849
+ rech read-headers --allow-domain <host> [--seconds N] [--goto <url>]
4850
+ capture response status + headers (no bodies) to a JSON evidence file;
4851
+ credential header values and URL query values are redacted
4852
+ rech record start [--fps <n>] [--quality <1-100>] [--format mp4|webm|mjpeg] [--max-seconds <n>]
4853
+ rech record stop [--filename <name>]
4854
+ record the active tab, streamed into ffmpeg on the host, until stop (or
4855
+ --max-seconds, default 600); stop hands you the video. No click, no new
4856
+ permission; mjpeg if the host has no ffmpeg
4857
+ rech record [--seconds <n>] [...]
4858
+ the same in one call: record n seconds (max 60) and return the file
4786
4859
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4787
4860
  rech --version rechrome's version
4788
4861
 
@@ -4848,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4848
4921
  .option("webrtc", { type: "string", describe: `Share over WebRTC: bare = ${DEFAULT_RTC} (${DEFAULT_SIGNAL}, best effort); --webrtc=<signal-url> = that server's listener (made if needed); repeatable` })
4849
4922
  .option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
4850
4923
  .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4851
- .option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
4924
+ .option("invite-ttl", { type: "string", requiresArg: true, alias: "ttl", describe: "With new: the invite stops working after this (default 24h; 7d with --reusable)" })
4925
+ .option("reusable", { type: "boolean", describe: "With new: many devices may redeem the same invite link (each gets its own credential) until --ttl or rech share rotate" })
4852
4926
  .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
4853
4927
  .option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
4854
4928
  .option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
@@ -4861,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4861
4935
  const [action, target, ...rest] = profiles;
4862
4936
  const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
4863
4937
  const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
4864
- const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve };
4938
+ const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve, reusable: a.reusable };
4865
4939
  if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
4866
4940
  // Transport flags make a share (an invite link) for the named profiles.
4867
4941
  if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
@@ -4923,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4923
4997
  .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" })
4924
4998
  .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
4925
4999
  .option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
5000
+ .option("read-headers", { type: "boolean", describe: "Off by default. Let this listener's links capture response status + headers (no bodies) for an allowed host (rech read-headers); credential header values and URL query values are redacted. --no-read-headers turns it off" })
4926
5001
  .option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
4927
5002
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4928
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
4929
- 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 } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
5003
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.readHeaders !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies/--read-headers, or --keep-downloads"),
5004
+ 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 } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.readHeaders !== undefined ? { readHeaders: a.readHeaders } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
4930
5005
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4931
5006
  a => handlers.listenerPort(a.name))
4932
5007
  .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,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
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, parseIceUrl, iceLabel, rtcListeners, shareRtcListeners, defaultRtcListener, DEFAULT_RTC, DEFAULT_RTC_NOTE, LEGACY_RTC, type IceServer, type Listener, type ListenerConfig, type RtcListener } from "./listeners.ts";
4
+ import { recordHoldMs } from "./record.ts";
4
5
 
5
6
  import { file } from "bun";
6
7
  import yargs from "yargs";
7
8
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
8
9
  import { redact } from "./redact.ts";
9
- import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
10
+ import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, READ_HEADERS_USAGE, parseReadHeadersArgs, readHeadersWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
10
11
  import { createHash, randomBytes } from "crypto";
11
12
  import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
12
13
  import { hostname, homedir, networkInterfaces } from "os";
@@ -17,7 +18,7 @@ import { createRequire } from "node:module";
17
18
  import { spawn as cpSpawn } from "child_process";
18
19
  import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
19
20
  import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.ts";
20
- import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.ts";
21
+ import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, REUSABLE_DEFAULT_TTL, REUSABLE_MAX_DEVICES, readShares, updateShares, type Share } from "./shares.ts";
21
22
  import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.ts";
22
23
  import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.ts";
23
24
  import { startSignalRelay } from "./signal-relay.ts";
@@ -1961,7 +1962,8 @@ async function callServe(
1961
1962
  // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
1962
1963
  // `secret` (fill-secret only) rides in its own field so it is never part of the logged args.
1963
1964
  body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}), ...(secret ? { secret } : {}) }),
1964
- signal: AbortSignal.timeout(70_000),
1965
+ // A timed `record --seconds N` holds the request for N seconds before it answers.
1966
+ signal: AbortSignal.timeout(70_000 + recordHoldMs(args)),
1965
1967
  }).catch(async (e) => {
1966
1968
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
1967
1969
  console.error(`[rech] ${e.message}`);
@@ -2261,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2261
2263
  console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
2262
2264
  process.exit(0);
2263
2265
  }
2266
+ if (args[cmdIdx] === "read-headers") {
2267
+ // read-headers writes a JSON evidence file (no bodies, credential header values redacted), so it
2268
+ // goes through the normal file-download path below, not the secretOut pipe. Validate and
2269
+ // normalize the args here for an early error and help text, then fall through.
2270
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_HEADERS_USAGE); process.exit(0); }
2271
+ try {
2272
+ const hwire = readHeadersWireArgs(parseReadHeadersArgs(args.slice(cmdIdx)));
2273
+ args = [...args.filter(a => a.startsWith("-s=")), ...hwire];
2274
+ } catch (err) {
2275
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_HEADERS_USAGE}`);
2276
+ process.exit(2);
2277
+ }
2278
+ }
2264
2279
  if (args[cmdIdx] === "fill-secret") {
2265
2280
  if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
2266
2281
  try {
@@ -2333,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2333
2348
  }
2334
2349
  process.stderr.write(stderr);
2335
2350
  }
2336
- if (stdout) process.stdout.write(stdout);
2351
+ // Wait for the write: process.exit() below would otherwise cut a pipe off at its buffer
2352
+ // (128 KiB), e.g. a large run-code result read through `| jq`. A reader that stops early
2353
+ // (`| head`) closes the pipe: that is EPIPE, not an error.
2354
+ if (stdout) await new Promise<void>((done, fail) => process.stdout.write(stdout, e => {
2355
+ if (!e || (e as NodeJS.ErrnoException).code === "EPIPE") done(); else fail(e);
2356
+ }));
2337
2357
 
2338
2358
  if (files?.length) {
2339
2359
  // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
@@ -3046,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
3046
3066
  }
3047
3067
  }
3048
3068
 
3069
+ // The Chromium revision pinned by the playwright-core that the relay (playwright-cli) runs, read
3070
+ // from its browsers.json. Provisioning must seed with that same build: the relay later reopens the
3071
+ // profile with it, and a profile written by a newer Chromium (e.g. 156, whose localStorage moved to
3072
+ // SQLite) loses the seeded token, so the extension mints a new one and the bridge never connects.
3073
+ export function relayChromiumRevision(cli: string = resolvePlaywrightCli()): string | null {
3074
+ try {
3075
+ const entry = cli.split(" ").pop()!;
3076
+ let dir = dirname(createRequire(entry).resolve("playwright-core/lib/tools/cli-client/program"));
3077
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++, dir = dirname(dir)) {
3078
+ const browsers = join(dir, "browsers.json");
3079
+ if (!existsSync(browsers)) continue;
3080
+ const chromium = JSON.parse(readFileSync(browsers, "utf8")).browsers?.find((b: { name?: string }) => b.name === "chromium");
3081
+ return chromium?.revision ? String(chromium.revision) : null;
3082
+ }
3083
+ } catch {}
3084
+ return null;
3085
+ }
3086
+
3049
3087
  // Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
3050
3088
  // Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
3051
3089
  // --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
3052
- function findChromiumForTesting(): string | null {
3090
+ function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
3053
3091
  // Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
3054
3092
  // `playwright install` doesn't always write to the env path, so check both.
3055
3093
  const bases = [
@@ -3062,6 +3100,8 @@ function findChromiumForTesting(): string | null {
3062
3100
  let revs: string[];
3063
3101
  try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
3064
3102
  catch { continue; }
3103
+ // The relay's own build first; the newest installed one only if it is missing.
3104
+ if (preferredRevision) revs = revs.filter(r => r === `chromium-${preferredRevision}`).concat(revs.filter(r => r !== `chromium-${preferredRevision}`));
3065
3105
  for (const rev of revs) {
3066
3106
  const root = join(base, rev);
3067
3107
  const candidates = process.platform === "darwin"
@@ -3509,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
3509
3549
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3510
3550
  }
3511
3551
 
3512
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
3552
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; readHeaders?: boolean; keepDownloads?: boolean }): Promise<void> {
3513
3553
  const config = await requireListeners();
3514
3554
  let listener = config.listeners.find(l => l.name === name);
3515
3555
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3535,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3535
3575
  if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
3536
3576
  if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
3537
3577
  }
3578
+ if (opts.readHeaders !== undefined) {
3579
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-headers is always available there.`);
3580
+ if (opts.readHeaders) listener.readHeaders = true; else delete listener.readHeaders;
3581
+ }
3538
3582
  if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
3539
3583
  await writeListeners(config);
3540
3584
  if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
3541
3585
  if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
3542
3586
  if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
3587
+ if (opts.readHeaders !== undefined) console.log(opts.readHeaders ? `${name}: read-headers on. Anyone with this link can capture response status + headers for any site its profiles reach; credential header values (cookie, authorization, tokens) and URL query values are redacted. Each capture is logged without those values` : `${name}: read-headers off`);
3543
3588
  if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3544
3589
  if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3545
3590
  const dirs = (listener.uploads || {}).hostDirs;
@@ -3714,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
3714
3759
  }
3715
3760
  }
3716
3761
 
3717
- type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean };
3762
+ type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean; sessions?: { name: string; device: string }[] };
3718
3763
 
3719
3764
  async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
3720
3765
  if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3721
3766
  // A bare rech1_ token means the default signal server.
3722
3767
  if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
3723
3768
  const share = shareLinkParts(url);
3724
- if (share && !shared) return connectShare(url, share, opts);
3769
+ if (share && !shared) {
3770
+ // node-datachannel's native threads can keep the process alive after every connection is
3771
+ // closed, so the command leaves explicitly: done, it must not hang on.
3772
+ try { await connectShare(url, share, opts); }
3773
+ catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }
3774
+ process.exit(0);
3775
+ }
3725
3776
  if (share && shared) return saveConnection(url, opts, shared);
3726
3777
  const parsed = parseUrl(url);
3727
3778
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
@@ -3782,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
3782
3833
  for (const p of shared.profiles) console.log(` ${p}`);
3783
3834
  console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
3784
3835
  } else if (shared.profiles.length === 1) console.log(`Profile: ${shared.profiles[0]}. Try: rech open https://example.com`);
3836
+ if (shared.sessions?.length) {
3837
+ console.log(`\nOpen sessions on this share:`);
3838
+ for (const x of shared.sessions) console.log(` ${x.name} (device ${x.device})`);
3839
+ }
3785
3840
  }
3786
3841
 
3787
3842
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -4329,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
4329
4384
  function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
4330
4385
  const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
4331
4386
  console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
4332
- console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
4387
+ if (share.reusable) {
4388
+ const until = share.inviteExpiresAt ? `until ${fmtDate(share.inviteExpiresAt)}` : "with no expiry";
4389
+ console.log(`Reusable invite: up to ${share.maxDevices} devices may redeem it ${until}, each getting its own credential (secret: anyone with it can join; kill it with rech share rotate ${share.label}):\n`);
4390
+ } else console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
4333
4391
  const width = Math.max(...links.map(l => l.via.length));
4334
4392
  for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
4335
4393
  if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
@@ -4341,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
4341
4399
  console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
4342
4400
  }
4343
4401
 
4344
- export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
4402
+ export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean; reusable?: boolean };
4345
4403
 
4346
4404
  /**
4347
4405
  * The transports named on the command line, in order: --via entries (comma-separated), then
@@ -4397,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
4397
4455
  const share = await updateShares(config => createShare(config, {
4398
4456
  label, profiles, via,
4399
4457
  expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
4400
- inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
4401
- maxDevices: opts.devices ?? 1, approve: opts.approve,
4458
+ inviteTtlMs: parseDuration(opts.inviteTtl ?? (opts.reusable ? REUSABLE_DEFAULT_TTL : "24h")),
4459
+ maxDevices: opts.devices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1), approve: opts.approve, reusable: opts.reusable,
4402
4460
  }));
4403
4461
  // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
4404
4462
  if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
@@ -4409,12 +4467,15 @@ async function shareList(): Promise<void> {
4409
4467
  const { shares } = await readShares();
4410
4468
  if (shares.length) {
4411
4469
  const now = Date.now();
4412
- const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
4470
+ const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
4413
4471
  for (const s of shares) {
4414
4472
  const pending = s.devices.filter(d => !d.approved).length;
4415
4473
  const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
4416
4474
  : inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
4417
- rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), status]);
4475
+ const invite = s.reusable
4476
+ ? `reusable, ${s.redeemed ?? 0} redeemed, ${s.inviteExpiresAt && now >= s.inviteExpiresAt ? "expired" : `until ${s.inviteExpiresAt ? fmtDate(s.inviteExpiresAt) : "never"}`}`
4477
+ : "single-use";
4478
+ rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), invite, status]);
4418
4479
  }
4419
4480
  printTable(rows);
4420
4481
  console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
@@ -4461,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
4461
4522
  const s = findShare(config, name);
4462
4523
  s.invite = newToken();
4463
4524
  s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
4464
- if (s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
4525
+ if (!s.reusable && s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
4526
+ if (s.reusable) s.redeemed = 0;
4465
4527
  return s;
4466
4528
  });
4467
4529
  console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
@@ -4550,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
4550
4612
  const saved = new URL(parts.origin);
4551
4613
  if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
4552
4614
  saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
4553
- await connect(saved.toString(), opts, { share, device, profiles, webrtc });
4615
+ const sessions = Array.isArray(ping?.sessions) ? ping.sessions as { name: string; device: string }[] : [];
4616
+ await connect(saved.toString(), opts, { share, device, profiles, webrtc, sessions });
4554
4617
  }
4555
4618
 
4556
4619
  // ---- WebRTC client bridge -------------------------------------------------------------------
@@ -4781,8 +4844,18 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4781
4844
  copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4782
4845
  rech read-cookie <name> <domain> --allow-domain <domain> | <command>
4783
4846
  copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
4784
- rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
4847
+ rech read-storage <local|session> <key> [json-path] --allow-domain <host> [--goto <url>] | <command>
4785
4848
  copy a localStorage/sessionStorage value into a pipe, never shown or logged
4849
+ rech read-headers --allow-domain <host> [--seconds N] [--goto <url>]
4850
+ capture response status + headers (no bodies) to a JSON evidence file;
4851
+ credential header values and URL query values are redacted
4852
+ rech record start [--fps <n>] [--quality <1-100>] [--format mp4|webm|mjpeg] [--max-seconds <n>]
4853
+ rech record stop [--filename <name>]
4854
+ record the active tab, streamed into ffmpeg on the host, until stop (or
4855
+ --max-seconds, default 600); stop hands you the video. No click, no new
4856
+ permission; mjpeg if the host has no ffmpeg
4857
+ rech record [--seconds <n>] [...]
4858
+ the same in one call: record n seconds (max 60) and return the file
4786
4859
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4787
4860
  rech --version rechrome's version
4788
4861
 
@@ -4848,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4848
4921
  .option("webrtc", { type: "string", describe: `Share over WebRTC: bare = ${DEFAULT_RTC} (${DEFAULT_SIGNAL}, best effort); --webrtc=<signal-url> = that server's listener (made if needed); repeatable` })
4849
4922
  .option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
4850
4923
  .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4851
- .option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
4924
+ .option("invite-ttl", { type: "string", requiresArg: true, alias: "ttl", describe: "With new: the invite stops working after this (default 24h; 7d with --reusable)" })
4925
+ .option("reusable", { type: "boolean", describe: "With new: many devices may redeem the same invite link (each gets its own credential) until --ttl or rech share rotate" })
4852
4926
  .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
4853
4927
  .option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
4854
4928
  .option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
@@ -4861,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4861
4935
  const [action, target, ...rest] = profiles;
4862
4936
  const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
4863
4937
  const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
4864
- const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve };
4938
+ const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve, reusable: a.reusable };
4865
4939
  if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
4866
4940
  // Transport flags make a share (an invite link) for the named profiles.
4867
4941
  if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
@@ -4923,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4923
4997
  .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" })
4924
4998
  .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
4925
4999
  .option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
5000
+ .option("read-headers", { type: "boolean", describe: "Off by default. Let this listener's links capture response status + headers (no bodies) for an allowed host (rech read-headers); credential header values and URL query values are redacted. --no-read-headers turns it off" })
4926
5001
  .option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
4927
5002
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4928
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
4929
- 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 } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
5003
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.readHeaders !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies/--read-headers, or --keep-downloads"),
5004
+ 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 } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.readHeaders !== undefined ? { readHeaders: a.readHeaders } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
4930
5005
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4931
5006
  a => handlers.listenerPort(a.name))
4932
5007
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),