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/fill-secret.ts CHANGED
@@ -16,6 +16,7 @@ import { connect } from "net";
16
16
  import { readdirSync, readFileSync } from "fs";
17
17
  import { homedir } from "os";
18
18
  import { join } from "path";
19
+ import { redact } from "./redact.ts";
19
20
 
20
21
  export const SECRET_MASK = "***";
21
22
  export const SECRET_MASK_TTL_MS = 15 * 60_000;
@@ -321,14 +322,14 @@ export function socketRequest(path: string, method: string, params: unknown, tim
321
322
  }
322
323
 
323
324
  /** Run a run-code function on the session's live browser over its cliDaemon socket (no child argv). */
324
- export async function runCodeOnSession(opts: { socketRoot: string; session: string; cwd: string; code: string; json?: boolean }): Promise<{ isError: boolean; text: string }> {
325
+ export async function runCodeOnSession(opts: { socketRoot: string; session: string; cwd: string; code: string; json?: boolean; timeoutMs?: number }): Promise<{ isError: boolean; text: string }> {
325
326
  const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
326
327
  if (!candidates.length) throw new Error("no browser is open in this session; run `rech open <url>` first");
327
328
  const errors: string[] = [];
328
329
  for (const path of candidates) {
329
330
  let reply: { result?: any; error?: string };
330
331
  try {
331
- reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...(opts.json ? { json: true } : {}) });
332
+ reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...(opts.json ? { json: true } : {}) }, opts.timeoutMs);
332
333
  } catch (e) {
333
334
  errors.push(e instanceof Error ? e.message : String(e)); // stale socket: try the next one
334
335
  continue;
@@ -340,6 +341,20 @@ export async function runCodeOnSession(opts: { socketRoot: string; session: stri
340
341
  throw new Error(`could not reach this session's browser (${errors.join("; ")}); run \`rech open <url>\` first`);
341
342
  }
342
343
 
344
+ /** Run a plain CLI command (e.g. tab-list, tab-select) on the session's browser over its socket. */
345
+ export async function runCliOnSession(opts: { socketRoot: string; session: string; cwd: string }, cliArgs: string[]): Promise<{ isError: boolean; text: string }> {
346
+ const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
347
+ for (const path of candidates) {
348
+ let reply: { result?: any; error?: string };
349
+ try { reply = await socketRequest(path, "run", { args: { _: cliArgs }, cwd: opts.cwd }); }
350
+ catch { continue; }
351
+ if (reply.error) throw new Error(reply.error);
352
+ const r = reply.result;
353
+ return { isError: !!r?.isError, text: typeof r === "string" ? r : typeof r?.text === "string" ? r.text : "" };
354
+ }
355
+ throw new Error("no browser is open in this session");
356
+ }
357
+
343
358
  /**
344
359
  * Fill on the session's live browser. Returns the cliDaemon's reply (NOT yet masked — the
345
360
  * caller masks it) or throws an error whose message never contains the value.
@@ -450,30 +465,44 @@ export async function readSecretOnSession(opts: { socketRoot: string; session: s
450
465
  // via page-level evaluate, so it works over the extension bridge. localStorage is per-origin, so
451
466
  // the session's page must be on the allowed host.
452
467
 
453
- export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
468
+ export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> [--goto <url>] | <command>
454
469
  Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
455
470
  program that stores it (never a terminal or a file). Never logged; masked in later outputs.
456
471
  <local|session> localStorage or sessionStorage
457
472
  <key> the storage key, e.g. localConfig_v2
458
473
  [json-path] optional: a path into the JSON value, e.g. teams.T0123.token or teams[T0123].token
459
- --allow-domain GLOB required: the page's host must match, e.g. app.slack.com`;
474
+ --allow-domain GLOB required: the page's host must match, e.g. app.slack.com
475
+ --goto URL first navigate the attached tab to URL (host must match --allow-domain),
476
+ so the value is read from it, e.g. --goto https://app.slack.com/client/T0`;
477
+
478
+ /** --goto must be http(s) on an --allow-domain host (checked by the client and again by serve). */
479
+ export function checkGotoUrl(gotoUrl: string, allowDomains: string[]): void {
480
+ let u: URL;
481
+ try { u = new URL(gotoUrl); } catch { throw new Error("--goto must be an http(s) URL"); }
482
+ if (!/^https?:$/.test(u.protocol)) throw new Error("--goto must be an http(s) URL");
483
+ if (!allowDomains.some(d => domainGlobToRegExp(d).test(u.hostname.toLowerCase()))) throw new Error("--goto URL host must match --allow-domain");
484
+ }
460
485
 
461
486
  /** Split "teams[T0123].token" / "teams.T0123.token" into ["teams","T0123","token"]. */
462
487
  export function parseStoragePath(path: string): string[] {
463
488
  return path.match(/[^.[\]]+/g) ?? [];
464
489
  }
465
490
 
466
- export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
491
+ export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string } {
467
492
  const [cmd, ...rest] = args;
468
493
  if (cmd !== "read-storage") throw new Error("Expected read-storage");
469
494
  const pos: string[] = [];
470
495
  const allowDomains: string[] = [];
496
+ let gotoUrl: string | undefined;
471
497
  for (let i = 0; i < rest.length; i++) {
472
498
  const a = rest[i]!;
473
499
  if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
474
500
  const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
475
501
  if (!v) throw new Error("--allow-domain got an empty host");
476
502
  for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
503
+ } else if (a === "--goto" || a.startsWith("--goto=")) {
504
+ gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
505
+ if (!gotoUrl) throw new Error("--goto got an empty URL");
477
506
  } else if (a.startsWith("-")) throw new Error(`read-storage: unknown option ${a}`);
478
507
  else pos.push(a);
479
508
  }
@@ -482,25 +511,29 @@ export function parseReadStorageArgs(args: string[]): { area: "local" | "session
482
511
  if (area !== "local" && area !== "session") throw new Error("read-storage: first argument must be 'local' or 'session'");
483
512
  if (!key) throw new Error("read-storage: a storage key is required");
484
513
  if (!allowDomains.length) throw new Error("read-storage needs --allow-domain: name the site the value must come from");
485
- return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains };
514
+ if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
515
+ return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains, gotoUrl };
486
516
  }
487
517
 
488
- export function readStorageWireArgs(req: { area: string; key: string; path: string[]; allowDomains: string[] }): string[] {
489
- return ["read-storage", req.area, req.key, ...(req.path.length ? [req.path.join(".")] : []), ...req.allowDomains.map(d => `--allow-domain=${d}`)];
518
+ export function readStorageWireArgs(req: { area: string; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string }): string[] {
519
+ return ["read-storage", req.area, req.key, ...(req.path.length ? [req.path.join(".")] : []), ...req.allowDomains.map(d => `--allow-domain=${d}`), ...(req.gotoUrl ? [`--goto=${req.gotoUrl}`] : [])];
490
520
  }
491
521
 
492
- export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
522
+ export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string } {
493
523
  const [cmd, area, key, ...rest] = args;
494
524
  if (cmd !== "read-storage" || (area !== "local" && area !== "session") || !key || key.startsWith("-")) throw new Error("read-storage needs <local|session> <key>");
495
525
  const allowDomains: string[] = [];
496
526
  let path: string[] = [];
527
+ let gotoUrl: string | undefined;
497
528
  for (const a of rest) {
498
529
  if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
530
+ else if (a.startsWith("--goto=")) { gotoUrl = a.slice("--goto=".length); }
499
531
  else if (!a.startsWith("-") && !path.length) path = parseStoragePath(a);
500
532
  else throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
501
533
  }
502
534
  if (!allowDomains.length) throw new Error("read-storage needs --allow-domain");
503
- return { area, key, path, allowDomains };
535
+ if (gotoUrl !== undefined) checkGotoUrl(gotoUrl, allowDomains);
536
+ return { area, key, path, allowDomains, gotoUrl };
504
537
  }
505
538
 
506
539
  /** run-code reading storage[key], optionally walking a JSON path; returns {host,value} JSON. */
@@ -508,25 +541,34 @@ export function buildReadStorageCode(area: "local" | "session", key: string, pat
508
541
  const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
509
542
  const store = area === "local" ? "localStorage" : "sessionStorage";
510
543
  return `async page => {
511
- // One evaluate: the host checked is the origin read, with no navigation in between.
512
- const out = await page.evaluate(({ key, path }) => {
513
- const host = String(location.hostname).toLowerCase();
544
+ const allowed = ${JSON.stringify(patterns)};
545
+ const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
546
+ const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
547
+ // The caller has foregrounded the --allow-domain tab; read from whichever of this tab's frames
548
+ // is on the allowed host (the top frame can be a blank wrapper over the bridge).
549
+ const frame = page.frames().find(f => ok(hostOf(f.url())));
550
+ const host = frame ? hostOf(frame.url()) : "";
551
+ if (!frame)
552
+ throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (host " + JSON.stringify(hostOf(page.url())) + ")");
553
+ // Re-check the host inside the same evaluate that reads, so a navigation between the frame
554
+ // pick and the read can't hand over another origin's storage.
555
+ const out = await frame.evaluate(({ key, path, allowed }) => {
556
+ const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(document.URL || ""));
557
+ const h = m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : "";
558
+ if (!h || !allowed.some(p => new RegExp(p, "i").test(h))) return { moved: true };
514
559
  const raw = window.${store}.getItem(key);
515
- if (raw === null) return { host, missing: true };
516
- if (!path.length) return { host, value: raw };
560
+ if (raw === null) return { missing: true };
561
+ if (!path.length) return { value: raw };
517
562
  let v;
518
- try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
563
+ try { v = JSON.parse(raw); } catch { return { notjson: true }; }
519
564
  for (const seg of path) {
520
- if (v == null || typeof v !== "object") return { host, nopath: true };
565
+ if (v == null || typeof v !== "object") return { nopath: true };
521
566
  v = v[seg];
522
567
  }
523
- if (v === undefined) return { host, nopath: true };
524
- return { host, value: typeof v === "string" ? v : JSON.stringify(v) };
525
- }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
526
- const host = out.host;
527
- const allowed = ${JSON.stringify(patterns)};
528
- if (!allowed.some(p => new RegExp(p, "i").test(host)))
529
- throw new Error("read-storage refused: the page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
568
+ if (v === undefined) return { nopath: true };
569
+ return { value: typeof v === "string" ? v : JSON.stringify(v) };
570
+ }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)}, allowed });
571
+ if (out.moved) throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (it navigated away)");
530
572
  if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
531
573
  if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
532
574
  if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
@@ -534,15 +576,33 @@ export function buildReadStorageCode(area: "local" | "session", key: string, pat
534
576
  }`;
535
577
  }
536
578
 
537
- export async function readStorageOnSession(opts: { socketRoot: string; session: string; cwd: string; area: "local" | "session"; key: string; path: string[]; allowDomains: string[] }): Promise<{ host: string; value: string }> {
538
- const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
539
- if (isError) throw new Error(text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-storage: could not read the value"));
540
- let raw: unknown;
541
- try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
542
- let o: any = raw;
543
- for (let i = 0; i < 2 && typeof o === "string"; i++) { try { o = JSON.parse(o); } catch { o = undefined; } }
544
- if (!o || typeof o.host !== "string" || typeof o.value !== "string") throw new Error("read-storage: the page returned no value");
545
- return o;
579
+ export async function readStorageOnSession(opts: { socketRoot: string; session: string; cwd: string; area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string }): Promise<{ host: string; value: string }> {
580
+ // Over the extension bridge Playwright reaches only the attached tab, and localStorage is
581
+ // per-origin. With --goto, navigate that tab to the allowed URL in THIS same invocation (so the
582
+ // attachment can't change between navigate and read), then read it.
583
+ if (opts.gotoUrl) {
584
+ await runCliOnSession(opts, ["goto", opts.gotoUrl]).catch(() => {});
585
+ await new Promise(r => setTimeout(r, 2500));
586
+ }
587
+ let last = "";
588
+ for (let attempt = 0; attempt < 3; attempt++) {
589
+ const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
590
+ if (!isError) {
591
+ let raw: unknown;
592
+ try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
593
+ let o: any = raw;
594
+ for (let i = 0; i < 2 && typeof o === "string"; i++) { try { o = JSON.parse(o); } catch { o = undefined; } }
595
+ if (o && typeof o.host === "string" && typeof o.value === "string") return o;
596
+ throw new Error("read-storage: the page returned no value");
597
+ }
598
+ last = text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-storage: could not read the value");
599
+ if (!/foreground tab is not on/.test(last)) break;
600
+ await new Promise(r => setTimeout(r, 1000));
601
+ }
602
+ // A clearer hint for the common case: the allowed site isn't the attached tab.
603
+ if (/foreground tab is not on/.test(last))
604
+ last += ". Put the site on the active tab first: rech goto <url on that host>, then rerun read-storage.";
605
+ throw new Error(last || "read-storage: could not read the value");
546
606
  }
547
607
 
548
608
  // ---------- read-cookie: a cookie value (incl. HttpOnly) to a pipe ----------
@@ -637,6 +697,206 @@ export async function readCookieOnSession(opts: { socketRoot: string; session: s
637
697
  return out;
638
698
  }
639
699
 
700
+ // ---------- read-headers: response status + headers (no bodies) to an evidence file ----------
701
+ //
702
+ // Captures the status code and response headers of every request on an --allow-domain host over a
703
+ // capture window, with an optional --goto that navigates the attached tab first. Headers are not
704
+ // secrets in general, but some carry credentials, so their VALUES are redacted (the name is kept), as
705
+ // are URL query values, fragments and userinfo; the file then goes through the secret scanner.
706
+ // Unlike read-secret/read-storage/read-cookie this writes a JSON evidence FILE (an array of
707
+ // {url, status, headers}) to the run's output dir and returns its path; nothing is piped. On a
708
+ // profile-scoped listener it is refused unless the host turned it on (listener.readHeaders).
709
+
710
+ /** Header names whose VALUES carry credentials: keep the name, replace the value. */
711
+ export const REDACTED_HEADERS = [
712
+ "set-cookie", "cookie", "authorization", "proxy-authorization", "www-authenticate", "proxy-authenticate",
713
+ "x-api-key", "x-csrf-token", "x-xsrf-token",
714
+ ];
715
+ /** …and any header named like one: `*-token`, `*-secret`, `*-api-key` (also with `_`). */
716
+ export const REDACTED_HEADER_PATTERN = "[-_](?:token|secret|api[-_]?key)$";
717
+ /** Headers whose value is a URL: its query values, fragment and userinfo are redacted. */
718
+ export const URL_HEADERS = ["location", "content-location"];
719
+ export const HEADER_REDACTION = "<redacted>";
720
+ export const READ_HEADERS_DEFAULT_SECONDS = 5;
721
+ // The whole capture (a --goto included) runs inside one /run, and the client gives up at 70 s.
722
+ export const READ_HEADERS_MAX_SECONDS = 50;
723
+
724
+ export const READ_HEADERS_USAGE = `Usage: rech read-headers --allow-domain <host> [--allow-domain <host>]... [--seconds N] [--goto <url>]
725
+ Captures the status code and response headers (NO bodies) of every request on an --allow-domain
726
+ host over a capture window, and writes them as a JSON evidence file in the output dir (an array of
727
+ {url, status, headers}). Credential header values (cookie, set-cookie, authorization,
728
+ *-authenticate, x-api-key, *-token, *-secret, ...) are redacted, as are URL query values,
729
+ fragments and userinfo; then the file goes through the secret scanner.
730
+ --allow-domain GLOB required: capture only responses whose host matches, e.g. app.example.com
731
+ --seconds N capture window in seconds (default ${READ_HEADERS_DEFAULT_SECONDS}, max ${READ_HEADERS_MAX_SECONDS})
732
+ --goto URL first navigate the attached tab to URL (host must match --allow-domain),
733
+ then capture, so a page load's requests are captured (within the window)`;
734
+
735
+ /** The host of a URL string, lowercased, without userinfo or port ("" when it has none). */
736
+ export function hostFromUrl(u: string): string {
737
+ const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(String(u || ""));
738
+ return m ? m[1]!.replace(/^[^@]*@/, "").replace(/:\d+$/, "").toLowerCase() : "";
739
+ }
740
+
741
+ export type CapturedResponse = { url: string; status: number; headers: Record<string, string> };
742
+
743
+ /**
744
+ * One captured response with its credentials taken out: credential header values, and in the URL
745
+ * and URL-valued headers the query values, fragment and userinfo. Self-contained (only its
746
+ * arguments and builtins), because the browser runs it too via toString(), so raw values never
747
+ * leave the browser; serve runs it again on what comes back.
748
+ */
749
+ export function redactResponse(e: { url?: unknown; status?: unknown; headers?: unknown }, names: readonly string[], pattern: string, urlHeaders: readonly string[], mask: string): CapturedResponse {
750
+ const maskUrl = (u: string): string => {
751
+ u = u.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, `$1${mask}@`);
752
+ const hash = u.indexOf("#");
753
+ const frag = hash >= 0 ? `#${mask}` : "";
754
+ if (hash >= 0) u = u.slice(0, hash);
755
+ const q = u.indexOf("?");
756
+ if (q < 0) return u + frag;
757
+ const query = u.slice(q + 1).split("&").map(p => (p.includes("=") ? `${p.slice(0, p.indexOf("="))}=${mask}` : p ? mask : p)).join("&");
758
+ return `${u.slice(0, q)}?${query}${frag}`;
759
+ };
760
+ const credential = new RegExp(pattern, "i");
761
+ const raw = e.headers && typeof e.headers === "object" ? (e.headers as Record<string, unknown>) : {};
762
+ const headers: Record<string, string> = {};
763
+ for (const k of Object.keys(raw)) {
764
+ const n = k.toLowerCase();
765
+ headers[k] = names.includes(n) || credential.test(n) ? mask : urlHeaders.includes(n) ? maskUrl(String(raw[k])) : String(raw[k]);
766
+ }
767
+ const status = Number(e.status);
768
+ return { url: maskUrl(String(e.url ?? "")), status: Number.isFinite(status) ? status : 0, headers };
769
+ }
770
+
771
+ /**
772
+ * The evidence file's text: the (already redacted) responses, then anything `mask` knows (filled
773
+ * passwords, TOTPs) and anything the secret scanner recognizes, e.g. a token in an unusual header.
774
+ */
775
+ export function readHeadersEvidence(captured: CapturedResponse[], mask: (text: string) => string): string {
776
+ return redact(mask(JSON.stringify(captured.map(redactCaptured), null, 2))).text + "\n";
777
+ }
778
+
779
+ /** redactResponse with this module's lists. */
780
+ export function redactCaptured(e: { url?: unknown; status?: unknown; headers?: unknown }): CapturedResponse {
781
+ return redactResponse(e, REDACTED_HEADERS, REDACTED_HEADER_PATTERN, URL_HEADERS, HEADER_REDACTION);
782
+ }
783
+
784
+ /** Keep every header name; replace the value of a credential-bearing header with <redacted>. */
785
+ export function redactHeaders(headers: Record<string, unknown>): Record<string, string> {
786
+ return redactCaptured({ headers }).headers;
787
+ }
788
+
789
+ function validateSeconds(seconds: number): number {
790
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > READ_HEADERS_MAX_SECONDS)
791
+ throw new Error(`read-headers: --seconds must be an integer between 1 and ${READ_HEADERS_MAX_SECONDS}`);
792
+ return seconds;
793
+ }
794
+
795
+
796
+ export function parseReadHeadersArgs(args: string[]): { allowDomains: string[]; seconds: number; gotoUrl?: string } {
797
+ const [cmd, ...rest] = args;
798
+ if (cmd !== "read-headers") throw new Error("Expected read-headers");
799
+ const allowDomains: string[] = [];
800
+ let seconds = READ_HEADERS_DEFAULT_SECONDS;
801
+ let gotoUrl: string | undefined;
802
+ for (let i = 0; i < rest.length; i++) {
803
+ const a = rest[i]!;
804
+ if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
805
+ const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
806
+ if (!v) throw new Error("--allow-domain got an empty host");
807
+ for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
808
+ } else if (a === "--seconds" || a.startsWith("--seconds=")) {
809
+ const v = a === "--seconds" ? rest[++i] : a.slice("--seconds=".length);
810
+ if (!v) throw new Error("--seconds needs a value");
811
+ seconds = validateSeconds(Number(v));
812
+ } else if (a === "--goto" || a.startsWith("--goto=")) {
813
+ gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
814
+ if (!gotoUrl) throw new Error("--goto got an empty URL");
815
+ } else if (a.startsWith("-")) throw new Error(`read-headers: unknown option ${a}`);
816
+ else throw new Error(`read-headers: unexpected argument ${a}`);
817
+ }
818
+ if (!allowDomains.length) throw new Error("read-headers needs --allow-domain: name the host to capture responses from");
819
+ if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
820
+ return { allowDomains, seconds, gotoUrl };
821
+ }
822
+
823
+ export function readHeadersWireArgs(req: { allowDomains: string[]; seconds: number; gotoUrl?: string }): string[] {
824
+ return ["read-headers", ...req.allowDomains.map(d => `--allow-domain=${d}`), `--seconds=${req.seconds}`, ...(req.gotoUrl ? [`--goto=${req.gotoUrl}`] : [])];
825
+ }
826
+
827
+ /** Parse what the daemon received; exactly the shape readHeadersWireArgs sends. */
828
+ export function parseReadHeadersWire(args: string[]): { allowDomains: string[]; seconds: number; gotoUrl?: string } {
829
+ const [cmd, ...rest] = args;
830
+ if (cmd !== "read-headers") throw new Error("read-headers expected");
831
+ const allowDomains: string[] = [];
832
+ let seconds = READ_HEADERS_DEFAULT_SECONDS;
833
+ let gotoUrl: string | undefined;
834
+ for (const a of rest) {
835
+ if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
836
+ else if (a.startsWith("--seconds=")) { seconds = validateSeconds(Number(a.slice("--seconds=".length))); }
837
+ else if (a.startsWith("--goto=")) { gotoUrl = a.slice("--goto=".length); }
838
+ else throw new Error(`read-headers: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
839
+ }
840
+ if (!allowDomains.length) throw new Error("read-headers needs --allow-domain");
841
+ if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
842
+ return { allowDomains, seconds, gotoUrl };
843
+ }
844
+
845
+ /**
846
+ * run-code that captures responses on the attached page, optionally navigates to gotoUrl first, waits
847
+ * the window, and returns {host, count, captured} JSON. Only responses whose host matches an
848
+ * --allow-domain glob are kept, and credential-bearing header values are redacted IN the browser so
849
+ * their raw values never leave it. Uses page.on("response") (Playwright's view of the CDP Network
850
+ * domain), so it works over the extension bridge the same way read-cookie's run-code does.
851
+ */
852
+ export function buildReadHeadersCode(allowDomains: string[], seconds: number, gotoUrl?: string): string {
853
+ const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
854
+ return `async page => {
855
+ const allowed = ${JSON.stringify(patterns)};
856
+ const redact = ${redactResponse.toString()};
857
+ const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
858
+ const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
859
+ const captured = [];
860
+ const onResponse = async (response) => {
861
+ try {
862
+ const url = String(response.url());
863
+ const host = hostOf(url);
864
+ if (!ok(host)) return;
865
+ let headers = {};
866
+ try { headers = await response.allHeaders(); } catch { try { headers = response.headers(); } catch { headers = {}; } }
867
+ captured.push(redact({ url, status: response.status(), headers }, ${JSON.stringify(REDACTED_HEADERS)}, ${JSON.stringify(REDACTED_HEADER_PATTERN)}, ${JSON.stringify(URL_HEADERS)}, ${JSON.stringify(HEADER_REDACTION)}));
868
+ } catch {}
869
+ };
870
+ page.on("response", onResponse);
871
+ // The window includes the --goto: the whole capture fits in the one request.
872
+ const endAt = Date.now() + ${seconds} * 1000;
873
+ try {
874
+ ${gotoUrl ? `try { await page.goto(${JSON.stringify(gotoUrl)}, { waitUntil: "load", timeout: ${seconds * 1000} }); } catch {}` : ""}
875
+ await page.waitForTimeout(Math.max(0, endAt - Date.now()));
876
+ } finally {
877
+ try { page.off("response", onResponse); } catch {}
878
+ }
879
+ return JSON.stringify({ host: ${JSON.stringify(allowDomains[0] ?? "")}, count: captured.length, captured });
880
+ }`;
881
+ }
882
+
883
+ /**
884
+ * Capture response status + headers on the session's live browser over the window. Returns the
885
+ * captured array (re-redacted daemon-side as defence in depth); the caller writes the evidence file.
886
+ */
887
+ export async function readHeadersOnSession(opts: { socketRoot: string; session: string; cwd: string; allowDomains: string[]; seconds: number; gotoUrl?: string }): Promise<{ host: string; captured: CapturedResponse[] }> {
888
+ const timeoutMs = opts.seconds * 1000 + 15_000;
889
+ const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadHeadersCode(opts.allowDomains, opts.seconds, opts.gotoUrl), json: true, timeoutMs });
890
+ if (isError) throw new Error(text.match(/read-headers(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-headers: could not capture responses"));
891
+ let raw: unknown;
892
+ try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
893
+ let out: any = raw;
894
+ for (let i = 0; i < 2 && typeof out === "string"; i++) { try { out = JSON.parse(out); } catch { out = undefined; } }
895
+ if (!out || !Array.isArray(out.captured)) throw new Error("read-headers: the browser returned no capture");
896
+ const captured = out.captured.map((e: any) => redactCaptured(e && typeof e === "object" ? e : {}));
897
+ return { host: typeof out.host === "string" ? out.host : (opts.allowDomains[0] ?? ""), captured };
898
+ }
899
+
640
900
  // ---------- password fields in snapshots ----------
641
901
  //
642
902
  // Snapshots print every input's value, and that includes password fields Chrome autofilled
package/listeners.js CHANGED
@@ -29,7 +29,7 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
29
29
  import { homedir } from "os";
30
30
  import { join as join2 } from "path";
31
31
 
32
- // secret.ts
32
+ // token.ts
33
33
  var B58 = "[1-9A-HJ-NP-Za-km-z]";
34
34
  var BODY_LEN = 22;
35
35
  var SUM_LEN = 6;
@@ -45,7 +45,6 @@ var CRC_TABLE = (() => {
45
45
  }
46
46
  return t;
47
47
  })();
48
-
49
48
  // shares.ts
50
49
  var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
51
50
  var DEFAULT_RTC = "webrtc-default";
@@ -192,6 +191,8 @@ function validateListeners(value) {
192
191
  throw new Error("readSecrets must be true or false");
193
192
  if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
194
193
  throw new Error("readCookies must be true or false");
194
+ if (l.readHeaders !== undefined && typeof l.readHeaders !== "boolean")
195
+ throw new Error("readHeaders must be true or false");
195
196
  if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean")
196
197
  throw new Error("keepDownloads must be true or false");
197
198
  const address = listenerAddress(l);
@@ -237,7 +238,8 @@ async function writeListeners(config) {
237
238
  `, { mode: 384 });
238
239
  await rename(temp, LISTENERS_FILE);
239
240
  }
240
- var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
241
+ var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss", "record"]);
242
+ var RECORD_SCOPED_OPTS = ["--seconds", "--max-seconds", "--fps", "--quality", "--filename", "--format"];
241
243
  function authorizeProfileRequest(listener, body) {
242
244
  if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a) => typeof a !== "string"))
243
245
  throw new Error("Scoped requests require identity.key, identity.profile and string args");
@@ -255,9 +257,11 @@ function authorizeProfileRequest(listener, body) {
255
257
  throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
256
258
  if (args[0] === "read-cookie" && listener.readCookies !== true)
257
259
  throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
258
- if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && !SCOPED_COMMANDS.has(args[0]))
260
+ if (args[0] === "read-headers" && listener.readHeaders !== true)
261
+ throw new Error(`read-headers is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-headers`);
262
+ if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && args[0] !== "read-headers" && !SCOPED_COMMANDS.has(args[0]))
259
263
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
260
- if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && a.startsWith("--allow-domain="))))
264
+ if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && (a.startsWith("--allow-domain=") || a.startsWith("--goto="))) && !(args[0] === "read-headers" && (a.startsWith("--allow-domain=") || a.startsWith("--seconds=") || a.startsWith("--goto="))) && !(args[0] === "record" && RECORD_SCOPED_OPTS.some((o) => a === o || a.startsWith(o + "=")))))
261
265
  throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
262
266
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
263
267
  throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
package/listeners.ts CHANGED
@@ -7,7 +7,7 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
7
  import { DEFAULT_RTC, DEFAULT_SIGNAL, normalizeSignal } from "./shares.ts";
8
8
 
9
9
  // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
10
- export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean };
10
+ export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; readHeaders?: boolean; keepDownloads?: boolean };
11
11
  // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
12
12
  // saved for a removed listener gets "connection refused", not some other listener's answer.
13
13
  // rtcListeners: WebRTC transports (a signal server, plus optional STUN/TURN). They live under
@@ -140,6 +140,7 @@ export function validateListeners(value: unknown): ListenerConfig {
140
140
  if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
141
141
  if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
142
142
  if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
143
+ if (l.readHeaders !== undefined && typeof l.readHeaders !== "boolean") throw new Error("readHeaders must be true or false");
143
144
  if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean") throw new Error("keepDownloads must be true or false");
144
145
  const address = listenerAddress(l);
145
146
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
@@ -179,7 +180,10 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
179
180
 
180
181
  // Restricted listeners deliberately exclude arbitrary code, host filesystem access,
181
182
  // attachment/config overrides, and commands that manage all browser sessions.
182
- const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
183
+ const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss", "record"]);
184
+ // The CLI options each scoped command may carry (everything else is refused). record uses a
185
+ // start/stop subcommand (a positional) plus these capture/output knobs (record.ts RECORD_OPTIONS).
186
+ const RECORD_SCOPED_OPTS = ["--seconds", "--max-seconds", "--fps", "--quality", "--filename", "--format"];
183
187
  export function authorizeProfileRequest(listener: Listener, body: any): string {
184
188
  if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a: unknown) => typeof a !== "string")) throw new Error("Scoped requests require identity.key, identity.profile and string args");
185
189
  const profile = body.identity.profile;
@@ -194,8 +198,10 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
194
198
  throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
195
199
  if (args[0] === "read-cookie" && listener.readCookies !== true)
196
200
  throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
197
- if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
198
- if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && a.startsWith("--allow-domain=")))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
201
+ if (args[0] === "read-headers" && listener.readHeaders !== true)
202
+ throw new Error(`read-headers is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-headers`);
203
+ if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && args[0] !== "read-headers" && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
204
+ if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && (a.startsWith("--allow-domain=") || a.startsWith("--goto="))) && !(args[0] === "read-headers" && (a.startsWith("--allow-domain=") || a.startsWith("--seconds=") || a.startsWith("--goto="))) && !(args[0] === "record" && RECORD_SCOPED_OPTS.some(o => a === o || a.startsWith(o + "="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
199
205
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank") throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
200
206
  // Upload is handled by the byte-only receiver; the caller cannot supply host paths.
201
207
  if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -23,6 +23,8 @@
23
23
  "listeners.js",
24
24
  "fill-secret.ts",
25
25
  "fill-secret.js",
26
+ "record.ts",
27
+ "record.js",
26
28
  "redact.ts",
27
29
  "redact.js",
28
30
  "secret-rules",
@@ -36,6 +38,10 @@
36
38
  "scoped-upload.js",
37
39
  "secret.ts",
38
40
  "secret.js",
41
+ "token.ts",
42
+ "token.js",
43
+ "rtc-channel.ts",
44
+ "rtc-channel.js",
39
45
  "shares.ts",
40
46
  "shares.js",
41
47
  "e2e.ts",
@@ -49,11 +55,12 @@
49
55
  "scripts": {
50
56
  "serve": "bun run rechrome.ts serve",
51
57
  "test": "bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts",
58
+ "build-invite-page": "bun scripts/build-invite-page.ts",
52
59
  "build-playwright": "bash scripts/build-playwright.sh",
53
60
  "vendor-cli": "bash scripts/vendor-cli.sh",
54
61
  "prepare": "bash scripts/vendor-cli.sh --prepare",
55
62
  "check-pack": "bash scripts/check-pack.sh",
56
- "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bun build redact.ts --target=bun --outfile=redact.js && bash scripts/check-pack.sh"
63
+ "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build record.ts --target=bun --outfile=record.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build token.ts --target=bun --outfile=token.js && bun build rtc-channel.ts --target=bun --outfile=rtc-channel.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bun build redact.ts --target=bun --outfile=redact.js && bash scripts/check-pack.sh"
57
64
  },
58
65
  "devDependencies": {
59
66
  "@types/bun": "latest",