rechrome 1.43.2 → 1.45.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/README.md +17 -0
- package/fill-secret.js +124 -0
- package/fill-secret.ts +102 -0
- package/listeners.js +6 -2
- package/listeners.ts +6 -3
- package/package.json +1 -1
- package/rechrome.js +31 -6
- package/rechrome.ts +31 -6
- package/serve.js +80 -1
- package/serve.ts +80 -1
package/README.md
CHANGED
|
@@ -398,6 +398,23 @@ cp .env.example .env.local
|
|
|
398
398
|
>
|
|
399
399
|
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
400
400
|
|
|
401
|
+
### Downloading files
|
|
402
|
+
|
|
403
|
+
A file the page downloads (a link, an export button) goes to **your** machine. Any type
|
|
404
|
+
works. It lands in `<project>/.rechrome/output/` with the command's other files. The host
|
|
405
|
+
saves it in a private folder, hands it to the client once, and then deletes it. A download
|
|
406
|
+
that is never fetched is deleted after 10 minutes. Each download is logged on the host
|
|
407
|
+
with its name, size and sha256.
|
|
408
|
+
|
|
409
|
+
Keeping a copy on the host is opt-in, per listener:
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
rech listener set <listener> --keep-downloads # --no-keep-downloads: back to client-only
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
A download that finishes after its command has returned is reported, and handed over,
|
|
416
|
+
by the next command on that tab.
|
|
417
|
+
|
|
401
418
|
### Uploading files
|
|
402
419
|
|
|
403
420
|
`rech upload <file>` puts a local image into the page: a file picker, an
|
package/fill-secret.js
CHANGED
|
@@ -434,6 +434,123 @@ async function readSecretOnSession(opts) {
|
|
|
434
434
|
throw new Error("read-secret: the page returned no value");
|
|
435
435
|
return out;
|
|
436
436
|
}
|
|
437
|
+
var READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
|
|
438
|
+
Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
|
|
439
|
+
program that stores it (never a terminal or a file). Never logged; masked in later outputs.
|
|
440
|
+
<local|session> localStorage or sessionStorage
|
|
441
|
+
<key> the storage key, e.g. localConfig_v2
|
|
442
|
+
[json-path] optional: a path into the JSON value, e.g. teams.T0123.token or teams[T0123].token
|
|
443
|
+
--allow-domain GLOB required: the page's host must match, e.g. app.slack.com`;
|
|
444
|
+
function parseStoragePath(path) {
|
|
445
|
+
return path.match(/[^.[\]]+/g) ?? [];
|
|
446
|
+
}
|
|
447
|
+
function parseReadStorageArgs(args) {
|
|
448
|
+
const [cmd, ...rest] = args;
|
|
449
|
+
if (cmd !== "read-storage")
|
|
450
|
+
throw new Error("Expected read-storage");
|
|
451
|
+
const pos = [];
|
|
452
|
+
const allowDomains = [];
|
|
453
|
+
for (let i = 0;i < rest.length; i++) {
|
|
454
|
+
const a = rest[i];
|
|
455
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
456
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
457
|
+
if (!v)
|
|
458
|
+
throw new Error("--allow-domain got an empty host");
|
|
459
|
+
for (const d of v.split(",")) {
|
|
460
|
+
domainGlobToRegExp(d.trim());
|
|
461
|
+
allowDomains.push(d.trim());
|
|
462
|
+
}
|
|
463
|
+
} else if (a.startsWith("-"))
|
|
464
|
+
throw new Error(`read-storage: unknown option ${a}`);
|
|
465
|
+
else
|
|
466
|
+
pos.push(a);
|
|
467
|
+
}
|
|
468
|
+
if (pos.length < 2 || pos.length > 3)
|
|
469
|
+
throw new Error("read-storage needs <local|session> <key> [json-path]");
|
|
470
|
+
const [area, key, rawPath] = pos;
|
|
471
|
+
if (area !== "local" && area !== "session")
|
|
472
|
+
throw new Error("read-storage: first argument must be 'local' or 'session'");
|
|
473
|
+
if (!key)
|
|
474
|
+
throw new Error("read-storage: a storage key is required");
|
|
475
|
+
if (!allowDomains.length)
|
|
476
|
+
throw new Error("read-storage needs --allow-domain: name the site the value must come from");
|
|
477
|
+
return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains };
|
|
478
|
+
}
|
|
479
|
+
function readStorageWireArgs(req) {
|
|
480
|
+
return ["read-storage", req.area, req.key, ...req.path.length ? [req.path.join(".")] : [], ...req.allowDomains.map((d) => `--allow-domain=${d}`)];
|
|
481
|
+
}
|
|
482
|
+
function parseReadStorageWire(args) {
|
|
483
|
+
const [cmd, area, key, ...rest] = args;
|
|
484
|
+
if (cmd !== "read-storage" || area !== "local" && area !== "session" || !key || key.startsWith("-"))
|
|
485
|
+
throw new Error("read-storage needs <local|session> <key>");
|
|
486
|
+
const allowDomains = [];
|
|
487
|
+
let path = [];
|
|
488
|
+
for (const a of rest) {
|
|
489
|
+
if (a.startsWith("--allow-domain=")) {
|
|
490
|
+
const d = a.slice("--allow-domain=".length);
|
|
491
|
+
domainGlobToRegExp(d);
|
|
492
|
+
allowDomains.push(d);
|
|
493
|
+
} else if (!a.startsWith("-") && !path.length)
|
|
494
|
+
path = parseStoragePath(a);
|
|
495
|
+
else
|
|
496
|
+
throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
497
|
+
}
|
|
498
|
+
if (!allowDomains.length)
|
|
499
|
+
throw new Error("read-storage needs --allow-domain");
|
|
500
|
+
return { area, key, path, allowDomains };
|
|
501
|
+
}
|
|
502
|
+
function buildReadStorageCode(area, key, path, allowDomains) {
|
|
503
|
+
const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
|
|
504
|
+
const store = area === "local" ? "localStorage" : "sessionStorage";
|
|
505
|
+
return `async page => {
|
|
506
|
+
// One evaluate: the host checked is the origin read, with no navigation in between.
|
|
507
|
+
const out = await page.evaluate(({ key, path }) => {
|
|
508
|
+
const host = String(location.hostname).toLowerCase();
|
|
509
|
+
const raw = window.${store}.getItem(key);
|
|
510
|
+
if (raw === null) return { host, missing: true };
|
|
511
|
+
if (!path.length) return { host, value: raw };
|
|
512
|
+
let v;
|
|
513
|
+
try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
|
|
514
|
+
for (const seg of path) {
|
|
515
|
+
if (v == null || typeof v !== "object") return { host, nopath: true };
|
|
516
|
+
v = v[seg];
|
|
517
|
+
}
|
|
518
|
+
if (v === undefined) return { host, nopath: true };
|
|
519
|
+
return { host, value: typeof v === "string" ? v : JSON.stringify(v) };
|
|
520
|
+
}, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
|
|
521
|
+
const host = out.host;
|
|
522
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
523
|
+
if (!allowed.some(p => new RegExp(p, "i").test(host)))
|
|
524
|
+
throw new Error("read-storage refused: the page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
|
|
525
|
+
if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
|
|
526
|
+
if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
|
|
527
|
+
if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
|
|
528
|
+
return JSON.stringify({ host, value: out.value });
|
|
529
|
+
}`;
|
|
530
|
+
}
|
|
531
|
+
async function readStorageOnSession(opts) {
|
|
532
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
|
|
533
|
+
if (isError)
|
|
534
|
+
throw new Error(text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
|
|
535
|
+
`)[0] : "read-storage: could not read the value"));
|
|
536
|
+
let raw;
|
|
537
|
+
try {
|
|
538
|
+
raw = JSON.parse(text).result;
|
|
539
|
+
} catch {
|
|
540
|
+
raw = text.match(/### Result\n([^\n]*)/)?.[1];
|
|
541
|
+
}
|
|
542
|
+
let o = raw;
|
|
543
|
+
for (let i = 0;i < 2 && typeof o === "string"; i++) {
|
|
544
|
+
try {
|
|
545
|
+
o = JSON.parse(o);
|
|
546
|
+
} catch {
|
|
547
|
+
o = undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!o || typeof o.host !== "string" || typeof o.value !== "string")
|
|
551
|
+
throw new Error("read-storage: the page returned no value");
|
|
552
|
+
return o;
|
|
553
|
+
}
|
|
437
554
|
var READ_COOKIE_USAGE = `Usage: rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
438
555
|
Writes the cookie's value, raw and without a newline, to stdout, which must be a pipe into the
|
|
439
556
|
program that stores it (never a terminal or a file). Never logged; masked in later outputs.
|
|
@@ -590,6 +707,7 @@ export {
|
|
|
590
707
|
MIN_MASK_LENGTH,
|
|
591
708
|
READ_COOKIE_USAGE,
|
|
592
709
|
READ_SECRET_USAGE,
|
|
710
|
+
READ_STORAGE_USAGE,
|
|
593
711
|
SECRET_MASK,
|
|
594
712
|
SECRET_MASK_TTL_MS,
|
|
595
713
|
SecretMasker,
|
|
@@ -597,6 +715,7 @@ export {
|
|
|
597
715
|
buildFillCode,
|
|
598
716
|
buildReadCode,
|
|
599
717
|
buildReadCookieCode,
|
|
718
|
+
buildReadStorageCode,
|
|
600
719
|
daemonSessionSockets,
|
|
601
720
|
domainGlobToRegExp,
|
|
602
721
|
fillSecretOnSession,
|
|
@@ -612,13 +731,18 @@ export {
|
|
|
612
731
|
parseReadCookieWire,
|
|
613
732
|
parseReadSecretArgs,
|
|
614
733
|
parseReadSecretWire,
|
|
734
|
+
parseReadStorageArgs,
|
|
735
|
+
parseReadStorageWire,
|
|
615
736
|
parseScanResult,
|
|
737
|
+
parseStoragePath,
|
|
616
738
|
passwordValuesOnSession,
|
|
617
739
|
readCookieOnSession,
|
|
618
740
|
readCookieWireArgs,
|
|
619
741
|
readSecretOnSession,
|
|
620
742
|
readSecretSource,
|
|
621
743
|
readSecretWireArgs,
|
|
744
|
+
readStorageOnSession,
|
|
745
|
+
readStorageWireArgs,
|
|
622
746
|
runCodeOnSession,
|
|
623
747
|
sessionSocketCandidates,
|
|
624
748
|
socketRequest,
|
package/fill-secret.ts
CHANGED
|
@@ -443,6 +443,108 @@ export async function readSecretOnSession(opts: { socketRoot: string; session: s
|
|
|
443
443
|
return out;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// ---------- read-storage: a localStorage/sessionStorage value to a pipe ----------
|
|
447
|
+
//
|
|
448
|
+
// Same contract as read-secret: value only in `secretOut`, never logged, masked later, client
|
|
449
|
+
// pipe-only. For the live app token in storage (Slack's localConfig_v2 teams[T...].token). Reads
|
|
450
|
+
// via page-level evaluate, so it works over the extension bridge. localStorage is per-origin, so
|
|
451
|
+
// the session's page must be on the allowed host.
|
|
452
|
+
|
|
453
|
+
export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
|
|
454
|
+
Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
|
|
455
|
+
program that stores it (never a terminal or a file). Never logged; masked in later outputs.
|
|
456
|
+
<local|session> localStorage or sessionStorage
|
|
457
|
+
<key> the storage key, e.g. localConfig_v2
|
|
458
|
+
[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`;
|
|
460
|
+
|
|
461
|
+
/** Split "teams[T0123].token" / "teams.T0123.token" into ["teams","T0123","token"]. */
|
|
462
|
+
export function parseStoragePath(path: string): string[] {
|
|
463
|
+
return path.match(/[^.[\]]+/g) ?? [];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
|
|
467
|
+
const [cmd, ...rest] = args;
|
|
468
|
+
if (cmd !== "read-storage") throw new Error("Expected read-storage");
|
|
469
|
+
const pos: string[] = [];
|
|
470
|
+
const allowDomains: string[] = [];
|
|
471
|
+
for (let i = 0; i < rest.length; i++) {
|
|
472
|
+
const a = rest[i]!;
|
|
473
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
474
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
475
|
+
if (!v) throw new Error("--allow-domain got an empty host");
|
|
476
|
+
for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
|
|
477
|
+
} else if (a.startsWith("-")) throw new Error(`read-storage: unknown option ${a}`);
|
|
478
|
+
else pos.push(a);
|
|
479
|
+
}
|
|
480
|
+
if (pos.length < 2 || pos.length > 3) throw new Error("read-storage needs <local|session> <key> [json-path]");
|
|
481
|
+
const [area, key, rawPath] = pos;
|
|
482
|
+
if (area !== "local" && area !== "session") throw new Error("read-storage: first argument must be 'local' or 'session'");
|
|
483
|
+
if (!key) throw new Error("read-storage: a storage key is required");
|
|
484
|
+
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 };
|
|
486
|
+
}
|
|
487
|
+
|
|
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}`)];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
|
|
493
|
+
const [cmd, area, key, ...rest] = args;
|
|
494
|
+
if (cmd !== "read-storage" || (area !== "local" && area !== "session") || !key || key.startsWith("-")) throw new Error("read-storage needs <local|session> <key>");
|
|
495
|
+
const allowDomains: string[] = [];
|
|
496
|
+
let path: string[] = [];
|
|
497
|
+
for (const a of rest) {
|
|
498
|
+
if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
|
|
499
|
+
else if (!a.startsWith("-") && !path.length) path = parseStoragePath(a);
|
|
500
|
+
else throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
501
|
+
}
|
|
502
|
+
if (!allowDomains.length) throw new Error("read-storage needs --allow-domain");
|
|
503
|
+
return { area, key, path, allowDomains };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** run-code reading storage[key], optionally walking a JSON path; returns {host,value} JSON. */
|
|
507
|
+
export function buildReadStorageCode(area: "local" | "session", key: string, path: string[], allowDomains: string[]): string {
|
|
508
|
+
const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
|
|
509
|
+
const store = area === "local" ? "localStorage" : "sessionStorage";
|
|
510
|
+
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();
|
|
514
|
+
const raw = window.${store}.getItem(key);
|
|
515
|
+
if (raw === null) return { host, missing: true };
|
|
516
|
+
if (!path.length) return { host, value: raw };
|
|
517
|
+
let v;
|
|
518
|
+
try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
|
|
519
|
+
for (const seg of path) {
|
|
520
|
+
if (v == null || typeof v !== "object") return { host, nopath: true };
|
|
521
|
+
v = v[seg];
|
|
522
|
+
}
|
|
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");
|
|
530
|
+
if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
|
|
531
|
+
if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
|
|
532
|
+
if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
|
|
533
|
+
return JSON.stringify({ host, value: out.value });
|
|
534
|
+
}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
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;
|
|
546
|
+
}
|
|
547
|
+
|
|
446
548
|
// ---------- read-cookie: a cookie value (incl. HttpOnly) to a pipe ----------
|
|
447
549
|
//
|
|
448
550
|
// Same contract as read-secret: the value travels only in the response's `secretOut`, is never
|
package/listeners.js
CHANGED
|
@@ -80,6 +80,8 @@ function validateListeners(value) {
|
|
|
80
80
|
throw new Error("readSecrets must be true or false");
|
|
81
81
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
|
|
82
82
|
throw new Error("readCookies must be true or false");
|
|
83
|
+
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean")
|
|
84
|
+
throw new Error("keepDownloads must be true or false");
|
|
83
85
|
const address = listenerAddress(l);
|
|
84
86
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
|
|
85
87
|
throw new Error("Listener names, addresses, and credentials must be unique");
|
|
@@ -136,11 +138,13 @@ function authorizeProfileRequest(listener, body) {
|
|
|
136
138
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
137
139
|
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
138
140
|
throw new Error(`read-secret is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
141
|
+
if (args[0] === "read-storage" && listener.readSecrets !== true)
|
|
142
|
+
throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
139
143
|
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
140
144
|
throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
|
|
141
|
-
if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && !SCOPED_COMMANDS.has(args[0]))
|
|
145
|
+
if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && !SCOPED_COMMANDS.has(args[0]))
|
|
142
146
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
143
|
-
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="))))
|
|
147
|
+
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="))))
|
|
144
148
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
145
149
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
146
150
|
throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
package/listeners.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
7
7
|
|
|
8
8
|
// publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
|
|
9
|
-
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean };
|
|
9
|
+
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
10
|
// retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
|
|
11
11
|
// saved for a removed listener gets "connection refused", not some other listener's answer.
|
|
12
12
|
export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
|
|
@@ -63,6 +63,7 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
63
63
|
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
64
64
|
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
|
|
65
65
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
|
|
66
|
+
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean") throw new Error("keepDownloads must be true or false");
|
|
66
67
|
const address = listenerAddress(l);
|
|
67
68
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
68
69
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
@@ -111,10 +112,12 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
111
112
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
112
113
|
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
113
114
|
throw new Error(`read-secret is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
115
|
+
if (args[0] === "read-storage" && listener.readSecrets !== true)
|
|
116
|
+
throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
114
117
|
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
115
118
|
throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
|
|
116
|
-
if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && !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");
|
|
117
|
-
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=")))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
119
|
+
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");
|
|
120
|
+
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");
|
|
118
121
|
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");
|
|
119
122
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
120
123
|
if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -6,7 +6,7 @@ import { file } from "bun";
|
|
|
6
6
|
import yargs from "yargs";
|
|
7
7
|
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
8
8
|
import { redact } from "./redact.js";
|
|
9
|
-
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
|
|
9
|
+
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
|
|
10
10
|
import { createHash, randomBytes } from "crypto";
|
|
11
11
|
import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
12
12
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
@@ -2189,6 +2189,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2189
2189
|
console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
|
|
2190
2190
|
process.exit(0);
|
|
2191
2191
|
}
|
|
2192
|
+
if (args[cmdIdx] === "read-storage") {
|
|
2193
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_STORAGE_USAGE); process.exit(0); }
|
|
2194
|
+
let swire: string[];
|
|
2195
|
+
try {
|
|
2196
|
+
const out = fstatSync(1);
|
|
2197
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-storage writes the value to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2198
|
+
swire = readStorageWireArgs(parseReadStorageArgs(args.slice(cmdIdx)));
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_STORAGE_USAGE}`);
|
|
2201
|
+
process.exit(2);
|
|
2202
|
+
}
|
|
2203
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...swire], overrideEnv, identity);
|
|
2204
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2205
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-storage: this daemon is older than the client (no value returned)\n" : ""));
|
|
2206
|
+
process.exit(res.status || 1);
|
|
2207
|
+
}
|
|
2208
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2209
|
+
console.error(`[rech] read-storage: wrote ${res.secretOut.length} characters to stdout`);
|
|
2210
|
+
process.exit(0);
|
|
2211
|
+
}
|
|
2192
2212
|
if (args[cmdIdx] === "read-secret") {
|
|
2193
2213
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2194
2214
|
let wire: string[];
|
|
@@ -3388,7 +3408,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3388
3408
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3389
3409
|
}
|
|
3390
3410
|
|
|
3391
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void> {
|
|
3411
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
|
|
3392
3412
|
const config = await requireListeners();
|
|
3393
3413
|
let listener = config.listeners.find(l => l.name === name);
|
|
3394
3414
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3414,7 +3434,9 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3414
3434
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3415
3435
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3416
3436
|
}
|
|
3437
|
+
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3417
3438
|
await writeListeners(config);
|
|
3439
|
+
if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
|
|
3418
3440
|
if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
|
|
3419
3441
|
if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
|
|
3420
3442
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
@@ -4500,7 +4522,7 @@ export type RechHandlers = {
|
|
|
4500
4522
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4501
4523
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4502
4524
|
rotateKey(name: string): Promise<void>;
|
|
4503
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4525
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void>;
|
|
4504
4526
|
setup(opts: SetupOptions): Promise<void>;
|
|
4505
4527
|
tray(action?: string): Promise<void>;
|
|
4506
4528
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4537,6 +4559,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4537
4559
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4538
4560
|
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4539
4561
|
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4562
|
+
rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
|
|
4563
|
+
copy a localStorage/sessionStorage value into a pipe, never shown or logged
|
|
4540
4564
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4541
4565
|
rech --version rechrome's version
|
|
4542
4566
|
|
|
@@ -4652,11 +4676,12 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4652
4676
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4653
4677
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4654
4678
|
.option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
|
|
4655
|
-
.option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) into a pipe (rech read-secret): only for links you trust with those accounts. --no-read-secrets turns it off" })
|
|
4679
|
+
.option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
|
|
4656
4680
|
.option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
|
|
4681
|
+
.option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
|
|
4657
4682
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4658
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads,
|
|
4659
|
-
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}) }))
|
|
4683
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
|
|
4684
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
|
|
4660
4685
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4661
4686
|
a => handlers.listenerPort(a.name))
|
|
4662
4687
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/rechrome.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { file } from "bun";
|
|
|
6
6
|
import yargs from "yargs";
|
|
7
7
|
import { readExtensionTokenFromProfile } from "./extension-token.ts";
|
|
8
8
|
import { redact } from "./redact.ts";
|
|
9
|
-
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
|
|
9
|
+
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
|
|
10
10
|
import { createHash, randomBytes } from "crypto";
|
|
11
11
|
import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
12
12
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
@@ -2189,6 +2189,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2189
2189
|
console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
|
|
2190
2190
|
process.exit(0);
|
|
2191
2191
|
}
|
|
2192
|
+
if (args[cmdIdx] === "read-storage") {
|
|
2193
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_STORAGE_USAGE); process.exit(0); }
|
|
2194
|
+
let swire: string[];
|
|
2195
|
+
try {
|
|
2196
|
+
const out = fstatSync(1);
|
|
2197
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-storage writes the value to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2198
|
+
swire = readStorageWireArgs(parseReadStorageArgs(args.slice(cmdIdx)));
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_STORAGE_USAGE}`);
|
|
2201
|
+
process.exit(2);
|
|
2202
|
+
}
|
|
2203
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...swire], overrideEnv, identity);
|
|
2204
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2205
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-storage: this daemon is older than the client (no value returned)\n" : ""));
|
|
2206
|
+
process.exit(res.status || 1);
|
|
2207
|
+
}
|
|
2208
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2209
|
+
console.error(`[rech] read-storage: wrote ${res.secretOut.length} characters to stdout`);
|
|
2210
|
+
process.exit(0);
|
|
2211
|
+
}
|
|
2192
2212
|
if (args[cmdIdx] === "read-secret") {
|
|
2193
2213
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2194
2214
|
let wire: string[];
|
|
@@ -3388,7 +3408,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3388
3408
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3389
3409
|
}
|
|
3390
3410
|
|
|
3391
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void> {
|
|
3411
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
|
|
3392
3412
|
const config = await requireListeners();
|
|
3393
3413
|
let listener = config.listeners.find(l => l.name === name);
|
|
3394
3414
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3414,7 +3434,9 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3414
3434
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3415
3435
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3416
3436
|
}
|
|
3437
|
+
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3417
3438
|
await writeListeners(config);
|
|
3439
|
+
if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
|
|
3418
3440
|
if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
|
|
3419
3441
|
if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
|
|
3420
3442
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
@@ -4500,7 +4522,7 @@ export type RechHandlers = {
|
|
|
4500
4522
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4501
4523
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4502
4524
|
rotateKey(name: string): Promise<void>;
|
|
4503
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4525
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void>;
|
|
4504
4526
|
setup(opts: SetupOptions): Promise<void>;
|
|
4505
4527
|
tray(action?: string): Promise<void>;
|
|
4506
4528
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4537,6 +4559,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4537
4559
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4538
4560
|
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4539
4561
|
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4562
|
+
rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
|
|
4563
|
+
copy a localStorage/sessionStorage value into a pipe, never shown or logged
|
|
4540
4564
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4541
4565
|
rech --version rechrome's version
|
|
4542
4566
|
|
|
@@ -4652,11 +4676,12 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4652
4676
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4653
4677
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4654
4678
|
.option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
|
|
4655
|
-
.option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) into a pipe (rech read-secret): only for links you trust with those accounts. --no-read-secrets turns it off" })
|
|
4679
|
+
.option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
|
|
4656
4680
|
.option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
|
|
4681
|
+
.option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
|
|
4657
4682
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4658
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads,
|
|
4659
|
-
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}) }))
|
|
4683
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
|
|
4684
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
|
|
4660
4685
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4661
4686
|
a => handlers.listenerPort(a.name))
|
|
4662
4687
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/serve.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.js";
|
|
27
|
-
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
27
|
+
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
28
28
|
|
|
29
29
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
30
30
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -100,6 +100,32 @@ const runsInFlight = new Map<Promise<void>, string | undefined>();
|
|
|
100
100
|
const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
|
|
101
101
|
// Artifacts that could not be checked AND could not be deleted: never served.
|
|
102
102
|
const deniedFiles = new Set<string>();
|
|
103
|
+
// Browser downloads go to the client: a download waits here (real path → cleanup timer) until the
|
|
104
|
+
// client fetches it once, then the host copy is deleted. A listener with keepDownloads: true
|
|
105
|
+
// keeps them on the host instead (opt-in).
|
|
106
|
+
const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
|
+
const DOWNLOAD_PICKUP_MS = 10 * 60_000;
|
|
108
|
+
// playwright-cli's line for a finished download (tools/backend/response.js).
|
|
109
|
+
const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
|
|
110
|
+
// Where the CLI saves downloads: its output dir under the session's cwd (context.js outputDir).
|
|
111
|
+
const CLI_OUTPUT_DIRS = new Set([".playwright-cli", ".playwright-mcp"]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The downloads the CLI itself reported: `- Downloaded file …` lines inside its own `### Events`
|
|
115
|
+
* section only, with paths in its output dir. The rest of the output carries page-controlled text
|
|
116
|
+
* (snapshots, console messages, eval results), and a page that prints a look-alike line must not
|
|
117
|
+
* be able to make the host forward and delete a file of its choosing.
|
|
118
|
+
*/
|
|
119
|
+
export function reportedDownloads(output: string): { name: string; rel: string }[] {
|
|
120
|
+
const out: { name: string; rel: string }[] = [];
|
|
121
|
+
for (const m of output.matchAll(/^### Events\n([\s\S]*?)(?=^### |(?![\s\S]))/gm)) {
|
|
122
|
+
for (const [, name, rel] of m[1]!.matchAll(DOWNLOADED_LINE)) {
|
|
123
|
+
const first = rel!.replaceAll("\\", "/").split("/")[0]!;
|
|
124
|
+
if (CLI_OUTPUT_DIRS.has(first)) out.push({ name: name!, rel: rel! });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
103
129
|
function noteSession(sess: string, now: number): void {
|
|
104
130
|
recentSessions.set(sess, now);
|
|
105
131
|
if (recentSessions.size > 64) {
|
|
@@ -752,6 +778,19 @@ export async function serve() {
|
|
|
752
778
|
const real = realpathSync(resolved);
|
|
753
779
|
if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
754
780
|
if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
|
|
781
|
+
const pickup = pendingDownloads.get(real);
|
|
782
|
+
if (pickup) {
|
|
783
|
+
// Handed over once, then gone from the host.
|
|
784
|
+
clearTimeout(pickup);
|
|
785
|
+
pendingDownloads.delete(real);
|
|
786
|
+
// Streamed, then deleted once fully sent (or when the client hangs up).
|
|
787
|
+
const done = () => { try { unlinkSync(real); } catch { /* already gone */ } };
|
|
788
|
+
const reader = file(real).stream().getReader();
|
|
789
|
+
return new Response(new ReadableStream({
|
|
790
|
+
async pull(c) { const { done: end, value } = await reader.read(); if (end) { c.close(); done(); } else c.enqueue(value); },
|
|
791
|
+
cancel() { void reader.cancel(); done(); },
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
755
794
|
if (TEXT_OUTPUT.test(real)) {
|
|
756
795
|
const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
|
|
757
796
|
for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
|
|
@@ -961,6 +1000,22 @@ export async function serve() {
|
|
|
961
1000
|
}
|
|
962
1001
|
}
|
|
963
1002
|
|
|
1003
|
+
// read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
|
|
1004
|
+
if (filteredArgs[0] === "read-storage") {
|
|
1005
|
+
try {
|
|
1006
|
+
const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
|
|
1007
|
+
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
|
|
1008
|
+
if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
|
|
1009
|
+
secretMasker.add(value, Infinity);
|
|
1010
|
+
log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
|
|
1011
|
+
return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
|
|
1014
|
+
log(`exit: 1 | read-storage: ${message.split("\n")[0]}`);
|
|
1015
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
964
1019
|
// read-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.js.
|
|
965
1020
|
if (filteredArgs[0] === "read-cookie") {
|
|
966
1021
|
try {
|
|
@@ -1106,6 +1161,7 @@ export async function serve() {
|
|
|
1106
1161
|
}
|
|
1107
1162
|
|
|
1108
1163
|
const commandStartedAt = Date.now();
|
|
1164
|
+
const runStartedAt = Date.now(); // downloads must be newer than this
|
|
1109
1165
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
1110
1166
|
cwd: runWorkDir,
|
|
1111
1167
|
stdin: "ignore",
|
|
@@ -1228,6 +1284,29 @@ export async function serve() {
|
|
|
1228
1284
|
}
|
|
1229
1285
|
}
|
|
1230
1286
|
|
|
1287
|
+
// Browser downloads, whatever their type: forwarded to the client and logged.
|
|
1288
|
+
for (const { name, rel } of reportedDownloads(rawOutput)) {
|
|
1289
|
+
if (!isUnderDir(runWorkDir, rel)) continue;
|
|
1290
|
+
const path = resolve(runWorkDir, rel);
|
|
1291
|
+
if (!existsSync(path)) continue;
|
|
1292
|
+
// Only a regular file really inside the work dir: never follow a link to delete elsewhere.
|
|
1293
|
+
const real = realpathSync(path);
|
|
1294
|
+
if (!isUnderDir(realpathSync(runWorkDir), real) || !lstatSync(real).isFile()) continue;
|
|
1295
|
+
// Written during this command: an older file named by a replayed line is left alone.
|
|
1296
|
+
if (statSync(real).mtimeMs < runStartedAt - 2000) continue;
|
|
1297
|
+
const f = relative(runWorkDir, path).replaceAll("\\", "/");
|
|
1298
|
+
if (!outputFiles.includes(f)) outputFiles.push(f);
|
|
1299
|
+
const hash = createHash("sha256");
|
|
1300
|
+
let size = 0;
|
|
1301
|
+
for await (const chunk of file(real).stream()) { hash.update(chunk); size += chunk.byteLength; }
|
|
1302
|
+
const keep = listener.keepDownloads === true;
|
|
1303
|
+
log(`download: ${JSON.stringify({ listener: listener.name, name: name.slice(0, 256), size, sha256: hash.digest("hex"), kept: keep })}`);
|
|
1304
|
+
if (keep) continue;
|
|
1305
|
+
clearTimeout(pendingDownloads.get(real));
|
|
1306
|
+
// Never fetched (the client went away): delete it anyway.
|
|
1307
|
+
pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1231
1310
|
// A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
|
|
1232
1311
|
const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
|
|
1233
1312
|
// Snapshot files echo input values: scrub them on disk too, not only on download. Only
|
package/serve.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.ts";
|
|
27
|
-
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
27
|
+
import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
28
28
|
|
|
29
29
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
30
30
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -100,6 +100,32 @@ const runsInFlight = new Map<Promise<void>, string | undefined>();
|
|
|
100
100
|
const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
|
|
101
101
|
// Artifacts that could not be checked AND could not be deleted: never served.
|
|
102
102
|
const deniedFiles = new Set<string>();
|
|
103
|
+
// Browser downloads go to the client: a download waits here (real path → cleanup timer) until the
|
|
104
|
+
// client fetches it once, then the host copy is deleted. A listener with keepDownloads: true
|
|
105
|
+
// keeps them on the host instead (opt-in).
|
|
106
|
+
const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
|
+
const DOWNLOAD_PICKUP_MS = 10 * 60_000;
|
|
108
|
+
// playwright-cli's line for a finished download (tools/backend/response.ts).
|
|
109
|
+
const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
|
|
110
|
+
// Where the CLI saves downloads: its output dir under the session's cwd (context.ts outputDir).
|
|
111
|
+
const CLI_OUTPUT_DIRS = new Set([".playwright-cli", ".playwright-mcp"]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The downloads the CLI itself reported: `- Downloaded file …` lines inside its own `### Events`
|
|
115
|
+
* section only, with paths in its output dir. The rest of the output carries page-controlled text
|
|
116
|
+
* (snapshots, console messages, eval results), and a page that prints a look-alike line must not
|
|
117
|
+
* be able to make the host forward and delete a file of its choosing.
|
|
118
|
+
*/
|
|
119
|
+
export function reportedDownloads(output: string): { name: string; rel: string }[] {
|
|
120
|
+
const out: { name: string; rel: string }[] = [];
|
|
121
|
+
for (const m of output.matchAll(/^### Events\n([\s\S]*?)(?=^### |(?![\s\S]))/gm)) {
|
|
122
|
+
for (const [, name, rel] of m[1]!.matchAll(DOWNLOADED_LINE)) {
|
|
123
|
+
const first = rel!.replaceAll("\\", "/").split("/")[0]!;
|
|
124
|
+
if (CLI_OUTPUT_DIRS.has(first)) out.push({ name: name!, rel: rel! });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
103
129
|
function noteSession(sess: string, now: number): void {
|
|
104
130
|
recentSessions.set(sess, now);
|
|
105
131
|
if (recentSessions.size > 64) {
|
|
@@ -752,6 +778,19 @@ export async function serve() {
|
|
|
752
778
|
const real = realpathSync(resolved);
|
|
753
779
|
if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
754
780
|
if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
|
|
781
|
+
const pickup = pendingDownloads.get(real);
|
|
782
|
+
if (pickup) {
|
|
783
|
+
// Handed over once, then gone from the host.
|
|
784
|
+
clearTimeout(pickup);
|
|
785
|
+
pendingDownloads.delete(real);
|
|
786
|
+
// Streamed, then deleted once fully sent (or when the client hangs up).
|
|
787
|
+
const done = () => { try { unlinkSync(real); } catch { /* already gone */ } };
|
|
788
|
+
const reader = file(real).stream().getReader();
|
|
789
|
+
return new Response(new ReadableStream({
|
|
790
|
+
async pull(c) { const { done: end, value } = await reader.read(); if (end) { c.close(); done(); } else c.enqueue(value); },
|
|
791
|
+
cancel() { void reader.cancel(); done(); },
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
755
794
|
if (TEXT_OUTPUT.test(real)) {
|
|
756
795
|
const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
|
|
757
796
|
for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
|
|
@@ -961,6 +1000,22 @@ export async function serve() {
|
|
|
961
1000
|
}
|
|
962
1001
|
}
|
|
963
1002
|
|
|
1003
|
+
// read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
|
|
1004
|
+
if (filteredArgs[0] === "read-storage") {
|
|
1005
|
+
try {
|
|
1006
|
+
const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
|
|
1007
|
+
const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
|
|
1008
|
+
if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
|
|
1009
|
+
secretMasker.add(value, Infinity);
|
|
1010
|
+
log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
|
|
1011
|
+
return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
|
|
1014
|
+
log(`exit: 1 | read-storage: ${message.split("\n")[0]}`);
|
|
1015
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
964
1019
|
// read-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.ts.
|
|
965
1020
|
if (filteredArgs[0] === "read-cookie") {
|
|
966
1021
|
try {
|
|
@@ -1106,6 +1161,7 @@ export async function serve() {
|
|
|
1106
1161
|
}
|
|
1107
1162
|
|
|
1108
1163
|
const commandStartedAt = Date.now();
|
|
1164
|
+
const runStartedAt = Date.now(); // downloads must be newer than this
|
|
1109
1165
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
1110
1166
|
cwd: runWorkDir,
|
|
1111
1167
|
stdin: "ignore",
|
|
@@ -1228,6 +1284,29 @@ export async function serve() {
|
|
|
1228
1284
|
}
|
|
1229
1285
|
}
|
|
1230
1286
|
|
|
1287
|
+
// Browser downloads, whatever their type: forwarded to the client and logged.
|
|
1288
|
+
for (const { name, rel } of reportedDownloads(rawOutput)) {
|
|
1289
|
+
if (!isUnderDir(runWorkDir, rel)) continue;
|
|
1290
|
+
const path = resolve(runWorkDir, rel);
|
|
1291
|
+
if (!existsSync(path)) continue;
|
|
1292
|
+
// Only a regular file really inside the work dir: never follow a link to delete elsewhere.
|
|
1293
|
+
const real = realpathSync(path);
|
|
1294
|
+
if (!isUnderDir(realpathSync(runWorkDir), real) || !lstatSync(real).isFile()) continue;
|
|
1295
|
+
// Written during this command: an older file named by a replayed line is left alone.
|
|
1296
|
+
if (statSync(real).mtimeMs < runStartedAt - 2000) continue;
|
|
1297
|
+
const f = relative(runWorkDir, path).replaceAll("\\", "/");
|
|
1298
|
+
if (!outputFiles.includes(f)) outputFiles.push(f);
|
|
1299
|
+
const hash = createHash("sha256");
|
|
1300
|
+
let size = 0;
|
|
1301
|
+
for await (const chunk of file(real).stream()) { hash.update(chunk); size += chunk.byteLength; }
|
|
1302
|
+
const keep = listener.keepDownloads === true;
|
|
1303
|
+
log(`download: ${JSON.stringify({ listener: listener.name, name: name.slice(0, 256), size, sha256: hash.digest("hex"), kept: keep })}`);
|
|
1304
|
+
if (keep) continue;
|
|
1305
|
+
clearTimeout(pendingDownloads.get(real));
|
|
1306
|
+
// Never fetched (the client went away): delete it anyway.
|
|
1307
|
+
pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1231
1310
|
// A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
|
|
1232
1311
|
const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
|
|
1233
1312
|
// Snapshot files echo input values: scrub them on disk too, not only on download. Only
|