rechrome 1.44.0 → 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/listeners.js +2 -0
- package/listeners.ts +2 -1
- package/package.json +1 -1
- package/rechrome.js +7 -4
- package/rechrome.ts +7 -4
- package/serve.js +63 -0
- package/serve.ts +63 -0
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/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");
|
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);
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -3408,7 +3408,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3408
3408
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3409
3409
|
}
|
|
3410
3410
|
|
|
3411
|
-
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> {
|
|
3412
3412
|
const config = await requireListeners();
|
|
3413
3413
|
let listener = config.listeners.find(l => l.name === name);
|
|
3414
3414
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3434,7 +3434,9 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3434
3434
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3435
3435
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3436
3436
|
}
|
|
3437
|
+
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3437
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`);
|
|
3438
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`);
|
|
3439
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`);
|
|
3440
3442
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
@@ -4520,7 +4522,7 @@ export type RechHandlers = {
|
|
|
4520
4522
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4521
4523
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4522
4524
|
rotateKey(name: string): Promise<void>;
|
|
4523
|
-
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>;
|
|
4524
4526
|
setup(opts: SetupOptions): Promise<void>;
|
|
4525
4527
|
tray(action?: string): Promise<void>;
|
|
4526
4528
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4676,9 +4678,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4676
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" })
|
|
4677
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" })
|
|
4678
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" })
|
|
4679
4682
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4680
|
-
.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,
|
|
4681
|
-
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 } : {}) }))
|
|
4682
4685
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4683
4686
|
a => handlers.listenerPort(a.name))
|
|
4684
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
|
@@ -3408,7 +3408,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3408
3408
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3409
3409
|
}
|
|
3410
3410
|
|
|
3411
|
-
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> {
|
|
3412
3412
|
const config = await requireListeners();
|
|
3413
3413
|
let listener = config.listeners.find(l => l.name === name);
|
|
3414
3414
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3434,7 +3434,9 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3434
3434
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3435
3435
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3436
3436
|
}
|
|
3437
|
+
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3437
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`);
|
|
3438
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`);
|
|
3439
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`);
|
|
3440
3442
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
@@ -4520,7 +4522,7 @@ export type RechHandlers = {
|
|
|
4520
4522
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4521
4523
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4522
4524
|
rotateKey(name: string): Promise<void>;
|
|
4523
|
-
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>;
|
|
4524
4526
|
setup(opts: SetupOptions): Promise<void>;
|
|
4525
4527
|
tray(action?: string): Promise<void>;
|
|
4526
4528
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4676,9 +4678,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4676
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" })
|
|
4677
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" })
|
|
4678
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" })
|
|
4679
4682
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4680
|
-
.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,
|
|
4681
|
-
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 } : {}) }))
|
|
4682
4685
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4683
4686
|
a => handlers.listenerPort(a.name))
|
|
4684
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
|
@@ -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
|
|
@@ -1122,6 +1161,7 @@ export async function serve() {
|
|
|
1122
1161
|
}
|
|
1123
1162
|
|
|
1124
1163
|
const commandStartedAt = Date.now();
|
|
1164
|
+
const runStartedAt = Date.now(); // downloads must be newer than this
|
|
1125
1165
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
1126
1166
|
cwd: runWorkDir,
|
|
1127
1167
|
stdin: "ignore",
|
|
@@ -1244,6 +1284,29 @@ export async function serve() {
|
|
|
1244
1284
|
}
|
|
1245
1285
|
}
|
|
1246
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
|
+
|
|
1247
1310
|
// A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
|
|
1248
1311
|
const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
|
|
1249
1312
|
// Snapshot files echo input values: scrub them on disk too, not only on download. Only
|
package/serve.ts
CHANGED
|
@@ -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
|
|
@@ -1122,6 +1161,7 @@ export async function serve() {
|
|
|
1122
1161
|
}
|
|
1123
1162
|
|
|
1124
1163
|
const commandStartedAt = Date.now();
|
|
1164
|
+
const runStartedAt = Date.now(); // downloads must be newer than this
|
|
1125
1165
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
1126
1166
|
cwd: runWorkDir,
|
|
1127
1167
|
stdin: "ignore",
|
|
@@ -1244,6 +1284,29 @@ export async function serve() {
|
|
|
1244
1284
|
}
|
|
1245
1285
|
}
|
|
1246
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
|
+
|
|
1247
1310
|
// A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
|
|
1248
1311
|
const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
|
|
1249
1312
|
// Snapshot files echo input values: scrub them on disk too, not only on download. Only
|