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/fill-secret.ts ADDED
@@ -0,0 +1,399 @@
1
+ // `rech fill-secret`: type a password or TOTP code into a page without the value ever
2
+ // reaching an argv, a log line, or command output.
3
+ //
4
+ // The secret lives with the client (an env var or stdin), so the client reads it and sends it
5
+ // in its own JSON body field, `secret`, never in `args`. The daemon (`serve`) then:
6
+ // - logs only the args (ref + flags), never `secret`;
7
+ // - hands the value to the session's cliDaemon over its local unix socket as a run-code
8
+ // message, so no child process carries it in argv;
9
+ // - checks the page host and fills in one step, so --allow-domain can't be raced by a
10
+ // navigation between a separate check and the fill;
11
+ // - masks the value as *** in that command's output, and keeps masking it in every later
12
+ // output and served text file (snapshots echo input values, passwords included) for
13
+ // SECRET_MASK_TTL_MS.
14
+ import { createHmac } from "crypto";
15
+ import { connect } from "net";
16
+ import { readdirSync, readFileSync } from "fs";
17
+ import { homedir } from "os";
18
+ import { join } from "path";
19
+
20
+ export const SECRET_MASK = "***";
21
+ export const SECRET_MASK_TTL_MS = 15 * 60_000;
22
+
23
+ // ---------- client side ----------
24
+
25
+ export type FillSecretRequest = {
26
+ ref: string;
27
+ source: { kind: "env" | "stdin"; name?: string; envFile?: string };
28
+ totp: boolean;
29
+ allowDomains: string[];
30
+ submit: boolean;
31
+ };
32
+
33
+ export const FILL_SECRET_USAGE = `Usage: rech fill-secret <ref> (--from-env VAR | --from-stdin | --totp-from-env VAR | --totp-from-stdin)
34
+ [--env-file <path>] [--allow-domain <glob>]... [--submit]
35
+ Fills <ref> (from \`rech snapshot\`) with a secret the daemon never logs or echoes.
36
+ --from-env VAR the value of env var VAR on this machine
37
+ --from-stdin the first line of stdin
38
+ --totp-from-env VAR the current 6-digit TOTP code (RFC 6238, SHA1, 30s) for the base32 seed in VAR;
39
+ only the code leaves this machine, never the seed
40
+ --totp-from-stdin the same, seed read from stdin
41
+ --env-file PATH look VAR up in this dotenv file instead of the environment
42
+ --allow-domain GLOB refuse unless the page host matches, e.g. '*.app.example.com' (repeatable)
43
+ --submit press Enter after filling`;
44
+
45
+ /** Parse `fill-secret` args (without the command itself). Throws a usage error. */
46
+ export function parseFillSecretArgs(args: string[]): FillSecretRequest {
47
+ let ref: string | undefined;
48
+ let source: FillSecretRequest["source"] | undefined;
49
+ let totp = false;
50
+ const allowDomains: string[] = [];
51
+ let submit = false;
52
+ let envFile: string | undefined;
53
+ const setSource = (s: FillSecretRequest["source"], isTotp: boolean) => {
54
+ if (source) throw new Error("fill-secret takes exactly one of --from-env, --from-stdin, --totp-from-env, --totp-from-stdin");
55
+ source = s;
56
+ totp = isTotp;
57
+ };
58
+ for (let i = 0; i < args.length; i++) {
59
+ const a = args[i]!;
60
+ const eq = a.indexOf("=");
61
+ const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
62
+ const value = () => {
63
+ if (a.startsWith("--") && eq > 0) return a.slice(eq + 1);
64
+ const next = args[++i];
65
+ if (next === undefined || next.startsWith("--")) throw new Error(`${flag} needs a value`);
66
+ return next;
67
+ };
68
+ if (flag === "--from-env") setSource({ kind: "env", name: value() }, false);
69
+ else if (flag === "--totp-from-env") setSource({ kind: "env", name: value() }, true);
70
+ else if (flag === "--from-stdin") setSource({ kind: "stdin" }, false);
71
+ else if (flag === "--totp-from-stdin") setSource({ kind: "stdin" }, true);
72
+ else if (flag === "--allow-domain") {
73
+ // An explicitly empty guard (`--allow-domain "$UNSET"`) must not fail open into "no guard".
74
+ const globs = value().split(",").map(s => s.trim());
75
+ if (globs.some(g => !g)) throw new Error("--allow-domain got an empty host");
76
+ allowDomains.push(...globs);
77
+ }
78
+ else if (flag === "--submit") submit = true;
79
+ else if (flag === "--env-file") envFile = value();
80
+ else if (a.startsWith("-")) throw new Error(`fill-secret: unknown option ${a}`);
81
+ else if (ref === undefined) ref = a;
82
+ // A second positional is almost certainly the secret itself typed on the command line,
83
+ // which is exactly what this command exists to avoid. Don't echo it back.
84
+ else throw new Error("fill-secret takes the secret from --from-env/--from-stdin, never as an argument");
85
+ }
86
+ if (!ref) throw new Error("fill-secret needs a target ref, e.g. e12 (see `rech snapshot`)");
87
+ if (!source) throw new Error("fill-secret needs --from-env VAR, --from-stdin, --totp-from-env VAR or --totp-from-stdin");
88
+ if (envFile) {
89
+ if (source.kind !== "env") throw new Error("--env-file goes with --from-env / --totp-from-env");
90
+ source.envFile = envFile;
91
+ }
92
+ if (source.kind === "env" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(source.name!)) throw new Error(`fill-secret: "${source.name}" is not an environment variable name`);
93
+ for (const d of allowDomains) domainGlobToRegExp(d); // validate early
94
+ return { ref, source, totp, allowDomains, submit };
95
+ }
96
+
97
+ /** The args the daemon sees (and logs): the ref and flags, never the secret or where it came from. */
98
+ export function fillSecretWireArgs(req: FillSecretRequest): string[] {
99
+ return ["fill-secret", req.ref, ...req.allowDomains.map(d => `--allow-domain=${d}`), ...(req.submit ? ["--submit"] : []), ...(req.totp ? ["--totp"] : [])];
100
+ }
101
+
102
+ /** Read the raw secret (a value, or a TOTP seed) from the client's env or stdin. */
103
+ export async function readSecretSource(source: FillSecretRequest["source"], env = process.env, stdin: () => Promise<string> = () => Bun.stdin.text()): Promise<string> {
104
+ if (source.kind === "env") {
105
+ const v = source.envFile ? parseDotenv(await Bun.file(source.envFile).text().catch(() => { throw new Error(`fill-secret: cannot read ${source.envFile}`); }))[source.name!] : env[source.name!];
106
+ if (!v) throw new Error(`fill-secret: ${source.name} is not set or empty${source.envFile ? ` in ${source.envFile}` : ""}`);
107
+ return v;
108
+ }
109
+ const text = await stdin();
110
+ const v = text.replace(/\r?\n[\s\S]*$/, "");
111
+ if (!v) throw new Error("fill-secret: nothing on stdin");
112
+ return v;
113
+ }
114
+
115
+ /** Minimal dotenv: KEY=VALUE, optional `export `, single/double quotes, # comments. */
116
+ export function parseDotenv(text: string): Record<string, string> {
117
+ const out: Record<string, string> = {};
118
+ for (const line of text.split(/\r?\n/)) {
119
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
120
+ if (!m) continue;
121
+ let v = m[2]!;
122
+ const q = v[0];
123
+ if ((q === '"' || q === "'") && v.lastIndexOf(q) > 0) {
124
+ v = v.slice(1, v.lastIndexOf(q));
125
+ if (q === '"') v = v.replace(/\\n/g, "\n").replace(/\\(["\\])/g, "$1");
126
+ } else v = v.replace(/\s+#.*$/, "");
127
+ out[m[1]!] = v;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // ---------- TOTP (RFC 6238 / RFC 4226) ----------
133
+
134
+ export function base32Decode(input: string): Buffer {
135
+ const s = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
136
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
137
+ let bits = 0, acc = 0;
138
+ const out: number[] = [];
139
+ for (const ch of s) {
140
+ const v = alphabet.indexOf(ch);
141
+ // Never quote the input: it is the seed.
142
+ if (v < 0) throw new Error("TOTP seed is not valid base32");
143
+ acc = (acc << 5) | v;
144
+ bits += 5;
145
+ if (bits >= 8) { bits -= 8; out.push((acc >>> bits) & 0xff); }
146
+ }
147
+ if (!out.length) throw new Error("TOTP seed is empty");
148
+ return Buffer.from(out);
149
+ }
150
+
151
+ export function totpCode(seedBase32: string, timeMs = Date.now(), opts: { digits?: number; period?: number; algorithm?: "sha1" | "sha256" | "sha512" } = {}): string {
152
+ const { digits = 6, period = 30, algorithm = "sha1" } = opts;
153
+ const counter = Math.floor(timeMs / 1000 / period);
154
+ const msg = Buffer.alloc(8);
155
+ msg.writeBigUInt64BE(BigInt(counter));
156
+ const mac = createHmac(algorithm, base32Decode(seedBase32)).update(msg).digest();
157
+ const offset = mac[mac.length - 1]! & 0x0f;
158
+ const bin = ((mac[offset]! & 0x7f) << 24) | (mac[offset + 1]! << 16) | (mac[offset + 2]! << 8) | mac[offset + 3]!;
159
+ return String(bin % 10 ** digits).padStart(digits, "0");
160
+ }
161
+
162
+ /** ms to wait so a code still has at least `minLeftMs` of validity when it reaches the page. */
163
+ export function totpWaitMs(timeMs = Date.now(), period = 30, minLeftMs = 5000): number {
164
+ const left = period * 1000 - (timeMs % (period * 1000));
165
+ return left < minLeftMs ? left + 50 : 0;
166
+ }
167
+
168
+ // ---------- shared ----------
169
+
170
+ /**
171
+ * `example.com` matches only that host; `*.example.com` matches any subdomain at any depth
172
+ * (not the apex). Case-insensitive; ports are not part of the match.
173
+ */
174
+ export function domainGlobToRegExp(glob: string): RegExp {
175
+ const g = glob.trim().toLowerCase();
176
+ if (!/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*$/.test(g)) throw new Error(`--allow-domain "${glob}" is not a host or *.host glob`);
177
+ const esc = (s: string) => s.replace(/[.]/g, "\\.");
178
+ return g.startsWith("*.") ? new RegExp(`^(?:[a-z0-9-]+\\.)+${esc(g.slice(2))}$`, "i") : new RegExp(`^${esc(g)}$`, "i");
179
+ }
180
+
181
+ export function hostAllowed(host: string, globs: string[]): boolean {
182
+ return globs.length === 0 || globs.some(g => domainGlobToRegExp(g).test(host));
183
+ }
184
+
185
+ // ---------- daemon side ----------
186
+
187
+ /** Parse what the daemon received; the scoped-listener check accepts exactly this shape. */
188
+ export function parseFillSecretWire(args: string[]): { ref: string; allowDomains: string[]; submit: boolean; totp: boolean } {
189
+ const [cmd, ref, ...rest] = args;
190
+ if (cmd !== "fill-secret" || !ref || ref.startsWith("-")) throw new Error("fill-secret needs a target ref");
191
+ const allowDomains: string[] = [];
192
+ let submit = false, totp = false;
193
+ for (const a of rest) {
194
+ if (a === "--submit") submit = true;
195
+ else if (a === "--totp") totp = true;
196
+ else if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
197
+ else throw new Error(`fill-secret: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
198
+ }
199
+ return { ref, allowDomains, submit, totp };
200
+ }
201
+
202
+ /**
203
+ * The run-code function the cliDaemon executes. The host check reads the target element's OWN
204
+ * document (so a cross-origin iframe ref is checked against the iframe, not the top page), and the
205
+ * fill goes through that same element handle: if the document navigates after the check, the
206
+ * handle is detached and the fill fails instead of landing on the new page. The value is embedded
207
+ * as a JSON string literal; the cliDaemon echoes this code back, which the daemon masks.
208
+ */
209
+ export function buildFillCode(ref: string, value: string, allowDomains: string[], submit: boolean): string {
210
+ const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
211
+ const locator = /^(f\d+)?e\d+$/.test(ref) ? `page.locator(${JSON.stringify(`aria-ref=${ref}`)})` : `page.locator(${JSON.stringify(ref)})`;
212
+ return `async page => {
213
+ const el = await ${locator}.elementHandle({ timeout: 10000 });
214
+ try {
215
+ const host = String(await el.evaluate(e => e.ownerDocument.location.hostname)).toLowerCase();
216
+ const allowed = ${JSON.stringify(patterns)};
217
+ if (allowed.length && !allowed.some(p => new RegExp(p, "i").test(host)))
218
+ throw new Error("fill-secret refused: the field's page host " + JSON.stringify(host) + " is not allowed by --allow-domain ${allowDomains.join(",").replace(/[^a-z0-9*.,-]/gi, "")}");
219
+ await el.fill(${JSON.stringify(value)}, { timeout: 10000 });
220
+ ${submit ? `await el.press("Enter", { timeout: 10000 });` : ""}
221
+ return "filled " + ${JSON.stringify(ref)} + " on " + host;
222
+ } finally {
223
+ await el.dispose().catch(() => {});
224
+ }
225
+ }`;
226
+ }
227
+
228
+ /**
229
+ * Values to mask, with expiry. Global, not per-session: masking a stray match is harmless.
230
+ * Passwords are masked for the daemon's lifetime (they stay in the field); TOTP codes for the
231
+ * default TTL (an expired code is worthless). A daemon restart forgets them.
232
+ */
233
+ export class SecretMasker {
234
+ private values = new Map<string, number>();
235
+ constructor(private ttlMs = SECRET_MASK_TTL_MS, private now = () => Date.now()) {}
236
+ add(value: string, ttlMs = this.ttlMs) { if (value) this.values.set(value, this.now() + ttlMs); }
237
+ get active(): boolean { this.prune(); return this.values.size > 0; }
238
+ mask(text: string): string {
239
+ if (!text) return text;
240
+ this.prune();
241
+ // Longest first, so a value containing another is masked whole.
242
+ for (const v of [...this.values.keys()].sort((a, b) => b.length - a.length)) {
243
+ text = text.replaceAll(v, SECRET_MASK);
244
+ // The cliDaemon echoes the value as a JS string literal; mask that spelling too.
245
+ const lit = JSON.stringify(v).slice(1, -1);
246
+ if (lit !== v) text = text.replaceAll(lit, SECRET_MASK);
247
+ }
248
+ return text;
249
+ }
250
+ private prune() { const t = this.now(); for (const [v, exp] of this.values) if (exp <= t) this.values.delete(v); }
251
+ }
252
+
253
+ /**
254
+ * cliDaemon sockets for exactly this session: `<root>/<16-hex workspaceHash>-<session>.sock`, or
255
+ * older `<session>.sock`. Never a plain suffix match: session `aaaa` must not match another
256
+ * client's `<hash>-bbbb-aaaa.sock` (its `-s=aaaa` sub-session).
257
+ */
258
+ export function sessionSocketCandidates(socketRoot: string, session: string): string[] {
259
+ let names: string[] = [];
260
+ try { names = readdirSync(socketRoot); } catch { return []; }
261
+ return names.filter(n => n === `${session}.sock` || (n.length === session.length + 22 && /^[0-9a-f]{16}-/.test(n) && n.slice(17) === `${session}.sock`)).map(n => join(socketRoot, n));
262
+ }
263
+
264
+ /**
265
+ * Read `socketPath` from `<baseDaemonDir>/<16-hex workspaceHash>/<session>.session` written by
266
+ * cliDaemon (including Windows named pipes `\\.\pipe\pw-...`, which do not appear in socketRoot).
267
+ */
268
+ export function daemonSessionSockets(session: string, env: Record<string, string | undefined> = process.env): string[] {
269
+ const home = env.HOME || env.USERPROFILE || homedir();
270
+ const bases = env.PLAYWRIGHT_DAEMON_SESSION_DIR
271
+ ? [env.PLAYWRIGHT_DAEMON_SESSION_DIR]
272
+ : process.platform === "win32"
273
+ ? [...new Set([
274
+ join(env.USERPROFILE || home, "AppData", "Local", "ms-playwright", "daemon"),
275
+ ...(env.LOCALAPPDATA ? [join(env.LOCALAPPDATA, "ms-playwright", "daemon")] : []),
276
+ ])]
277
+ : process.platform === "darwin"
278
+ ? [join(home, "Library", "Caches", "ms-playwright", "daemon")]
279
+ : [join(env.XDG_CACHE_HOME || join(home, ".cache"), "ms-playwright", "daemon")];
280
+ const out: string[] = [];
281
+ for (const base of bases) {
282
+ let hashes: string[] = [];
283
+ try { hashes = readdirSync(base); } catch { continue; }
284
+ for (const h of hashes) {
285
+ if (!/^[0-9a-f]{16}$/.test(h)) continue;
286
+ try {
287
+ const cfg = JSON.parse(readFileSync(join(base, h, `${session}.session`), "utf8"));
288
+ if ((cfg?.name ?? session) === session && typeof cfg?.socketPath === "string" && cfg.socketPath)
289
+ out.push(cfg.socketPath);
290
+ } catch {}
291
+ }
292
+ }
293
+ return [...new Set(out)];
294
+ }
295
+
296
+ /** One request/response over a cliDaemon socket (newline-delimited JSON). */
297
+ export function socketRequest(path: string, method: string, params: unknown, timeoutMs = 30_000): Promise<{ result?: any; error?: string }> {
298
+ return new Promise((resolve, reject) => {
299
+ const sock = connect(path);
300
+ let buf = "";
301
+ const timer = setTimeout(() => { sock.destroy(); reject(new Error("timeout")); }, timeoutMs);
302
+ sock.setEncoding("utf8"); // decode across chunk boundaries, not per chunk
303
+ sock.on("connect", () => sock.write(`${JSON.stringify({ id: 1, method, params })}\n`));
304
+ sock.on("data", (d) => {
305
+ buf += d;
306
+ const nl = buf.indexOf("\n");
307
+ if (nl < 0) return;
308
+ clearTimeout(timer);
309
+ sock.destroy();
310
+ try { resolve(JSON.parse(buf.slice(0, nl))); } catch { reject(new Error("unreadable cliDaemon response")); }
311
+ });
312
+ sock.on("error", (e) => { clearTimeout(timer); reject(e); });
313
+ sock.on("close", () => { clearTimeout(timer); reject(new Error("cliDaemon closed the connection")); });
314
+ });
315
+ }
316
+
317
+ /** Run a run-code function on the session's live browser over its cliDaemon socket (no child argv). */
318
+ export async function runCodeOnSession(opts: { socketRoot: string; session: string; cwd: string; code: string; json?: boolean }): Promise<{ isError: boolean; text: string }> {
319
+ const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
320
+ if (!candidates.length) throw new Error("no browser is open in this session; run `rech open <url>` first");
321
+ const errors: string[] = [];
322
+ for (const path of candidates) {
323
+ let reply: { result?: any; error?: string };
324
+ try {
325
+ reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...(opts.json ? { json: true } : {}) });
326
+ } catch (e) {
327
+ errors.push(e instanceof Error ? e.message : String(e)); // stale socket: try the next one
328
+ continue;
329
+ }
330
+ if (reply.error) throw new Error(reply.error);
331
+ const r = reply.result;
332
+ return { isError: !!r?.isError, text: typeof r === "string" ? r : typeof r?.text === "string" ? r.text : "" };
333
+ }
334
+ throw new Error(`could not reach this session's browser (${errors.join("; ")}); run \`rech open <url>\` first`);
335
+ }
336
+
337
+ /**
338
+ * Fill on the session's live browser. Returns the cliDaemon's reply (NOT yet masked — the
339
+ * caller masks it) or throws an error whose message never contains the value.
340
+ */
341
+ export function fillSecretOnSession(opts: { socketRoot: string; session: string; cwd: string; ref: string; value: string; allowDomains: string[]; submit: boolean }): Promise<{ isError: boolean; text: string }> {
342
+ return runCodeOnSession({ ...opts, code: buildFillCode(opts.ref, opts.value, opts.allowDomains, opts.submit) });
343
+ }
344
+
345
+ // ---------- password fields in snapshots ----------
346
+ //
347
+ // Snapshots print every input's value, and that includes password fields Chrome autofilled
348
+ // (or the user typed), which never went through fill-secret. Before a snapshot-bearing output
349
+ // leaves the daemon, read the live password values (all frames) and mask them like secrets.
350
+
351
+ // Password inputs, plus fields marked as passwords that a "show password" toggle turned into text.
352
+ const PASSWORD_FIELDS = 'input[type=password], input[autocomplete~="current-password" i], input[autocomplete~="new-password" i], input[name*="pass" i], input[id*="pass" i]';
353
+ const PASSWORD_VALUES_CODE = `async page => {
354
+ const values = [];
355
+ let failedFrames = 0;
356
+ for (const frame of page.frames()) {
357
+ try { values.push(...await frame.$$eval(${JSON.stringify(PASSWORD_FIELDS)}, els => els.map(e => e.value))); } catch { failedFrames++; }
358
+ }
359
+ return { values: values.filter(Boolean), failedFrames };
360
+ }`;
361
+
362
+ /**
363
+ * Current values of every password field on the session's page. Never logs them. Throws when
364
+ * coverage is incomplete (a frame could not be read), so the caller can fail closed.
365
+ */
366
+ export async function passwordValuesOnSession(opts: { socketRoot: string; session: string; cwd: string }): Promise<string[]> {
367
+ const { isError, text } = await runCodeOnSession({ ...opts, code: PASSWORD_VALUES_CODE, json: true });
368
+ if (isError) throw new Error("password scan failed");
369
+ const { values, failedFrames } = parseScanResult(text);
370
+ if (failedFrames) throw new Error(`password scan could not read ${failedFrames} frame(s)`);
371
+ return values;
372
+ }
373
+
374
+ /** The run-code result: `{"result": "{...}"}` in json mode, or a `### Result` section from older cliDaemons. */
375
+ export function parseScanResult(text: string): { values: string[]; failedFrames: number } {
376
+ let raw: unknown;
377
+ try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
378
+ let value: any = raw;
379
+ if (typeof raw === "string") { try { value = JSON.parse(raw); } catch { value = undefined; } }
380
+ if (!value || !Array.isArray(value.values) || typeof value.failedFrames !== "number") throw new Error("password scan returned no result");
381
+ return { values: value.values.filter((v: unknown): v is string => typeof v === "string" && v.length > 0), failedFrames: value.failedFrames };
382
+ }
383
+
384
+ /** Replace inline snapshot sections when they could not be checked for password values. */
385
+ export function withholdSnapshots(text: string, reason: string): string {
386
+ return text.replace(/### Snapshot\n[\s\S]*?(?=\n### |$)/g, `### Snapshot\n[rech] snapshot withheld: ${reason}. Retry the command.`);
387
+ }
388
+
389
+ /** True when a command's output carries a snapshot (inline or as a saved file). */
390
+ export const hasSnapshot = (text: string) => /### Snapshot|\.ya?ml\b/.test(text);
391
+
392
+ /**
393
+ * Fallback for when the live scan misses (the page moved on, the scan failed): blank the value
394
+ * of any snapshot textbox whose accessible name reads like a password field.
395
+ */
396
+ const PASSWORD_LINE = /^(\s*-\s*textbox\s+"[^"\n]*(?:password|passwd|passcode|\bpin\b|パスワード|暗証)[^"\n]*"[^\n:]*):[ \t]+\S.*$/gim;
397
+ export function maskPasswordLines(text: string): string {
398
+ return text.replace(PASSWORD_LINE, `$1: ${SECRET_MASK}`);
399
+ }
package/listeners.js CHANGED
@@ -94,7 +94,7 @@ async function writeListeners(config) {
94
94
  `, { mode: 384 });
95
95
  await rename(temp, LISTENERS_FILE);
96
96
  }
97
- 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"]);
97
+ 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"]);
98
98
  function authorizeProfileRequest(listener, body) {
99
99
  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"))
100
100
  throw new Error("Scoped requests require identity.key, identity.profile and string args");
@@ -106,7 +106,7 @@ function authorizeProfileRequest(listener, body) {
106
106
  const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
107
107
  if (!(args[0] === "upload" && listener.uploads !== undefined) && !SCOPED_COMMANDS.has(args[0]))
108
108
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
109
- if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page")))
109
+ 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] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
110
110
  throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
111
111
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
112
112
  throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
package/listeners.ts CHANGED
@@ -80,7 +80,7 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
80
80
 
81
81
  // Restricted listeners deliberately exclude arbitrary code, host filesystem access,
82
82
  // attachment/config overrides, and commands that manage all browser sessions.
83
- 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"]);
83
+ 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"]);
84
84
  export function authorizeProfileRequest(listener: Listener, body: any): string {
85
85
  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");
86
86
  const profile = body.identity.profile;
@@ -88,7 +88,7 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
88
88
  if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile) throw new Error("Profile override does not match request identity");
89
89
  const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
90
90
  if (!(args[0] === "upload" && listener.uploads !== undefined) && !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");
91
- 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"))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
91
+ 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] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
92
92
  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");
93
93
  // Upload is handled by the byte-only receiver; the caller cannot supply host paths.
94
94
  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.37.2",
3
+ "version": "1.38.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -21,6 +21,8 @@
21
21
  "daemon-manager.ts",
22
22
  "listeners.ts",
23
23
  "listeners.js",
24
+ "fill-secret.ts",
25
+ "fill-secret.js",
24
26
  "extension",
25
27
  "rechrome.js",
26
28
  "rechrome.ts",
@@ -47,7 +49,7 @@
47
49
  "vendor-cli": "bash scripts/vendor-cli.sh",
48
50
  "prepare": "bash scripts/vendor-cli.sh --prepare",
49
51
  "check-pack": "bash scripts/check-pack.sh",
50
- "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 scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bash scripts/check-pack.sh"
52
+ "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bash scripts/check-pack.sh"
51
53
  },
52
54
  "devDependencies": {
53
55
  "@types/bun": "latest",