rechrome 1.46.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.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}`);
@@ -2059,7 +2061,7 @@ export function withDefaultScheme(target: string): string {
2059
2061
  // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
2060
2062
  // missing value; accepts multiple occurrences (last one wins).
2061
2063
  /**
2062
- * `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
2064
+ * `rech [--profile X] [--isolate[=<name>]] pw <args>` forwards <args> to playwright-cli verbatim, so
2063
2065
  * `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
2064
2066
  * same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
2065
2067
  * Bun consumes the `--` right after the script path. The separator counts only when
@@ -2071,12 +2073,32 @@ export function rechSeparatorIndex(args: string[]): number {
2071
2073
  const a = args[i];
2072
2074
  if (a === "--" || a === "pw" || a === "playwright") return i;
2073
2075
  if (a === "--profile") { i++; continue; }
2074
- if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
2076
+ if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a)) continue;
2075
2077
  return -1;
2076
2078
  }
2077
2079
  return -1;
2078
2080
  }
2079
2081
 
2082
+ /** Prefix of a named isolated session (`--isolate=<name>` -> `-s=iso_<name>`); `_` keeps it out of the iso-<hex> reaper. */
2083
+ export const NAMED_ISO_PREFIX = "iso_";
2084
+
2085
+ /**
2086
+ * Turn rech's `--isolate` / `--isolate=<name>` into a session flag. Bare `--isolate` is a
2087
+ * throwaway `-s=iso-<random>` (reaped when idle); `--isolate=<name>` is the persistent
2088
+ * `-s=iso_<name>`, reused by later commands with the same name until `rech session close <name>`.
2089
+ */
2090
+ export function applyIsolateArg(args: string[]): string[] {
2091
+ const idx = args.findIndex(a => a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a));
2092
+ if (idx === -1) return args;
2093
+ const flag = args[idx];
2094
+ const rest = args.filter((_, i) => i !== idx);
2095
+ if (!flag.includes("=")) return [...rest, `-s=iso-${randomBytes(8).toString("hex")}`];
2096
+ const name = flag.slice(flag.indexOf("=") + 1);
2097
+ if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error(`--isolate=<name> takes 1-60 letters, digits, '-' or '_' (got "${name}")`);
2098
+ if (rest.some(a => a.startsWith("-s="))) throw new Error("Pass --isolate=<name> or -s=<session>, not both.");
2099
+ return [...rest, `-s=${NAMED_ISO_PREFIX}${name}`];
2100
+ }
2101
+
2080
2102
  /** rechrome's own version, from the package.json shipped next to this file. */
2081
2103
  export function rechromeVersion(): string {
2082
2104
  try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
@@ -2241,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2241
2263
  console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
2242
2264
  process.exit(0);
2243
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
+ }
2244
2279
  if (args[cmdIdx] === "fill-secret") {
2245
2280
  if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
2246
2281
  try {
@@ -2313,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2313
2348
  }
2314
2349
  process.stderr.write(stderr);
2315
2350
  }
2316
- 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
+ }));
2317
2357
 
2318
2358
  if (files?.length) {
2319
2359
  // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
@@ -3026,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
3026
3066
  }
3027
3067
  }
3028
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
+
3029
3087
  // Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
3030
3088
  // Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
3031
3089
  // --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
3032
- function findChromiumForTesting(): string | null {
3090
+ function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
3033
3091
  // Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
3034
3092
  // `playwright install` doesn't always write to the env path, so check both.
3035
3093
  const bases = [
@@ -3042,6 +3100,8 @@ function findChromiumForTesting(): string | null {
3042
3100
  let revs: string[];
3043
3101
  try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
3044
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}`));
3045
3105
  for (const rev of revs) {
3046
3106
  const root = join(base, rev);
3047
3107
  const candidates = process.platform === "darwin"
@@ -3489,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
3489
3549
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3490
3550
  }
3491
3551
 
3492
- 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> {
3493
3553
  const config = await requireListeners();
3494
3554
  let listener = config.listeners.find(l => l.name === name);
3495
3555
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3515,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3515
3575
  if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
3516
3576
  if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
3517
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
+ }
3518
3582
  if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
3519
3583
  await writeListeners(config);
3520
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`);
3521
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`);
3522
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`);
3523
3588
  if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3524
3589
  if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3525
3590
  const dirs = (listener.uploads || {}).hostDirs;
@@ -3694,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
3694
3759
  }
3695
3760
  }
3696
3761
 
3697
- 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 }[] };
3698
3763
 
3699
3764
  async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
3700
3765
  if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3701
3766
  // A bare rech1_ token means the default signal server.
3702
3767
  if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
3703
3768
  const share = shareLinkParts(url);
3704
- 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
+ }
3705
3776
  if (share && shared) return saveConnection(url, opts, shared);
3706
3777
  const parsed = parseUrl(url);
3707
3778
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
@@ -3762,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
3762
3833
  for (const p of shared.profiles) console.log(` ${p}`);
3763
3834
  console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
3764
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
+ }
3765
3840
  }
3766
3841
 
3767
3842
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -4309,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
4309
4384
  function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
4310
4385
  const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
4311
4386
  console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
4312
- 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`);
4313
4391
  const width = Math.max(...links.map(l => l.via.length));
4314
4392
  for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
4315
4393
  if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
@@ -4321,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
4321
4399
  console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
4322
4400
  }
4323
4401
 
4324
- 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 };
4325
4403
 
4326
4404
  /**
4327
4405
  * The transports named on the command line, in order: --via entries (comma-separated), then
@@ -4377,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
4377
4455
  const share = await updateShares(config => createShare(config, {
4378
4456
  label, profiles, via,
4379
4457
  expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
4380
- inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
4381
- 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,
4382
4460
  }));
4383
4461
  // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
4384
4462
  if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
@@ -4389,12 +4467,15 @@ async function shareList(): Promise<void> {
4389
4467
  const { shares } = await readShares();
4390
4468
  if (shares.length) {
4391
4469
  const now = Date.now();
4392
- const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
4470
+ const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
4393
4471
  for (const s of shares) {
4394
4472
  const pending = s.devices.filter(d => !d.approved).length;
4395
4473
  const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
4396
4474
  : inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
4397
- 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]);
4398
4479
  }
4399
4480
  printTable(rows);
4400
4481
  console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
@@ -4441,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
4441
4522
  const s = findShare(config, name);
4442
4523
  s.invite = newToken();
4443
4524
  s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
4444
- 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;
4445
4527
  return s;
4446
4528
  });
4447
4529
  console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
@@ -4530,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
4530
4612
  const saved = new URL(parts.origin);
4531
4613
  if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
4532
4614
  saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
4533
- 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 });
4534
4617
  }
4535
4618
 
4536
4619
  // ---- WebRTC client bridge -------------------------------------------------------------------
@@ -4585,22 +4668,40 @@ async function bridgeCommand(): Promise<void> {
4585
4668
 
4586
4669
  // ---- sessions (client view) -----------------------------------------------------------------
4587
4670
 
4671
+ type SessionRow = { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean };
4672
+
4673
+ async function fetchSessions(url: string): Promise<{ res: Response; body: { sessions?: SessionRow[]; stderr?: string } | null }> {
4674
+ const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4675
+ return { res, body: await res.json().catch(() => null) as { sessions?: SessionRow[]; stderr?: string } | null };
4676
+ }
4677
+
4678
+ /** `name` as given, unless only the named isolated session `iso_<name>` exists. */
4679
+ export function pickSessionName(name: string, known: string[]): string {
4680
+ if (name.startsWith(NAMED_ISO_PREFIX) || known.includes(name)) return name;
4681
+ return known.includes(NAMED_ISO_PREFIX + name) ? NAMED_ISO_PREFIX + name : name;
4682
+ }
4683
+
4684
+ async function resolveSessionName(url: string, name: string): Promise<string> {
4685
+ const known = await fetchSessions(url).then(({ body }) => (body?.sessions ?? []).map(s => s.session)).catch(() => [] as string[]);
4686
+ return pickSessionName(name, known);
4687
+ }
4688
+
4588
4689
  async function sessionCommand(action: string, name?: string): Promise<void> {
4589
4690
  const raw = process.env[ENV_KEY];
4590
4691
  if (!raw) throw new Error(notConnectedMessage());
4591
4692
  const url = await transportUrl(raw);
4592
4693
  if (action === "close") {
4593
4694
  if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
4594
- return run(url, ["close", `-s=${name}`]);
4695
+ // A named isolated session closes by its --isolate name too (`login` for iso_login).
4696
+ return run(url, ["close", `-s=${await resolveSessionName(url, name)}`]);
4595
4697
  }
4596
- const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4597
- const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
4698
+ const { res, body } = await fetchSessions(url);
4598
4699
  if (!res.ok || !body?.sessions) throw new Error(body?.stderr?.trim() || `The host does not list sessions (HTTP ${res.status}); it may run an older rech.`);
4599
4700
  if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
4600
4701
  const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
4601
4702
  for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
4602
4703
  printTable(rows);
4603
- console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
4704
+ console.log(`\nUse one: rech -s=<session> open https://example.com (an ${NAMED_ISO_PREFIX}<name> session: rech --isolate=<name> …) · close it: rech session close <session>`);
4604
4705
  }
4605
4706
 
4606
4707
  async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
@@ -4731,18 +4832,30 @@ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from s
4731
4832
  Usage: rech <command> [options] · rech <browser-command> [args]`;
4732
4833
 
4733
4834
  const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4734
- rech [--profile <p>] [--isolate] <browser-command> [args]
4835
+ rech [--profile <p>] [--isolate[=<name>]] <browser-command> [args]
4735
4836
  open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
4736
4837
  --profile <p> as another registered profile (email, name or folder); put it first
4737
- --isolate in a throwaway session, e.g. for a login flow
4838
+ --isolate in a throwaway session (a new one per command)
4839
+ --isolate=<n> in the named isolated session <n>: made on first use, reused by later
4840
+ commands (same tabs, same page) until \`rech session close <n>\`
4738
4841
  rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
4739
4842
  type a password / TOTP code without it reaching argv, logs or output
4740
4843
  rech read-secret <ref> --allow-domain <host> | <vault command>
4741
4844
  copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4742
4845
  rech read-cookie <name> <domain> --allow-domain <domain> | <command>
4743
4846
  copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
4744
- 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>
4745
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
4746
4859
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4747
4860
  rech --version rechrome's version
4748
4861
 
@@ -4808,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4808
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` })
4809
4922
  .option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
4810
4923
  .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4811
- .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" })
4812
4926
  .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
4813
4927
  .option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
4814
4928
  .option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
@@ -4821,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4821
4935
  const [action, target, ...rest] = profiles;
4822
4936
  const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
4823
4937
  const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
4824
- 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 };
4825
4939
  if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
4826
4940
  // Transport flags make a share (an invite link) for the named profiles.
4827
4941
  if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
@@ -4883,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4883
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" })
4884
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" })
4885
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" })
4886
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" })
4887
5002
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4888
- .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"),
4889
- 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 } : {}) }))
4890
5005
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4891
5006
  a => handlers.listenerPort(a.name))
4892
5007
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
@@ -4894,7 +5009,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4894
5009
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
4895
5010
  a => handlers.removeListener(a.name))
4896
5011
  .demandCommand(1).strict())
4897
- .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
5012
+ .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name> (also an --isolate=<name> session)", y => y
4898
5013
  .positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
4899
5014
  .positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
4900
5015
  a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
@@ -5041,10 +5156,13 @@ if (import.meta.main) {
5041
5156
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
5042
5157
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
5043
5158
  // OAuth drive can't leak an orphaned browser context.
5044
- const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
5045
- if (isolateIdx !== -1) {
5046
- args.splice(isolateIdx, 1);
5047
- args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
5159
+ // --isolate=<name>: a named isolated session (-s=iso_<name>), kept across commands until
5160
+ // `rech session close <name>`, for multi-step flows (email, password, TOTP).
5161
+ try { args = applyIsolateArg(args); }
5162
+ catch (err) {
5163
+ console.error(err instanceof Error ? err.message : String(err));
5164
+ envWatcher?.close();
5165
+ process.exit(1);
5048
5166
  }
5049
5167
  args = [...forwarded, ...args];
5050
5168
  await run(url, args, overrideEnv, { verbatim: separator !== -1 });