rechrome 1.24.1 → 1.26.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.
@@ -0,0 +1,81 @@
1
+ Third-party software bundled in the rechrome extension
2
+
3
+ Playwright extension: Apache-2.0; see LICENSE and NOTICE.
4
+
5
+
6
+ react 19.2.1 (MIT)
7
+ https://github.com/facebook/react
8
+
9
+ MIT License
10
+
11
+ Copyright (c) Meta Platforms, Inc. and affiliates.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+
32
+ react-dom 19.2.1 (MIT)
33
+ https://github.com/facebook/react
34
+
35
+ MIT License
36
+
37
+ Copyright (c) Meta Platforms, Inc. and affiliates.
38
+
39
+ Permission is hereby granted, free of charge, to any person obtaining a copy
40
+ of this software and associated documentation files (the "Software"), to deal
41
+ in the Software without restriction, including without limitation the rights
42
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
43
+ copies of the Software, and to permit persons to whom the Software is
44
+ furnished to do so, subject to the following conditions:
45
+
46
+ The above copyright notice and this permission notice shall be included in all
47
+ copies or substantial portions of the Software.
48
+
49
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
50
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
51
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
52
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
53
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
54
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
55
+ SOFTWARE.
56
+
57
+
58
+ scheduler 0.27.0 (MIT)
59
+ https://github.com/facebook/react
60
+
61
+ MIT License
62
+
63
+ Copyright (c) Meta Platforms, Inc. and affiliates.
64
+
65
+ Permission is hereby granted, free of charge, to any person obtaining a copy
66
+ of this software and associated documentation files (the "Software"), to deal
67
+ in the Software without restriction, including without limitation the rights
68
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
69
+ copies of the Software, and to permit persons to whom the Software is
70
+ furnished to do so, subject to the following conditions:
71
+
72
+ The above copyright notice and this permission notice shall be included in all
73
+ copies or substantial portions of the Software.
74
+
75
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
76
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
77
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
78
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
79
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
80
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
81
+ SOFTWARE.
@@ -1,6 +1,9 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+ /*! Rechrome fork: modified by rechrome contributors (2026).
5
+ * Playwright: Copyright (c) Microsoft Corporation; Apache-2.0.
6
+ * See LICENSE, NOTICE, MODIFICATIONS.md and THIRD_PARTY_NOTICES.txt. */
4
7
  class ProtocolV1Handler {
5
8
  constructor(context) {
6
9
  __publicField(this, "_context");
@@ -1,3 +1,6 @@
1
+ /*! Rechrome fork: modified by rechrome contributors (2026).
2
+ * Playwright: Copyright (c) Microsoft Corporation; Apache-2.0.
3
+ * See LICENSE, NOTICE, MODIFICATIONS.md and THIRD_PARTY_NOTICES.txt. */
1
4
  (function polyfill() {
2
5
  const relList = document.createElement("link").relList;
3
6
  if (relList && relList.supports && relList.supports("modulepreload")) {
@@ -1,3 +1,6 @@
1
+ /*! Rechrome fork: modified by rechrome contributors (2026).
2
+ * Playwright: Copyright (c) Microsoft Corporation; Apache-2.0.
3
+ * See LICENSE, NOTICE, MODIFICATIONS.md and THIRD_PARTY_NOTICES.txt. */
1
4
  import { c as clientExports, j as jsxRuntimeExports, r as reactExports, A as AuthTokenSection, T as TabItem, B as Button, g as getOrCreateAuthToken } from "./authToken.js";
2
5
  const SUPPORTED_PROTOCOL_VERSION = 2;
3
6
  const BACKGROUND_RESPONSE_TIMEOUT_MS = 1e4;
@@ -1,3 +1,6 @@
1
+ /*! Rechrome fork: modified by rechrome contributors (2026).
2
+ * Playwright: Copyright (c) Microsoft Corporation; Apache-2.0.
3
+ * See LICENSE, NOTICE, MODIFICATIONS.md and THIRD_PARTY_NOTICES.txt. */
1
4
  import { c as clientExports, j as jsxRuntimeExports, r as reactExports, B as Button, T as TabItem, A as AuthTokenSection } from "./authToken.js";
2
5
  const StatusApp = () => {
3
6
  const [connectedTabs, setConnectedTabs] = reactExports.useState([]);
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Playwright Extension",
4
4
  "version": "0.2.2",
5
- "description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.",
5
+ "description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation. Modified by rechrome contributors.",
6
6
  "minimum_chrome_version": "116",
7
7
  "permissions": [
8
8
  "debugger",
@@ -0,0 +1,177 @@
1
+ // @bun
2
+ // extension-token.ts
3
+ import { readFileSync, readdirSync } from "fs";
4
+ import { join } from "path";
5
+ function varint(b, cursor) {
6
+ let value = 0;
7
+ for (let shift = 0;shift < 56; shift += 7) {
8
+ if (cursor.p >= b.length)
9
+ throw new Error("Truncated varint");
10
+ const byte = b[cursor.p++];
11
+ value += (byte & 127) * 2 ** shift;
12
+ if (!(byte & 128))
13
+ return value;
14
+ }
15
+ throw new Error("Invalid varint");
16
+ }
17
+ function decodeSnappy(b) {
18
+ const c = { p: 0 };
19
+ const size = varint(b, c);
20
+ if (size > 64 * 1024 * 1024)
21
+ throw new Error("Oversized block");
22
+ const out = Buffer.alloc(size);
23
+ let p = 0;
24
+ while (c.p < b.length) {
25
+ const tag = b[c.p++], type = tag & 3;
26
+ let length, offset = 0;
27
+ if (type === 0) {
28
+ length = tag >>> 2;
29
+ if (length >= 60) {
30
+ const bytes = length - 59;
31
+ if (c.p + bytes > b.length)
32
+ throw new Error("Truncated literal");
33
+ length = b.readUIntLE(c.p, bytes);
34
+ c.p += bytes;
35
+ }
36
+ length++;
37
+ if (c.p + length > b.length || p + length > size)
38
+ throw new Error("Invalid literal");
39
+ b.copy(out, p, c.p, c.p + length);
40
+ c.p += length;
41
+ p += length;
42
+ } else {
43
+ length = type === 1 ? 4 + (tag >>> 2 & 7) : 1 + (tag >>> 2);
44
+ const bytes = type === 1 ? 1 : type === 2 ? 2 : 4;
45
+ if (c.p + bytes > b.length)
46
+ throw new Error("Truncated copy");
47
+ offset = b.readUIntLE(c.p, bytes) + (type === 1 ? (tag & 224) * 8 : 0);
48
+ c.p += bytes;
49
+ if (!offset || offset > p || p + length > size)
50
+ throw new Error("Invalid copy");
51
+ for (let i = 0;i < length; i++) {
52
+ out[p] = out[p - offset];
53
+ p++;
54
+ }
55
+ }
56
+ }
57
+ if (p !== size)
58
+ throw new Error("Truncated block");
59
+ return out;
60
+ }
61
+ function* entries(b) {
62
+ const restarts = b.readUInt32LE(b.length - 4);
63
+ const end = b.length - 4 - restarts * 4;
64
+ const c = { p: 0 };
65
+ let key = Buffer.alloc(0);
66
+ while (c.p < end) {
67
+ const shared = varint(b, c), extra = varint(b, c), length = varint(b, c);
68
+ if (shared > key.length || c.p + extra + length > end)
69
+ throw new Error("Invalid entry");
70
+ key = Buffer.concat([key.subarray(0, shared), b.subarray(c.p, c.p + extra)]);
71
+ c.p += extra;
72
+ const value = b.subarray(c.p, c.p + length);
73
+ c.p += length;
74
+ yield [key, value];
75
+ }
76
+ }
77
+ function block(file, handle) {
78
+ const c = { p: 0 }, offset = varint(handle, c), length = varint(handle, c);
79
+ if (offset + length + 5 > file.length)
80
+ throw new Error("Invalid block handle");
81
+ const bytes = file.subarray(offset, offset + length), compression = file[offset + length];
82
+ if (compression === 0)
83
+ return bytes;
84
+ if (compression === 1)
85
+ return decodeSnappy(bytes);
86
+ throw new Error("Unsupported compression");
87
+ }
88
+ function* logRecords(b) {
89
+ let parts = [];
90
+ for (let base = 0;base < b.length; base += 32768) {
91
+ const end = Math.min(base + 32768, b.length);
92
+ for (let p = base;p + 7 <= end; ) {
93
+ const length = b.readUInt16LE(p + 4), type = b[p + 6];
94
+ p += 7;
95
+ if (!type || p + length > end)
96
+ break;
97
+ const data = b.subarray(p, p + length);
98
+ p += length;
99
+ if (type === 1) {
100
+ parts = [];
101
+ yield data;
102
+ } else if (type === 2)
103
+ parts = [data];
104
+ else if (type === 3 && parts.length)
105
+ parts.push(data);
106
+ else if (type === 4 && parts.length) {
107
+ parts.push(data);
108
+ yield Buffer.concat(parts);
109
+ parts = [];
110
+ }
111
+ }
112
+ }
113
+ }
114
+ function readExtensionTokenFromProfile(userDataDir, profileDir, extensionId) {
115
+ const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
116
+ const wanted = Buffer.from(`_chrome-extension://${extensionId}\x00\x01auth-token`, "latin1");
117
+ let newest = -1n, token = null;
118
+ const accept = (key, value, seq, type) => {
119
+ if (!key.equals(wanted) || seq <= newest)
120
+ return;
121
+ newest = seq;
122
+ token = type === 1 && /^\x01[A-Za-z0-9_-]{43}$/.test(value.toString("latin1")) ? value.subarray(1).toString("latin1") : null;
123
+ };
124
+ try {
125
+ for (const name of readdirSync(dir)) {
126
+ if (!/\.(ldb|sst|log)$/.test(name))
127
+ continue;
128
+ let b;
129
+ try {
130
+ b = readFileSync(join(dir, name));
131
+ } catch {
132
+ continue;
133
+ }
134
+ if (name.endsWith(".log")) {
135
+ for (const record of logRecords(b)) {
136
+ if (record.length < 12)
137
+ continue;
138
+ const seq = record.readBigUInt64LE(0), count = record.readUInt32LE(8), c = { p: 12 };
139
+ for (let i = 0;i < count; i++) {
140
+ const type = record[c.p++];
141
+ if (type !== 0 && type !== 1)
142
+ throw new Error("Invalid write batch");
143
+ const keyLen = varint(record, c), key = record.subarray(c.p, c.p + keyLen);
144
+ c.p += keyLen;
145
+ const valueLen = type === 1 ? varint(record, c) : 0;
146
+ if (c.p + valueLen > record.length)
147
+ throw new Error("Truncated batch");
148
+ const value = record.subarray(c.p, c.p + valueLen);
149
+ c.p += valueLen;
150
+ accept(key, value, seq + BigInt(i), type);
151
+ }
152
+ }
153
+ } else {
154
+ if (b.length < 48 || b.readBigUInt64LE(b.length - 8) !== 0xdb4775248b80fb57n)
155
+ throw new Error("Invalid table");
156
+ const footer = b.subarray(b.length - 48), c = { p: 0 };
157
+ varint(footer, c);
158
+ varint(footer, c);
159
+ for (const [, handle] of entries(block(b, footer.subarray(c.p)))) {
160
+ for (const [key, value] of entries(block(b, handle))) {
161
+ if (key.length < 8)
162
+ throw new Error("Invalid internal key");
163
+ const tag = key.readBigUInt64LE(key.length - 8);
164
+ accept(key.subarray(0, -8), value, tag >> 8n, Number(tag & 255n));
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return token;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ export {
175
+ decodeSnappy,
176
+ readExtensionTokenFromProfile
177
+ };
@@ -0,0 +1,133 @@
1
+ import { readFileSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ function varint(b: Buffer, cursor: { p: number }): number {
5
+ let value = 0;
6
+ for (let shift = 0; shift < 56; shift += 7) {
7
+ if (cursor.p >= b.length) throw new Error("Truncated varint");
8
+ const byte = b[cursor.p++];
9
+ value += (byte & 127) * 2 ** shift;
10
+ if (!(byte & 128)) return value;
11
+ }
12
+ throw new Error("Invalid varint");
13
+ }
14
+
15
+ export function decodeSnappy(b: Buffer): Buffer {
16
+ const c = { p: 0 };
17
+ const size = varint(b, c);
18
+ if (size > 64 * 1024 * 1024) throw new Error("Oversized block");
19
+ const out = Buffer.alloc(size);
20
+ let p = 0;
21
+ while (c.p < b.length) {
22
+ const tag = b[c.p++], type = tag & 3;
23
+ let length: number, offset = 0;
24
+ if (type === 0) {
25
+ length = tag >>> 2;
26
+ if (length >= 60) {
27
+ const bytes = length - 59;
28
+ if (c.p + bytes > b.length) throw new Error("Truncated literal");
29
+ length = b.readUIntLE(c.p, bytes); c.p += bytes;
30
+ }
31
+ length++;
32
+ if (c.p + length > b.length || p + length > size) throw new Error("Invalid literal");
33
+ b.copy(out, p, c.p, c.p + length); c.p += length; p += length;
34
+ } else {
35
+ length = type === 1 ? 4 + ((tag >>> 2) & 7) : 1 + (tag >>> 2);
36
+ const bytes = type === 1 ? 1 : type === 2 ? 2 : 4;
37
+ if (c.p + bytes > b.length) throw new Error("Truncated copy");
38
+ offset = b.readUIntLE(c.p, bytes) + (type === 1 ? (tag & 224) * 8 : 0); c.p += bytes;
39
+ if (!offset || offset > p || p + length > size) throw new Error("Invalid copy");
40
+ for (let i = 0; i < length; i++) { out[p] = out[p - offset]; p++; }
41
+ }
42
+ }
43
+ if (p !== size) throw new Error("Truncated block");
44
+ return out;
45
+ }
46
+
47
+ function* entries(b: Buffer): Generator<[Buffer, Buffer]> {
48
+ const restarts = b.readUInt32LE(b.length - 4);
49
+ const end = b.length - 4 - restarts * 4;
50
+ const c = { p: 0 };
51
+ let key = Buffer.alloc(0);
52
+ while (c.p < end) {
53
+ const shared = varint(b, c), extra = varint(b, c), length = varint(b, c);
54
+ if (shared > key.length || c.p + extra + length > end) throw new Error("Invalid entry");
55
+ key = Buffer.concat([key.subarray(0, shared), b.subarray(c.p, c.p + extra)]);
56
+ c.p += extra;
57
+ const value = b.subarray(c.p, c.p + length); c.p += length;
58
+ yield [key, value];
59
+ }
60
+ }
61
+
62
+ function block(file: Buffer, handle: Buffer): Buffer {
63
+ const c = { p: 0 }, offset = varint(handle, c), length = varint(handle, c);
64
+ if (offset + length + 5 > file.length) throw new Error("Invalid block handle");
65
+ const bytes = file.subarray(offset, offset + length), compression = file[offset + length];
66
+ if (compression === 0) return bytes;
67
+ if (compression === 1) return decodeSnappy(bytes);
68
+ throw new Error("Unsupported compression");
69
+ }
70
+
71
+ function* logRecords(b: Buffer): Generator<Buffer> {
72
+ let parts: Buffer[] = [];
73
+ for (let base = 0; base < b.length; base += 32768) {
74
+ const end = Math.min(base + 32768, b.length);
75
+ for (let p = base; p + 7 <= end;) {
76
+ const length = b.readUInt16LE(p + 4), type = b[p + 6]; p += 7;
77
+ if (!type || p + length > end) break;
78
+ const data = b.subarray(p, p + length); p += length;
79
+ if (type === 1) { parts = []; yield data; }
80
+ else if (type === 2) parts = [data];
81
+ else if (type === 3 && parts.length) parts.push(data);
82
+ else if (type === 4 && parts.length) { parts.push(data); yield Buffer.concat(parts); parts = []; }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Read files without opening the database or taking Chrome's LevelDB lock. Internal
88
+ // sequence numbers, not filenames/byte order, determine the latest value. Decode
89
+ // SST prefix compression and Snappy, and scope the key to the exact extension origin.
90
+ export function readExtensionTokenFromProfile(userDataDir: string, profileDir: string, extensionId: string): string | null {
91
+ const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
92
+ const wanted = Buffer.from(`_chrome-extension://${extensionId}\0\x01auth-token`, "latin1");
93
+ let newest = -1n, token: string | null = null;
94
+ const accept = (key: Buffer, value: Buffer, seq: bigint, type: number) => {
95
+ if (!key.equals(wanted) || seq <= newest) return;
96
+ newest = seq;
97
+ token = type === 1 && /^\x01[A-Za-z0-9_-]{43}$/.test(value.toString("latin1")) ? value.subarray(1).toString("latin1") : null;
98
+ };
99
+ try {
100
+ for (const name of readdirSync(dir)) {
101
+ if (!/\.(ldb|sst|log)$/.test(name)) continue;
102
+ let b: Buffer;
103
+ try { b = readFileSync(join(dir, name)); } catch { continue; } // Compaction can remove files.
104
+ if (name.endsWith(".log")) {
105
+ for (const record of logRecords(b)) {
106
+ if (record.length < 12) continue;
107
+ const seq = record.readBigUInt64LE(0), count = record.readUInt32LE(8), c = { p: 12 };
108
+ for (let i = 0; i < count; i++) {
109
+ const type = record[c.p++];
110
+ if (type !== 0 && type !== 1) throw new Error("Invalid write batch");
111
+ const keyLen = varint(record, c), key = record.subarray(c.p, c.p + keyLen); c.p += keyLen;
112
+ const valueLen = type === 1 ? varint(record, c) : 0;
113
+ if (c.p + valueLen > record.length) throw new Error("Truncated batch");
114
+ const value = record.subarray(c.p, c.p + valueLen); c.p += valueLen;
115
+ accept(key, value, seq + BigInt(i), type);
116
+ }
117
+ }
118
+ } else {
119
+ if (b.length < 48 || b.readBigUInt64LE(b.length - 8) !== 0xdb4775248b80fb57n) throw new Error("Invalid table");
120
+ const footer = b.subarray(b.length - 48), c = { p: 0 };
121
+ varint(footer, c); varint(footer, c); // Skip metaindex handle.
122
+ for (const [, handle] of entries(block(b, footer.subarray(c.p)))) {
123
+ for (const [key, value] of entries(block(b, handle))) {
124
+ if (key.length < 8) throw new Error("Invalid internal key");
125
+ const tag = key.readBigUInt64LE(key.length - 8);
126
+ accept(key.subarray(0, -8), value, tag >> 8n, Number(tag & 255n));
127
+ }
128
+ }
129
+ }
130
+ }
131
+ return token;
132
+ } catch { return null; } // Never replace credentials with a partial/unsupported read.
133
+ }
package/listeners.js ADDED
@@ -0,0 +1,104 @@
1
+ // @bun
2
+ // listeners.ts
3
+ import { isIP } from "net";
4
+ import { createHash, randomBytes } from "crypto";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
8
+ var LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
9
+ var isLoopback = (host) => host === "::1" || /^127\./.test(host);
10
+ var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
11
+ var profileOutputPrefix = (profile) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
12
+ function normalizePrefix(value = "/") {
13
+ if (typeof value !== "string")
14
+ throw new Error("Prefix must be a URL path");
15
+ const path = value.replace(/^\/+|\/+$/g, "");
16
+ if (!path)
17
+ return "/";
18
+ if (path.split("/").some((segment) => !/^[A-Za-z0-9._~-]+$/.test(segment) || segment === "." || segment === ".."))
19
+ throw new Error("Prefix must contain URL path segments, without queries, escapes, or traversal");
20
+ return `/${path}/`;
21
+ }
22
+ function serviceUrl(raw, route = "") {
23
+ const source = new URL(raw);
24
+ const result = new URL(`${source.protocol === "https:" ? "https:" : "http:"}//${source.host}`);
25
+ result.pathname = normalizePrefix(source.pathname) + route.replace(/^\/+/, "");
26
+ return result.toString();
27
+ }
28
+ function validateListeners(value) {
29
+ const config = value;
30
+ if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length)
31
+ throw new Error("listeners.json requires version 1 and at least one listener");
32
+ const names = new Set, addresses = new Set, keys = new Set;
33
+ for (const l of config.listeners) {
34
+ if (!l || !/^[a-zA-Z0-9_-]+$/.test(l.name) || typeof l.host !== "string" || !isIP(l.host) || ["0.0.0.0", "::"].includes(l.host) || !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
35
+ throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
36
+ l.prefix = normalizePrefix(l.prefix);
37
+ if (l.profiles === "*") {
38
+ if (!isLoopback(l.host))
39
+ throw new Error("Unrestricted management listeners must bind to loopback");
40
+ } else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some((p) => typeof p !== "string" || !p.trim() || p.includes("\x00") || p === "*")) {
41
+ throw new Error("Listener profiles must be a nonempty list of registered profile keys");
42
+ }
43
+ const address = listenerAddress(l);
44
+ if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
45
+ throw new Error("Listener names, addresses, and credentials must be unique");
46
+ names.add(l.name);
47
+ addresses.add(address);
48
+ keys.add(l.key);
49
+ }
50
+ return config;
51
+ }
52
+ async function readListeners() {
53
+ let raw;
54
+ try {
55
+ raw = await readFile(LISTENERS_FILE, "utf8");
56
+ } catch (error) {
57
+ if (error.code === "ENOENT")
58
+ return null;
59
+ throw error;
60
+ }
61
+ return validateListeners(JSON.parse(raw));
62
+ }
63
+ async function writeListeners(config) {
64
+ validateListeners(config);
65
+ await mkdir(join(homedir(), ".rechrome"), { recursive: true });
66
+ const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
67
+ await writeFile(temp, JSON.stringify(config, null, 2) + `
68
+ `, { mode: 384 });
69
+ await rename(temp, LISTENERS_FILE);
70
+ }
71
+ var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close"]);
72
+ function authorizeProfileRequest(listener, body) {
73
+ if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a) => typeof a !== "string"))
74
+ throw new Error("Scoped requests require identity.key, identity.profile and string args");
75
+ const profile = body.identity.profile;
76
+ if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile))
77
+ throw new Error("Profile is not allowed on this listener");
78
+ if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
79
+ throw new Error("Profile override does not match request identity");
80
+ const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
81
+ if (!SCOPED_COMMANDS.has(args[0]))
82
+ throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
83
+ if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page")))
84
+ throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
85
+ if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
86
+ throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
87
+ return profile;
88
+ }
89
+ function canReadProfileFile(listener, path) {
90
+ return listener.profiles === "*" || listener.profiles.some((p) => path.startsWith(profileOutputPrefix(p)));
91
+ }
92
+ export {
93
+ LISTENERS_FILE,
94
+ authorizeProfileRequest,
95
+ canReadProfileFile,
96
+ isLoopback,
97
+ listenerAddress,
98
+ normalizePrefix,
99
+ profileOutputPrefix,
100
+ readListeners,
101
+ serviceUrl,
102
+ validateListeners,
103
+ writeListeners
104
+ };
package/listeners.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { isIP } from "net";
2
+ import { createHash, randomBytes } from "crypto";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
6
+
7
+ export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string };
8
+ export type ListenerConfig = { version: 1; listeners: Listener[] };
9
+ export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
10
+ export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
11
+ export const listenerAddress = (l: Listener) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
12
+ export const profileOutputPrefix = (profile: string) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
13
+
14
+ export function normalizePrefix(value = "/"): string {
15
+ if (typeof value !== "string") throw new Error("Prefix must be a URL path");
16
+ const path = value.replace(/^\/+|\/+$/g, "");
17
+ if (!path) return "/";
18
+ if (path.split("/").some(segment => !/^[A-Za-z0-9._~-]+$/.test(segment) || segment === "." || segment === ".."))
19
+ throw new Error("Prefix must contain URL path segments, without queries, escapes, or traversal");
20
+ return `/${path}/`;
21
+ }
22
+
23
+ /** Build an API endpoint without forwarding connection credentials or profile query params. */
24
+ export function serviceUrl(raw: string, route = ""): string {
25
+ const source = new URL(raw);
26
+ const result = new URL(`${source.protocol === "https:" ? "https:" : "http:"}//${source.host}`);
27
+ result.pathname = normalizePrefix(source.pathname) + route.replace(/^\/+/, "");
28
+ return result.toString();
29
+ }
30
+
31
+ export function validateListeners(value: unknown): ListenerConfig {
32
+ const config = value as ListenerConfig;
33
+ if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length) throw new Error("listeners.json requires version 1 and at least one listener");
34
+ const names = new Set<string>(), addresses = new Set<string>(), keys = new Set<string>();
35
+ for (const l of config.listeners) {
36
+ if (!l || !/^[a-zA-Z0-9_-]+$/.test(l.name) || typeof l.host !== "string" || !isIP(l.host) || ["0.0.0.0", "::"].includes(l.host)
37
+ || !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
38
+ throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
39
+ l.prefix = normalizePrefix(l.prefix);
40
+ if (l.profiles === "*") {
41
+ if (!isLoopback(l.host)) throw new Error("Unrestricted management listeners must bind to loopback");
42
+ } else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some(p => typeof p !== "string" || !p.trim() || p.includes("\0") || p === "*")) {
43
+ throw new Error("Listener profiles must be a nonempty list of registered profile keys");
44
+ }
45
+ const address = listenerAddress(l);
46
+ if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
47
+ names.add(l.name); addresses.add(address); keys.add(l.key);
48
+ }
49
+ return config;
50
+ }
51
+
52
+ export async function readListeners(): Promise<ListenerConfig | null> {
53
+ let raw: string;
54
+ try { raw = await readFile(LISTENERS_FILE, "utf8"); }
55
+ catch (error: any) { if (error.code === "ENOENT") return null; throw error; }
56
+ return validateListeners(JSON.parse(raw));
57
+ }
58
+
59
+ export async function writeListeners(config: ListenerConfig): Promise<void> {
60
+ validateListeners(config);
61
+ await mkdir(join(homedir(), ".rechrome"), { recursive: true });
62
+ const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
63
+ await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
64
+ await rename(temp, LISTENERS_FILE);
65
+ }
66
+
67
+ // Restricted listeners deliberately exclude arbitrary code, host filesystem access,
68
+ // attachment/config overrides, and commands that manage all browser sessions.
69
+ const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close"]);
70
+ export function authorizeProfileRequest(listener: Listener, body: any): string {
71
+ if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a: unknown) => typeof a !== "string")) throw new Error("Scoped requests require identity.key, identity.profile and string args");
72
+ const profile = body.identity.profile;
73
+ if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
74
+ if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile) throw new Error("Profile override does not match request identity");
75
+ const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
76
+ if (!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");
77
+ if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page"))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
78
+ 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");
79
+ return profile;
80
+ }
81
+
82
+ export function canReadProfileFile(listener: Listener, path: string): boolean {
83
+ return listener.profiles === "*" || listener.profiles.some(p => path.startsWith(profileOutputPrefix(p)));
84
+ }