rechrome 1.37.2 → 1.38.1

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
@@ -5,6 +5,7 @@ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePr
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
7
7
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
8
+ import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
8
9
  import { createHash, randomBytes } from "crypto";
9
10
  import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
10
11
  import { hostname, homedir, networkInterfaces } from "os";
@@ -1492,6 +1493,43 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1492
1493
  ].filter(Boolean).join("\n"), "none", scoped);
1493
1494
  }
1494
1495
 
1496
+ /**
1497
+ * Why a saved link to one of this machine's listeners stopped working, with the command that
1498
+ * replaces it; null when it still matches its listener or points at another machine.
1499
+ * A removed listener's port goes to the next one created (`freeListenerPort`), so a stale
1500
+ * link can reach a different listener: its path is not served there and its key is unknown.
1501
+ */
1502
+ export function staleLinkHint(url: string, listeners: Listener[], source?: string): string | null {
1503
+ let parsed: ReturnType<typeof parseUrl>;
1504
+ try { parsed = parseUrl(url); } catch { return null; }
1505
+ const loopback = (host: string) => isLoopback(host) || host === "localhost";
1506
+ const urlHost = parsed.host.replace(/^\[|\]$/g, "");
1507
+ const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || l.host === "0.0.0.0" || loopback(l.host) && loopback(urlHost)));
1508
+ const owner = listeners.find(l => l.key === parsed.key);
1509
+ if (!at && !owner && !loopback(urlHost)) return null; // another machine's daemon: only its owner knows
1510
+ if (owner && owner === at && normalizePrefix(owner.prefix) === parsed.prefix) return null;
1511
+ const profile = parsed.profileDirectory;
1512
+ let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
1513
+ if (profile) {
1514
+ try {
1515
+ const name = owner && owner.profiles !== "*" ? owner.name : chooseShareListener(profile, listeners);
1516
+ fix = `rech share ${JSON.stringify(profile)} --listener ${name}${owner?.publicUrl || listeners.find(l => l.name === name)?.publicUrl ? "" : " --local"} --save`;
1517
+ } catch { /* no single listener shares it: the default creates a fresh link */ }
1518
+ }
1519
+ const lines = [owner
1520
+ ? `this link's listener "${owner.name}" moved: it now serves ${listenerAddress(owner)}${normalizePrefix(owner.prefix)}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1521
+ : `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`];
1522
+ if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
1523
+ lines.push(`Replace it with a current link: ${fix}`);
1524
+ return lines.join("\n ");
1525
+ }
1526
+
1527
+ /** staleLinkHint against this machine's listeners.json; null when it can't be read. */
1528
+ async function localStaleLinkHint(url: string): Promise<string | null> {
1529
+ const config = await readListeners().catch(() => null);
1530
+ return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
1531
+ }
1532
+
1495
1533
  /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1496
1534
  function freeListenerPort(listeners: Listener[]): number {
1497
1535
  const used = new Set(listeners.map(l => l.port));
@@ -1869,6 +1907,7 @@ async function callServe(
1869
1907
  precomputedIdentity?: { key: string; label: string; profile?: string },
1870
1908
  throwOnFailure = false,
1871
1909
  upload?: UploadPayload,
1910
+ secret?: string,
1872
1911
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
1873
1912
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
1874
1913
  // Reuse the caller's identity when provided — computing it shells out to `git` several times,
@@ -1887,7 +1926,8 @@ async function callServe(
1887
1926
  method: "POST",
1888
1927
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
1889
1928
  // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
1890
- body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}) }),
1929
+ // `secret` (fill-secret only) rides in its own field so it is never part of the logged args.
1930
+ body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}), ...(secret ? { secret } : {}) }),
1891
1931
  signal: AbortSignal.timeout(70_000),
1892
1932
  }).catch(async (e) => {
1893
1933
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
@@ -1915,8 +1955,9 @@ async function callServe(
1915
1955
  process.exit(1);
1916
1956
  });
1917
1957
  if (res.status === 401) {
1918
- if (throwOnFailure) throw new Error("The daemon rejected its connection key. Run setup again to refresh it.");
1919
- console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
1958
+ const stale = await localStaleLinkHint(url);
1959
+ if (throwOnFailure) throw new Error(stale ? `The daemon rejected this link's key: ${stale}` : "The daemon rejected its connection key. Run setup again to refresh it.");
1960
+ console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]${stale ? `\n ${stale}` : ""}`);
1920
1961
  process.exit(1);
1921
1962
  }
1922
1963
  const text = await res.text();
@@ -1927,6 +1968,8 @@ async function callServe(
1927
1968
  let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1928
1969
  if (res.status === 502 || res.status === 503 || res.status === 504)
1929
1970
  detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
1971
+ const stale = await localStaleLinkHint(url);
1972
+ if (stale) detail += `\n ${stale}`;
1930
1973
  if (throwOnFailure) throw new Error(detail);
1931
1974
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1932
1975
  process.exit(1);
@@ -2099,12 +2142,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2099
2142
 
2100
2143
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension });
2101
2144
  const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
2145
+ let secret: string | undefined;
2102
2146
  let upload: UploadPayload | undefined;
2103
- if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
2147
+ const cmdIdx = args.findIndex(a => !a.startsWith("-s="));
2148
+ if (args[cmdIdx] === "fill-secret") {
2149
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
2150
+ try {
2151
+ ({ args, secret } = await prepareFillSecret(args));
2152
+ } catch (err) {
2153
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${FILL_SECRET_USAGE}`);
2154
+ process.exit(2);
2155
+ }
2156
+ } else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
2104
2157
  if (process.stdin.isTTY) throw new Error("upload reads stdin; redirect a PNG/SVG file with < icon.png");
2105
2158
  ({ args, upload } = await prepareStdinUpload(args, process.stdin));
2106
2159
  }
2107
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity, false, upload);
2160
+ const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
2161
+ // The daemon already masks the value; this is a second line of defence for an older daemon.
2162
+ const scrub = (s: string) => secret && s ? s.replaceAll(secret, SECRET_MASK) : s;
2163
+ const { files, existingSession } = served;
2164
+ const status = served.status, stdout = scrub(served.stdout), stderr = scrub(served.stderr);
2108
2165
  if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
2109
2166
  // Close the failed session so the retry gets a fresh cliDaemon and relay. callServe re-reads
2110
2167
  // the extension's live token from the profile, so a re-minted token heals here too.
@@ -2171,6 +2228,22 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2171
2228
  process.exit(status);
2172
2229
  }
2173
2230
 
2231
+ /** Swap the client-side fill-secret args for the wire form and read the value (or TOTP code). */
2232
+ async function prepareFillSecret(args: string[]): Promise<{ args: string[]; secret: string }> {
2233
+ const sessionArgs = args.filter(a => a.startsWith("-s="));
2234
+ const req = parseFillSecretArgs(args.filter(a => !a.startsWith("-s=")).slice(1));
2235
+ const raw = await readSecretSource(req.source);
2236
+ let secret = raw;
2237
+ if (req.totp) {
2238
+ const wait = totpWaitMs();
2239
+ if (wait) { console.error(`[rech] TOTP code expires in under 5s; waiting ${Math.ceil(wait / 1000)}s for the next one`); await Bun.sleep(wait); }
2240
+ secret = totpCode(raw); // only the code leaves this machine, never the seed
2241
+ }
2242
+ if (!req.allowDomains.length) console.error("[rech] fill-secret: no --allow-domain given; the value will be typed into whatever page is active");
2243
+ const wire = fillSecretWireArgs(req);
2244
+ return { args: [...wire, ...sessionArgs], secret };
2245
+ }
2246
+
2174
2247
  /** The daemon's Playwright CLI predates `open --wait` (rechrome <= 1.35.1 bundled one). */
2175
2248
  export function cliRejectsWaitOption(output: string): boolean {
2176
2249
  return /Unknown options?:?[^\n]*?['"`]?--wait\b/i.test(output);
@@ -2883,45 +2956,90 @@ function findChromiumForTesting(): string | null {
2883
2956
  // Minimal Chrome DevTools Protocol client over a WebSocket — just enough to create a
2884
2957
  // target, attach to it, and evaluate JS. Used to seed the auth token into a managed
2885
2958
  // profile's extension localStorage without pulling in the full Playwright dependency.
2886
- class CDPClient {
2959
+ export class CDPClient {
2887
2960
  private ws: WebSocket;
2888
2961
  private nextId = 0;
2889
- private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
2962
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
2890
2963
  private opened: Promise<void>;
2891
- constructor(url: string) {
2964
+ constructor(url: string, private timeoutMs = 30_000) {
2892
2965
  this.ws = new WebSocket(url);
2893
2966
  this.opened = new Promise<void>((resolve, reject) => {
2894
- this.ws.addEventListener("open", () => resolve(), { once: true });
2895
- this.ws.addEventListener("error", () => reject(new Error("CDP WebSocket error")), { once: true });
2967
+ const timer = setTimeout(() => { reject(new Error("CDP WebSocket connection timed out")); this.close(); }, timeoutMs);
2968
+ this.ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
2969
+ const fail = () => {
2970
+ clearTimeout(timer);
2971
+ const error = new Error("CDP WebSocket closed or failed");
2972
+ reject(error);
2973
+ for (const p of this.pending.values()) p.reject(error);
2974
+ this.pending.clear();
2975
+ };
2976
+ this.ws.addEventListener("error", fail);
2977
+ this.ws.addEventListener("close", fail);
2896
2978
  });
2897
2979
  this.ws.addEventListener("message", (ev: MessageEvent) => {
2898
2980
  let msg: any;
2899
2981
  try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : ""); } catch { return; }
2900
2982
  const p = msg.id != null ? this.pending.get(msg.id) : undefined;
2901
2983
  if (!p) return;
2902
- this.pending.delete(msg.id);
2903
2984
  if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
2904
2985
  else p.resolve(msg.result);
2905
2986
  });
2906
2987
  }
2907
2988
  async open(): Promise<void> { await this.opened; }
2908
2989
  send(method: string, params: Record<string, any> = {}, sessionId?: string): Promise<any> {
2990
+ if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new Error("CDP WebSocket is not open"));
2909
2991
  const id = ++this.nextId;
2910
- const payload: any = { id, method, params };
2911
- if (sessionId) payload.sessionId = sessionId;
2912
2992
  return new Promise((resolve, reject) => {
2913
- this.pending.set(id, { resolve, reject });
2914
- this.ws.send(JSON.stringify(payload));
2915
- setTimeout(() => { if (this.pending.delete(id)) reject(new Error(`CDP ${method} timed out`)); }, 15_000);
2993
+ const finish = (callback: (value: any) => void, value: any) => {
2994
+ clearTimeout(timer);
2995
+ this.pending.delete(id);
2996
+ callback(value);
2997
+ };
2998
+ const timer = setTimeout(() => finish(reject, new Error(`CDP ${method} timed out`)), this.timeoutMs);
2999
+ this.pending.set(id, { resolve: v => finish(resolve, v), reject: e => finish(reject, e) });
3000
+ try { this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
3001
+ catch (error) { finish(reject, error); }
2916
3002
  });
2917
3003
  }
2918
- close(): void { try { this.ws.close(); } catch {} }
3004
+ close(): void {
3005
+ for (const p of this.pending.values()) p.reject(new Error("CDP client closed"));
3006
+ this.pending.clear();
3007
+ try { this.ws.close(); } catch {}
3008
+ }
3009
+ }
3010
+
3011
+ async function waitForExit(exited: Promise<unknown>, timeoutMs: number): Promise<boolean> {
3012
+ let timer: ReturnType<typeof setTimeout>;
3013
+ try {
3014
+ return await Promise.race([exited.then(() => true), new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); })]);
3015
+ } finally { clearTimeout(timer!); }
3016
+ }
3017
+
3018
+ export async function seedExtensionToken(cdp: Pick<CDPClient, "send">, token: string): Promise<void> {
3019
+ const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
3020
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
3021
+ // The extension page may still be loading; retry the write until localStorage reflects it.
3022
+ let ok = false;
3023
+ const expr = `(()=>{try{if(location.origin!==${JSON.stringify(`chrome-extension://${EXTENSION_ID}`)})return 'ERR:Extension page not loaded';localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
3024
+ let lastError = "Extension page is not ready";
3025
+ const deadline = Date.now() + 30_000;
3026
+ while (Date.now() < deadline) {
3027
+ const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId);
3028
+ lastError = r?.exceptionDetails?.text || String(r?.result?.value ?? "No evaluation result");
3029
+ if (r?.result?.value === token) { ok = true; break; }
3030
+ // A target opened before the extension finishes loading can remain on Chrome's
3031
+ // error document forever. Evaluating it again cannot finish the navigation.
3032
+ const navigation = await cdp.send("Page.navigate", { url: `chrome-extension://${EXTENSION_ID}/status.html` }, sessionId);
3033
+ if (navigation.errorText) lastError = navigation.errorText;
3034
+ await Bun.sleep(1_000);
3035
+ }
3036
+ if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/: ${lastError}`);
2919
3037
  }
2920
3038
 
2921
3039
  // Launch a throwaway Chrome against a dedicated user-data-dir with the unpacked extension
2922
3040
  // loaded, then seed `token` into the extension's localStorage (the value `connect.html` checks
2923
3041
  // for token-bypass). Headless by default; never touches the user's real Chrome/profiles.
2924
- async function provisionExtensionToken(opts: {
3042
+ export async function provisionExtensionToken(opts: {
2925
3043
  userDataDir: string; profileDir: string; dist: string; token: string; headed?: boolean;
2926
3044
  }): Promise<void> {
2927
3045
  // Branded Google Chrome 149+ rejects --load-extension ("not allowed in Google Chrome"), so a
@@ -2944,73 +3062,80 @@ async function provisionExtensionToken(opts: {
2944
3062
  "--no-first-run",
2945
3063
  "--no-default-browser-check",
2946
3064
  "--disable-background-timer-throttling",
3065
+ "--disable-background-networking",
3066
+ "--disable-component-update",
3067
+ "--disable-search-engine-choice-screen",
3068
+ // Match Playwright's unattended macOS launch: no interactive keychain prompt.
3069
+ "--password-store=basic",
3070
+ "--use-mock-keychain",
2947
3071
  ];
2948
3072
  if (!opts.headed) args.push("--headless=new");
2949
3073
  if (process.platform === "linux") args.push("--no-sandbox");
2950
- // Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
2951
- if (process.platform === "darwin") args.push("--use-mock-keychain");
2952
3074
  args.push("about:blank");
2953
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
3075
+ // Own a separate process group so escalation cannot touch the user's Chrome.
3076
+ const proc = cpSpawn(chromeBin, args, { detached: process.platform !== "win32", stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
3077
+ let stderr = "";
3078
+ proc.stderr?.on("data", data => { stderr = (stderr + data.toString()).slice(-8_192); });
3079
+ let launchError: Error | undefined;
3080
+ const exited = new Promise<void>(resolve => {
3081
+ proc.once("exit", () => resolve());
3082
+ proc.once("error", error => { launchError = error; resolve(); });
3083
+ });
3084
+ const signal = (value: NodeJS.Signals) => {
3085
+ if (!proc.pid) return;
3086
+ try {
3087
+ if (process.platform === "win32") proc.kill(value);
3088
+ else process.kill(-proc.pid, value);
3089
+ } catch {}
3090
+ };
3091
+ const interrupted = (value: NodeJS.Signals) => {
3092
+ signal("SIGKILL");
3093
+ console.error(`Profile provisioning interrupted (${value})`);
3094
+ process.exit(value === "SIGINT" ? 130 : 143);
3095
+ };
3096
+ const onInt = () => interrupted("SIGINT");
3097
+ const onTerm = () => interrupted("SIGTERM");
3098
+ process.once("SIGINT", onInt);
3099
+ process.once("SIGTERM", onTerm);
2954
3100
  let cdp: CDPClient | null = null;
2955
- // Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
2956
- const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
2957
- Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
3101
+ let phase = "Chrome startup";
2958
3102
  try {
2959
3103
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
2960
3104
  let port: number | null = null;
2961
- for (let i = 0; i < 300; i++) {
3105
+ for (let i = 0; i < 600; i++) {
2962
3106
  await Bun.sleep(100);
2963
3107
  const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
2964
3108
  if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
2965
- if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
3109
+ if (launchError) throw launchError;
3110
+ if (proc.exitCode !== null || proc.signalCode !== null) throw new Error(`Chrome exited before opening the DevTools port (${proc.exitCode ?? proc.signalCode})`);
2966
3111
  }
2967
- if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
2968
- const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
3112
+ if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
3113
+ phase = "Chrome DevTools discovery";
3114
+ const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(30_000) })).json();
3115
+ phase = "Chrome DevTools connection";
2969
3116
  cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
2970
- await within(5000, "Connecting to Chrome DevTools", cdp.open());
2971
- // Wait for the extension's service worker: if it never shows, the extension didn't load.
2972
- const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
2973
- let loaded = false;
2974
- for (let i = 0; i < 50 && !loaded; i++) {
2975
- const { targetInfos } = await cdp.send("Target.getTargets");
2976
- loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
2977
- if (!loaded) await Bun.sleep(200);
2978
- }
2979
- if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
2980
- const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
2981
- const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
2982
- // The extension page may still be loading; retry the write until localStorage reflects it.
2983
- let ok = false, last = "";
2984
- const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
2985
- const deadline = Date.now() + 10_000;
2986
- while (Date.now() < deadline) {
2987
- const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
2988
- const value = (r as any)?.result?.value;
2989
- if (value === token) { ok = true; break; }
2990
- last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
2991
- await Bun.sleep(200);
2992
- }
2993
- if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
3117
+ await cdp.open();
3118
+ phase = "Extension token seeding";
3119
+ await seedExtensionToken(cdp, token);
2994
3120
  // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
2995
- await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
2996
- await within(5000, "Chrome exit", proc.exited).catch(() => {});
3121
+ phase = "Chrome shutdown";
3122
+ await cdp.send("Browser.close").catch(() => {});
3123
+ if (!await waitForExit(exited, 30_000)) throw new Error("Chrome did not exit after flushing the auth token");
3124
+ if (readExtensionTokenFromProfile(userDataDir, profileDir, EXTENSION_ID) !== token)
3125
+ throw new Error("Chrome exited without persisting the extension auth token");
3126
+ } catch (error) {
3127
+ const message = error instanceof Error ? error.message : String(error);
3128
+ throw new Error(`${phase}: ${message}${stderr.trim() ? `\nChrome stderr:\n${stderr.trim()}` : ""}`.replaceAll(token, "[redacted]"));
2997
3129
  } finally {
2998
3130
  cdp?.close();
2999
- await stopThrowawayChrome(proc, userDataDir);
3000
- }
3001
- }
3002
-
3003
- /**
3004
- * Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
3005
- * still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
3006
- */
3007
- async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
3008
- if (proc.exitCode === null) {
3009
- try { proc.kill(); } catch {}
3010
- const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
3011
- if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
3131
+ signal("SIGTERM");
3132
+ if (!await waitForExit(exited, 2_000)) signal("SIGKILL");
3133
+ // Also reap helpers that survived the browser parent exiting.
3134
+ signal("SIGKILL");
3135
+ await waitForExit(exited, 2_000);
3136
+ process.removeListener("SIGINT", onInt);
3137
+ process.removeListener("SIGTERM", onTerm);
3012
3138
  }
3013
- if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
3014
3139
  }
3015
3140
 
3016
3141
  async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
@@ -3029,7 +3154,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
3029
3154
  console.log(` extension: ${dist}`);
3030
3155
  console.log(` Launching ${opts.headed ? "headed" : "headless"} Chrome to seed the auth token...`);
3031
3156
  await provisionExtensionToken({ userDataDir, profileDir: name, dist, token, headed: opts.headed });
3032
- console.log(` Token seeded (${token.slice(0, 6)}…)`);
3157
+ console.log(" Token seeded");
3033
3158
 
3034
3159
  // [2/3] Daemon URL — reuse the running daemon's key; warn (don't fail) if it isn't up yet.
3035
3160
  console.log(`\n[2/3] Building RECHROME_URL`);
@@ -4050,7 +4175,8 @@ async function status(): Promise<void> {
4050
4175
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
4051
4176
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
4052
4177
  // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
4053
- const answered = pingResponse?.ok || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4178
+ // A 200 that isn't the /ping JSON is another listener's catch-all: its port was reused.
4179
+ const answered = (pingResponse?.ok && !!pingBody) || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4054
4180
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
4055
4181
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
4056
4182
  : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
@@ -4062,6 +4188,8 @@ async function status(): Promise<void> {
4062
4188
  if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
4063
4189
  if (pingResponse?.status === 401)
4064
4190
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
4191
+ const stale = ping && !pingBody ? await localStaleLinkHint(url) : null;
4192
+ if (stale) console.log(`stale: ✗ ${stale}`);
4065
4193
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
4066
4194
  // ReferenceError that took down the whole of `rech status`, so the one command
4067
4195
  // that reports "the relay is wedged" died exactly when the relay was wedged,
@@ -4140,6 +4268,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4140
4268
  open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
4141
4269
  --profile <p> as another registered profile (email, name or folder); put it first
4142
4270
  --isolate in a throwaway session, e.g. for a login flow
4271
+ rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
4272
+ type a password / TOTP code without it reaching argv, logs or output
4143
4273
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4144
4274
  rech --version rechrome's version
4145
4275
 
package/serve.js CHANGED
@@ -5,8 +5,8 @@ import { bearerOf, safeEqual } from "./secret.js";
5
5
  import { WebrtcHost } from "./webrtc.js";
6
6
  import { file } from "bun";
7
7
  import { createHash, randomBytes, X509Certificate } from "crypto";
8
- import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
9
- import { join, resolve, relative, isAbsolute } from "path";
8
+ import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
9
+ import { join, resolve, relative, isAbsolute, sep } from "path";
10
10
  import { tmpdir } from "os";
11
11
  import {
12
12
  log,
@@ -24,6 +24,7 @@ import {
24
24
  checkTailscaleServe,
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.js";
27
+ import { SecretMasker, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
27
28
 
28
29
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
29
30
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -87,6 +88,18 @@ const sessionTimeouts = new Map<string, number>(); // per-session consecutive-ti
87
88
  // Most-recently-used non-iso sessions, so the deep health probe can target something real
88
89
  // instead of spawning a fresh session (which could open a browser window).
89
90
  const recentSessions = new Map<string, number>();
91
+ // Values typed by `fill-secret`, masked in every output, log line and served text file for a
92
+ // while afterwards: snapshots echo input values (passwords included). See fill-secret.js.
93
+ const secretMasker = new SecretMasker();
94
+ const TEXT_OUTPUT = /\.(?:ya?ml|json|md|txt|log)$/i;
95
+ // /run commands in flight, with the (real) output dir each writes to once known. A snapshot file
96
+ // exists on disk before its command has checked it for password values, so a text download waits
97
+ // until no command writing to its directory is in flight.
98
+ const runsInFlight = new Map<Promise<void>, string | undefined>();
99
+ // Only the CLI's own artifacts are ever scrubbed or deleted, never a file a page merely names.
100
+ const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
101
+ // Artifacts that could not be checked AND could not be deleted: never served.
102
+ const deniedFiles = new Set<string>();
90
103
  function noteSession(sess: string, now: number): void {
91
104
  recentSessions.set(sess, now);
92
105
  if (recentSessions.size > 64) {
@@ -738,6 +751,15 @@ export async function serve() {
738
751
  if (!(await f.exists())) return new Response("Not found", { status: 404 });
739
752
  const real = realpathSync(resolved);
740
753
  if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
754
+ if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
755
+ if (TEXT_OUTPUT.test(real)) {
756
+ const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
757
+ for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
758
+ // Synchronous from the last check on: no await, so no new run can start and write here.
759
+ if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
760
+ if (!existsSync(real)) return new Response("Not found", { status: 404 }); // withheld
761
+ return new Response(maskPasswordLines(secretMasker.mask(readFileSync(real, "utf8"))));
762
+ }
741
763
  return new Response(f);
742
764
  }
743
765
 
@@ -782,6 +804,10 @@ export async function serve() {
782
804
  const denied = authCheck(req, key);
783
805
  if (denied) return denied;
784
806
  markActivity(); // a real command: this serve is not idle
807
+ let runDone = () => {};
808
+ const running = new Promise<void>((r) => { runDone = r; });
809
+ runsInFlight.set(running, undefined); // dir not known yet: holds every text download
810
+ try { return await (async () => {
785
811
 
786
812
  let body: any;
787
813
  try { body = await readRequestJson(req); }
@@ -827,6 +853,7 @@ export async function serve() {
827
853
  const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) + (cred?.kind === "device" ? `d-${cred.device.id}/` : "") : "";
828
854
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
829
855
  mkdirSync(runWorkDir, { recursive: true });
856
+ runsInFlight.set(running, realpathSync(runWorkDir));
830
857
  let args: string[];
831
858
  let sessionId: string;
832
859
  let clientName = "";
@@ -894,6 +921,27 @@ export async function serve() {
894
921
 
895
922
  log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
896
923
 
924
+ // fill-secret: the value arrives in body.secret, never in args; it is handed to the live
925
+ // session over its socket (no child argv) and never logged. See fill-secret.js.
926
+ if (filteredArgs[0] === "fill-secret") {
927
+ const secret = !Array.isArray(body) && typeof body.secret === "string" ? body.secret : "";
928
+ try {
929
+ const { ref, allowDomains, submit, totp } = parseFillSecretWire(filteredArgs);
930
+ if (!secret) throw new Error("fill-secret: the request carried no secret (this client is older than the daemon?)");
931
+ secretMasker.add(secret, totp ? undefined : Infinity);
932
+ const reply = await fillSecretOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, ref, value: secret, allowDomains, submit });
933
+ // Drop the echoed run-code body (it embeds the value, masked or not, and is noise).
934
+ const text = secretMasker.mask(reply.text).replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n?/, "").trim();
935
+ const status = reply.isError ? 1 : 0;
936
+ log(`exit: ${status} | fill-secret ${ref}`);
937
+ return Response.json(reply.isError ? { status, stdout: "", stderr: `${text}\n`, files: [] } : { status, stdout: `${text}\n`, stderr: "", files: [] });
938
+ } catch (error) {
939
+ const message = secretMasker.mask(secret ? (error instanceof Error ? error.message : String(error)).replaceAll(secret, "***") : (error instanceof Error ? error.message : String(error)));
940
+ log(`exit: 1 | fill-secret: ${message.split("\n")[0]}`);
941
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
942
+ }
943
+ }
944
+
897
945
  // For open commands, default to about:blank to avoid leaving connect.html visible
898
946
  const isOpenCmd = filteredArgs[0] === "open";
899
947
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
@@ -1041,7 +1089,7 @@ export async function serve() {
1041
1089
  reject(new Error("timeout"));
1042
1090
  }, TIMEOUT);
1043
1091
  });
1044
- const [status, stdout, rawStderr] = await Promise.race([
1092
+ let [status, stdout, rawStderr] = await Promise.race([
1045
1093
  Promise.all([
1046
1094
  proc.exited,
1047
1095
  new Response(proc.stdout).text(),
@@ -1058,7 +1106,7 @@ export async function serve() {
1058
1106
  ? configuredHandshakeTimeout
1059
1107
  : 30_000;
1060
1108
  const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
1061
- const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
1109
+ let stderr = (tccNote ?? "") + inferSilentExtensionFailure({
1062
1110
  status,
1063
1111
  stdout,
1064
1112
  stderr: rawStderr,
@@ -1068,6 +1116,24 @@ export async function serve() {
1068
1116
  handshakeTimeoutMs,
1069
1117
  });
1070
1118
 
1119
+ // Snapshots print every input's value, password fields included (e.g. ones Chrome
1120
+ // autofilled). Read the live password values and mask them before anything is logged,
1121
+ // returned or left on disk; the label-based pass catches what the scan misses.
1122
+ // Fails closed: a snapshot that can't be fully checked is withheld, not passed through.
1123
+ const rawOutput = `${stdout}\n${stderr}`; // artifact paths come from the unmasked text
1124
+ const snapshotOut = hasSnapshot(rawOutput);
1125
+ let withheld = "";
1126
+ if (snapshotOut) {
1127
+ try {
1128
+ for (const v of await passwordValuesOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir })) secretMasker.add(v, Infinity);
1129
+ } catch (error) {
1130
+ withheld = "could not check it for password values";
1131
+ log(`snapshot withheld: ${secretMasker.mask(error instanceof Error ? error.message : String(error)).split("\n")[0]}`);
1132
+ }
1133
+ }
1134
+ stdout = maskPasswordLines(secretMasker.mask(stdout));
1135
+ stderr = maskPasswordLines(secretMasker.mask(stderr));
1136
+ if (withheld) { stdout = withholdSnapshots(stdout, withheld); stderr = withholdSnapshots(stderr, withheld); }
1071
1137
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
1072
1138
 
1073
1139
  // Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS). A command that RETURNS usually
@@ -1105,12 +1171,8 @@ export async function serve() {
1105
1171
  }
1106
1172
 
1107
1173
  // Detect files mentioned in output
1108
- const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
1109
- const mentionedFiles = [
1110
- ...new Set(
1111
- [...stdout.matchAll(filePattern), ...stderr.matchAll(filePattern)].map((m) => m[0]),
1112
- ),
1113
- ];
1174
+ const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|ya?ml)\b/gi;
1175
+ const mentionedFiles = [...new Set([...rawOutput.matchAll(filePattern)].map((m) => m[0]))];
1114
1176
  const outputFiles: string[] = [];
1115
1177
  for (const f of mentionedFiles) {
1116
1178
  if (!isUnderDir(runWorkDir, f)) continue;
@@ -1130,6 +1192,35 @@ export async function serve() {
1130
1192
  }
1131
1193
  }
1132
1194
 
1195
+ // A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
1196
+ const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
1197
+ // Snapshot files echo input values: scrub them on disk too, not only on download. Only
1198
+ // files this command wrote (a page can merely mention an existing file's name). One that
1199
+ // can't be checked or scrubbed is deleted, or, failing that, never served.
1200
+ // The command's own --filename targets count even when the output doesn't name them.
1201
+ const ownTargets = ownFilenames.filter(n => isUnderDir(runWorkDir, n) && existsSync(join(runWorkDir, n)));
1202
+ if (snapshotOut || secretMasker.active) for (const f of [...new Set([...outputFiles, ...ownTargets])]) {
1203
+ const ownFile = ownFilenames.some(n => resolve(runWorkDir, n) === resolve(runWorkDir, f));
1204
+ if (!ownFile && !CLI_TEXT_ARTIFACT.test(f.replaceAll("\\", "/"))) continue;
1205
+ const path = join(runWorkDir, f);
1206
+ let mtimeMs = 0;
1207
+ try { mtimeMs = statSync(path).mtimeMs; } catch { continue; }
1208
+ if (mtimeMs < commandStartedAt - 1000) continue;
1209
+ try {
1210
+ // Unchecked: withhold every snapshot file, and anything the command was told to write.
1211
+ if (withheld && (ownFile || /\.ya?ml$/i.test(f))) throw new Error("withheld");
1212
+ // Text vs binary by content, not name: `snapshot --filename=x.png` writes text.
1213
+ const bytes = new Uint8Array(await file(path).arrayBuffer());
1214
+ if (bytes.subarray(0, 8192).includes(0)) continue; // binary (e.g. a screenshot)
1215
+ const text = new TextDecoder().decode(bytes);
1216
+ const masked = maskPasswordLines(secretMasker.mask(text));
1217
+ if (masked !== text) await Bun.write(path, masked);
1218
+ } catch {
1219
+ try { unlinkSync(path); } catch { try { deniedFiles.add(realpathSync(path)); } catch {} }
1220
+ if (outputFiles.includes(f)) outputFiles.splice(outputFiles.indexOf(f), 1);
1221
+ }
1222
+ }
1223
+
1133
1224
  const rebrand = (s: string) => s.replaceAll("npx playwright-cli", "rech");
1134
1225
  return Response.json({
1135
1226
  status,
@@ -1139,6 +1230,7 @@ export async function serve() {
1139
1230
  // a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
1140
1231
  files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
1141
1232
  });
1233
+ })(); } finally { runsInFlight.delete(running); runDone(); }
1142
1234
  };
1143
1235
 
1144
1236
  // WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the