rechrome 0.1.0 → 1.1.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/.env.example CHANGED
@@ -10,3 +10,7 @@
10
10
 
11
11
  # Bind address for the server (default: 127.0.0.1, use 0.0.0.0 for remote access)
12
12
  # RECH_HOST=127.0.0.1
13
+
14
+ # Playwright MCP extension settings (can be set on server or client; client overrides server)
15
+ # PLAYWRIGHT_MCP_EXTENSION_ID=your-extension-id
16
+ # PLAYWRIGHT_MCP_EXTENSION_TOKEN=your-extension-token
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # rech — Remote Chrome
1
+ # rechrome — Remote Chrome
2
+
3
+ [![npm](https://img.shields.io/npm/v/rechrome)](https://www.npmjs.com/package/rechrome)
2
4
 
3
5
  CLI proxy for running [Playwright](https://playwright.dev/) commands on a shared remote browser. Run a server on a machine with a browser, then send commands from any client.
4
6
 
@@ -22,14 +24,17 @@ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-mul
22
24
  ## Install
23
25
 
24
26
  ```bash
25
- # Clone and link globally
26
- git clone https://github.com/snomiao/rech.git
27
- cd rech
27
+ # From npm
28
+ bunx rechrome --help
29
+
30
+ # Or clone and link globally
31
+ git clone https://github.com/snomiao/rechrome.git
32
+ cd rechrome
28
33
  bun install
29
34
  bun link
30
35
  ```
31
36
 
32
- Now `rech` is available globally.
37
+ Now `rechrome` (or `rech`) is available globally.
33
38
 
34
39
  ## Quick start
35
40
 
@@ -38,7 +43,7 @@ Now `rech` is available globally.
38
43
  On the machine with a browser:
39
44
 
40
45
  ```bash
41
- rech serve
46
+ rechrome serve
42
47
  ```
43
48
 
44
49
  This auto-generates a connection URL in `.env.local` (with an auth key).
@@ -51,16 +56,16 @@ Copy the `REMOTE_CHROME_URL` from the server's `.env.local` to the client:
51
56
  export REMOTE_CHROME_URL=remote-chrome://YOUR_KEY@server-host:13775
52
57
 
53
58
  # Open a URL
54
- rech open https://example.com
59
+ rechrome open https://example.com
55
60
 
56
61
  # Take a screenshot
57
- rech screenshot
62
+ rechrome screenshot
58
63
 
59
64
  # List open tabs
60
- rech tab-list
65
+ rechrome tab-list
61
66
 
62
67
  # Any playwright-cli command works
63
- rech --help
68
+ rechrome --help
64
69
  ```
65
70
 
66
71
  ## Configuration
@@ -73,10 +78,12 @@ cp .env.example .env.local
73
78
 
74
79
  | Variable | Description | Default |
75
80
  |---|---|---|
76
- | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rech serve`) | — |
81
+ | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rechrome serve`) | — |
77
82
  | `GEMINI_API_KEY` | Gemini API key for screenshot vision descriptions | — |
78
83
  | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
79
84
  | `RECH_HOST` | Server bind address | `127.0.0.1` |
85
+ | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
86
+ | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token (client overrides server) | — |
80
87
 
81
88
  ### Remote access
82
89
 
@@ -103,7 +110,7 @@ bun test
103
110
 
104
111
  ## Related
105
112
 
106
- - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rech's multi-tab and multi-session browser control
113
+ - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rechrome's multi-tab and multi-session browser control
107
114
 
108
115
  ## License
109
116
 
package/package.json CHANGED
@@ -1,15 +1,35 @@
1
- {
1
+ {
2
2
  "name": "rechrome",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/snomiao/rechrome.git"
8
+ },
5
9
  "bin": {
6
- "rech": "./rech.ts"
10
+ "rech": "./rech.js",
11
+ "rechrome": "./rech.js"
7
12
  },
8
13
  "scripts": {
9
14
  "serve": "bun run rech.ts serve",
10
- "test": "bun test"
15
+ "test": "bun test",
16
+ "prepublishOnly": "sed 's/\\.ts/\\.js/g' rech.ts > rech.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js"
11
17
  },
12
18
  "devDependencies": {
13
19
  "@types/bun": "latest"
14
- }
20
+ },
21
+ "release": {
22
+ "branches": [
23
+ "main"
24
+ ]
25
+ },
26
+ "files": [
27
+ "rech.ts",
28
+ "rech.js",
29
+ "serve.ts",
30
+ "serve.js",
31
+ ".env.example",
32
+ "README.md",
33
+ "LICENSE"
34
+ ]
15
35
  }
package/rech.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { file } from "bun";
4
+ import { randomBytes } from "crypto";
5
+ import { mkdirSync, appendFileSync, existsSync } from "fs";
6
+ import { hostname } from "os";
7
+ import { join, basename } from "path";
8
+
9
+ export const ENV_KEY = "REMOTE_CHROME_URL";
10
+ export const DEFAULT_PORT = 13775;
11
+ export const RECH_DIR = join(import.meta.dir, ".rech");
12
+ export const LOG_DIR = join(RECH_DIR, "logs");
13
+
14
+ // Load .env.local from script's directory (works even when invoked from elsewhere)
15
+ const envFile = join(import.meta.dir, ".env.local");
16
+
17
+ /** Load .env.local into process.env. */
18
+ async function loadEnv() {
19
+ const envRaw = await file(envFile)
20
+ .text()
21
+ .catch(() => "");
22
+ for (const line of envRaw.split("\n")) {
23
+ const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
24
+ if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
25
+ }
26
+ }
27
+ await loadEnv();
28
+
29
+ // Watch .env.local for changes and hot-reload
30
+ import { watch } from "node:fs";
31
+ if (existsSync(envFile)) {
32
+ watch(envFile, async () => {
33
+ log(".env.local changed, reloading");
34
+ await loadEnv();
35
+ });
36
+ }
37
+
38
+ /** Describe an image using Gemini vision API. Returns description or null on failure. */
39
+ export async function describeImage(imagePath: string): Promise<string | null> {
40
+ const apiKey = process.env.GEMINI_API_KEY;
41
+ if (!apiKey) return null;
42
+ try {
43
+ const imageData = await file(imagePath).arrayBuffer();
44
+ const base64 = Buffer.from(imageData).toString("base64");
45
+ const mimeType = imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
46
+ const res = await fetch(
47
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent?key=${apiKey}`,
48
+ {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({
52
+ contents: [
53
+ {
54
+ parts: [
55
+ {
56
+ text: "Describe this browser screenshot concisely in 2-3 sentences. Focus on what's visible: page layout, content, any errors or issues.",
57
+ },
58
+ { inline_data: { mime_type: mimeType, data: base64 } },
59
+ ],
60
+ },
61
+ ],
62
+ }),
63
+ },
64
+ );
65
+ if (!res.ok) return null;
66
+ const json = await res.json();
67
+ return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export const PASSTHROUGH_ENV_KEYS = [
74
+ "PLAYWRIGHT_MCP_EXTENSION_ID",
75
+ "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
76
+ ] as const;
77
+
78
+ export function log(msg: string) {
79
+ mkdirSync(LOG_DIR, { recursive: true });
80
+ const ts = new Date().toISOString();
81
+ const line = `[${ts}] ${msg}\n`;
82
+ console.error(line.trimEnd());
83
+ const logFile = join(LOG_DIR, `${ts.slice(0, 10)}.log`);
84
+ appendFileSync(logFile, line);
85
+ }
86
+
87
+ export function parseUrl(raw: string) {
88
+ const u = new URL(raw);
89
+ return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
90
+ }
91
+
92
+ export async function getOrCreateUrl(): Promise<string> {
93
+ if (process.env[ENV_KEY]) return process.env[ENV_KEY];
94
+ const key = randomBytes(9).toString("base64url"); // 12 chars
95
+ const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
96
+ const newLine = `${ENV_KEY}=${url}`;
97
+ const envRaw = await file(envFile)
98
+ .text()
99
+ .catch(() => "");
100
+ const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
101
+ Bun.write(envFile, content);
102
+ process.env[ENV_KEY] = url;
103
+ return url;
104
+ }
105
+
106
+ export function authCheck(req: Request, key: string): Response | null {
107
+ const bearer = req.headers.get("authorization")?.replace("Bearer ", "");
108
+ if (bearer !== key) return new Response("Unauthorized", { status: 401 });
109
+ return null;
110
+ }
111
+
112
+ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
113
+ const cwd = process.cwd();
114
+ try {
115
+ const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
116
+ cwd,
117
+ stdout: "pipe",
118
+ stderr: "ignore",
119
+ });
120
+ const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
121
+ await remoteProc.exited;
122
+
123
+ const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
124
+ cwd,
125
+ stdout: "pipe",
126
+ stderr: "ignore",
127
+ });
128
+ const branch = (await new Response(branchProc.stdout).text()).trim();
129
+ await branchProc.exited;
130
+
131
+ if (remoteUrl) {
132
+ let gitUrl: string;
133
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
134
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
135
+ if (sshMatch) {
136
+ gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
137
+ } else if (httpsMatch) {
138
+ gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
139
+ } else {
140
+ gitUrl = remoteUrl.replace(/\.git$/, "");
141
+ }
142
+ if (branch) gitUrl += `/tree/${branch}`;
143
+ // Strip any embedded credentials from the URL
144
+ try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
145
+ return { gitUrl };
146
+ }
147
+ } catch {}
148
+ return { hostname: hostname(), cwd };
149
+ }
150
+
151
+ function getClientEnv(): Record<string, string> {
152
+ const env: Record<string, string> = {};
153
+ for (const key of PASSTHROUGH_ENV_KEYS) {
154
+ if (process.env[key]) env[key] = process.env[key];
155
+ }
156
+ return env;
157
+ }
158
+
159
+ async function run(url: string, args: string[]) {
160
+ const { key, host, port } = parseUrl(url);
161
+ const identity = await getClientIdentity();
162
+ console.error(
163
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
164
+ );
165
+ const res = await fetch(`http://${host}:${port}/run`, {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
168
+ body: JSON.stringify({ args, identity, env: getClientEnv() }),
169
+ signal: AbortSignal.timeout(70_000),
170
+ }).catch((e) => {
171
+ console.error(`[rech] ${e.message}`);
172
+ process.exit(1);
173
+ });
174
+
175
+ if (res.status === 401) {
176
+ console.error("Unauthorized: bad key");
177
+ process.exit(1);
178
+ }
179
+
180
+ const { status, stdout, stderr, files, descriptions, existingSession } = (await res.json()) as {
181
+ status: number;
182
+ stdout: string;
183
+ stderr: string;
184
+ files?: string[];
185
+ descriptions?: Record<string, string>;
186
+ existingSession?: boolean;
187
+ };
188
+
189
+ if (existingSession) {
190
+ console.error(
191
+ `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
192
+ );
193
+ }
194
+ if (stderr) process.stderr.write(stderr);
195
+ if (stdout) process.stdout.write(stdout);
196
+
197
+ if (files?.length) {
198
+ const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
199
+ mkdirSync(dlDir, { recursive: true });
200
+ const gitignorePath = join(dlDir, ".gitignore");
201
+ if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
202
+ for (const name of files) {
203
+ const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
204
+ headers: { Authorization: `Bearer ${key}` },
205
+ });
206
+ if (!fileRes.ok) continue;
207
+ const dest = join(dlDir, basename(name));
208
+ await Bun.write(dest, fileRes);
209
+ console.error(`[rech] downloaded: ${dest}`);
210
+ if (descriptions?.[name]) {
211
+ console.error(`[rech] vision: ${descriptions[name]}`);
212
+ }
213
+ }
214
+ }
215
+
216
+ process.exit(status);
217
+ }
218
+
219
+ if (import.meta.main) {
220
+ const args = process.argv.slice(2);
221
+
222
+ if (args[0] === "serve") {
223
+ const { serve } = await import("./serve.js");
224
+ serve();
225
+ } else {
226
+ const url = process.env[ENV_KEY];
227
+ if (!url) {
228
+ console.error(
229
+ `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
230
+ );
231
+ process.exit(1);
232
+ }
233
+ run(url, args);
234
+ }
235
+ }
package/rech.ts CHANGED
@@ -6,6 +6,11 @@ import { mkdirSync, appendFileSync, existsSync } from "fs";
6
6
  import { hostname } from "os";
7
7
  import { join, basename } from "path";
8
8
 
9
+ export const ENV_KEY = "REMOTE_CHROME_URL";
10
+ export const DEFAULT_PORT = 13775;
11
+ export const RECH_DIR = join(import.meta.dir, ".rech");
12
+ export const LOG_DIR = join(RECH_DIR, "logs");
13
+
9
14
  // Load .env.local from script's directory (works even when invoked from elsewhere)
10
15
  const envFile = join(import.meta.dir, ".env.local");
11
16
 
@@ -23,15 +28,12 @@ await loadEnv();
23
28
 
24
29
  // Watch .env.local for changes and hot-reload
25
30
  import { watch } from "node:fs";
26
- watch(envFile, async () => {
27
- log(".env.local changed, reloading");
28
- await loadEnv();
29
- });
30
-
31
- export const ENV_KEY = "REMOTE_CHROME_URL";
32
- export const DEFAULT_PORT = 13775;
33
- export const RECH_DIR = join(import.meta.dir, ".rech");
34
- export const LOG_DIR = join(RECH_DIR, "logs");
31
+ if (existsSync(envFile)) {
32
+ watch(envFile, async () => {
33
+ log(".env.local changed, reloading");
34
+ await loadEnv();
35
+ });
36
+ }
35
37
 
36
38
  /** Describe an image using Gemini vision API. Returns description or null on failure. */
37
39
  export async function describeImage(imagePath: string): Promise<string | null> {
@@ -68,6 +70,11 @@ export async function describeImage(imagePath: string): Promise<string | null> {
68
70
  }
69
71
  }
70
72
 
73
+ export const PASSTHROUGH_ENV_KEYS = [
74
+ "PLAYWRIGHT_MCP_EXTENSION_ID",
75
+ "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
76
+ ] as const;
77
+
71
78
  export function log(msg: string) {
72
79
  mkdirSync(LOG_DIR, { recursive: true });
73
80
  const ts = new Date().toISOString();
@@ -141,6 +148,14 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
141
148
  return { hostname: hostname(), cwd };
142
149
  }
143
150
 
151
+ function getClientEnv(): Record<string, string> {
152
+ const env: Record<string, string> = {};
153
+ for (const key of PASSTHROUGH_ENV_KEYS) {
154
+ if (process.env[key]) env[key] = process.env[key];
155
+ }
156
+ return env;
157
+ }
158
+
144
159
  async function run(url: string, args: string[]) {
145
160
  const { key, host, port } = parseUrl(url);
146
161
  const identity = await getClientIdentity();
@@ -150,7 +165,7 @@ async function run(url: string, args: string[]) {
150
165
  const res = await fetch(`http://${host}:${port}/run`, {
151
166
  method: "POST",
152
167
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
153
- body: JSON.stringify({ args, identity }),
168
+ body: JSON.stringify({ args, identity, env: getClientEnv() }),
154
169
  signal: AbortSignal.timeout(70_000),
155
170
  }).catch((e) => {
156
171
  console.error(`[rech] ${e.message}`);
package/serve.js ADDED
@@ -0,0 +1,217 @@
1
+ import { file } from "bun";
2
+ import { createHash } from "crypto";
3
+ import { mkdirSync } from "fs";
4
+ import { join, resolve, relative, isAbsolute } from "path";
5
+ import { log, parseUrl, getOrCreateUrl, authCheck, describeImage, RECH_DIR, PASSTHROUGH_ENV_KEYS } from "./rech.js";
6
+
7
+ export function isUnderDir(base: string, candidate: string): boolean {
8
+ const absBase = resolve(base) + "/";
9
+ const absCandidate = resolve(base, candidate);
10
+ return absCandidate.startsWith(absBase);
11
+ }
12
+
13
+ export async function serve() {
14
+ const url = await getOrCreateUrl();
15
+ const { key, port } = parseUrl(url);
16
+
17
+ const workDir = join(RECH_DIR, "output");
18
+ mkdirSync(workDir, { recursive: true });
19
+
20
+ const listenHost = process.env.RECH_HOST || "127.0.0.1";
21
+ const server = Bun.serve({
22
+ hostname: listenHost,
23
+ port,
24
+ async fetch(req) {
25
+ const reqUrl = new URL(req.url);
26
+
27
+ // Serve files from output dir
28
+ if (reqUrl.pathname.startsWith("/files/")) {
29
+ const denied = authCheck(req, key);
30
+ if (denied) return denied;
31
+ const name = decodeURIComponent(reqUrl.pathname.slice(7));
32
+ if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
33
+ const resolved = resolve(workDir, name);
34
+ const f = file(resolved);
35
+ if (!(await f.exists())) return new Response("Not found", { status: 404 });
36
+ return new Response(f);
37
+ }
38
+
39
+ if (reqUrl.pathname !== "/run") return new Response("rech server\n");
40
+ const denied = authCheck(req, key);
41
+ if (denied) return denied;
42
+
43
+ const body = await req.json();
44
+ let args: string[];
45
+ let sessionId: string;
46
+ let clientName = "";
47
+ let clientEnv: Record<string, string> = {};
48
+ if (Array.isArray(body)) {
49
+ args = body;
50
+ const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
51
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
52
+ clientName = clientAddr;
53
+ log(`session from client IP: ${clientAddr} -> ${sessionId}`);
54
+ } else {
55
+ args = body.args;
56
+ const id = body.identity as
57
+ | { gitUrl?: string; hostname?: string; cwd?: string }
58
+ | undefined;
59
+ const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
60
+ if (raw) {
61
+ sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
62
+ clientName = raw;
63
+ log(`session from identity: ${raw} -> ${sessionId}`);
64
+ } else {
65
+ const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
66
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
67
+ clientName = clientAddr;
68
+ log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
69
+ }
70
+ // Extract allowlisted env vars from client (client overrides server)
71
+ if (body.env && typeof body.env === "object") {
72
+ for (const key of PASSTHROUGH_ENV_KEYS) {
73
+ if (typeof body.env[key] === "string") clientEnv[key] = body.env[key];
74
+ }
75
+ }
76
+ }
77
+
78
+ let clientSession = "";
79
+ const filteredArgs = args.filter((a) => {
80
+ const m = a.match(/^-s=(.+)$/);
81
+ if (m) {
82
+ clientSession = m[1];
83
+ return false;
84
+ }
85
+ return true;
86
+ });
87
+ const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
88
+
89
+ const bin = process.env.PLAYWRIGHT_CLI || "playwright-cli";
90
+
91
+ if (filteredArgs.length === 0) {
92
+ filteredArgs.push("--help");
93
+ }
94
+
95
+ log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
96
+
97
+ // For open commands, check if this session already has tabs open
98
+ const isOpenCmd = filteredArgs[0] === "open";
99
+ if (isOpenCmd) {
100
+ try {
101
+ const listProc = Bun.spawn([bin, "tab-list", "--extension", `-s=${namespacedSession}`], {
102
+ cwd: workDir,
103
+ stdin: "ignore",
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ env: { PATH: process.env.PATH, HOME: process.env.HOME },
107
+ });
108
+ const [listStatus, listOut] = await Promise.all([
109
+ listProc.exited,
110
+ new Response(listProc.stdout).text(),
111
+ ]);
112
+ if (listStatus === 0 && listOut.trim()) {
113
+ log(`session ${namespacedSession} already has tabs, returning tab-list hint`);
114
+ return Response.json({
115
+ status: 0,
116
+ stdout: listOut,
117
+ stderr: `[rech] session "${namespacedSession}" already has open tabs:\n`,
118
+ files: [],
119
+ existingSession: true,
120
+ });
121
+ }
122
+ } catch (e) {
123
+ log(`tab-list check failed: ${e}`);
124
+ }
125
+ }
126
+
127
+ // Merge passthrough env: server .env.local defaults, then client overrides
128
+ const passthroughEnv: Record<string, string | undefined> = {};
129
+ for (const key of PASSTHROUGH_ENV_KEYS) {
130
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
131
+ }
132
+ Object.assign(passthroughEnv, clientEnv);
133
+
134
+ const childEnv: Record<string, string | undefined> = {
135
+ PATH: process.env.PATH,
136
+ HOME: process.env.HOME,
137
+ TMPDIR: process.env.TMPDIR,
138
+ DISPLAY: process.env.DISPLAY,
139
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
140
+ ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
141
+ ...passthroughEnv,
142
+ };
143
+ const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
144
+ cwd: workDir,
145
+ stdin: "ignore",
146
+ stdout: "pipe",
147
+ stderr: "pipe",
148
+ env: childEnv,
149
+ });
150
+
151
+ const TIMEOUT = 60_000;
152
+ const timeout = new Promise<never>((_, reject) =>
153
+ setTimeout(() => {
154
+ proc.kill();
155
+ reject(new Error("timeout"));
156
+ }, TIMEOUT),
157
+ );
158
+ const [status, stdout, stderr] = await Promise.race([
159
+ Promise.all([
160
+ proc.exited,
161
+ new Response(proc.stdout).text(),
162
+ new Response(proc.stderr).text(),
163
+ ]),
164
+ timeout.then(() => [1, "", ""] as [number, string, string]),
165
+ ]).catch(
166
+ () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
167
+ );
168
+
169
+ log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
170
+
171
+ // Detect files mentioned in output
172
+ const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
173
+ const mentionedFiles = [
174
+ ...new Set(
175
+ [...stdout.matchAll(filePattern), ...stderr.matchAll(filePattern)].map((m) => m[0]),
176
+ ),
177
+ ];
178
+ const outputFiles: string[] = [];
179
+ for (const f of mentionedFiles) {
180
+ if (!isUnderDir(workDir, f)) continue;
181
+ if (await file(join(workDir, f)).exists()) {
182
+ outputFiles.push(f);
183
+ } else {
184
+ const basename = f.split("/").pop()!;
185
+ for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
186
+ const subpath = join(subdir, basename);
187
+ if (await file(join(workDir, subpath)).exists()) {
188
+ outputFiles.push(subpath);
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // Auto-describe screenshot files with Gemini vision
196
+ const descriptions: Record<string, string> = {};
197
+ for (const f of outputFiles) {
198
+ if (/\.(?:png|jpe?g)$/i.test(f)) {
199
+ const desc = await describeImage(join(workDir, f));
200
+ if (desc) descriptions[f] = desc;
201
+ }
202
+ }
203
+
204
+ const rebrand = (s: string) => s.replaceAll("npx playwright-cli", "rech");
205
+ return Response.json({
206
+ status,
207
+ stdout: rebrand(stdout),
208
+ stderr: rebrand(stderr),
209
+ files: outputFiles,
210
+ descriptions,
211
+ });
212
+ },
213
+ });
214
+
215
+ log(`serving on http://${server.hostname}:${server.port}`);
216
+ log(`Connection URL set (use .env.local to view)`);
217
+ }
package/serve.ts CHANGED
@@ -2,7 +2,7 @@ import { file } from "bun";
2
2
  import { createHash } from "crypto";
3
3
  import { mkdirSync } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
- import { log, parseUrl, getOrCreateUrl, authCheck, describeImage, RECH_DIR } from "./rech.ts";
5
+ import { log, parseUrl, getOrCreateUrl, authCheck, describeImage, RECH_DIR, PASSTHROUGH_ENV_KEYS } from "./rech.ts";
6
6
 
7
7
  export function isUnderDir(base: string, candidate: string): boolean {
8
8
  const absBase = resolve(base) + "/";
@@ -44,6 +44,7 @@ export async function serve() {
44
44
  let args: string[];
45
45
  let sessionId: string;
46
46
  let clientName = "";
47
+ let clientEnv: Record<string, string> = {};
47
48
  if (Array.isArray(body)) {
48
49
  args = body;
49
50
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
@@ -66,6 +67,12 @@ export async function serve() {
66
67
  clientName = clientAddr;
67
68
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
68
69
  }
70
+ // Extract allowlisted env vars from client (client overrides server)
71
+ if (body.env && typeof body.env === "object") {
72
+ for (const key of PASSTHROUGH_ENV_KEYS) {
73
+ if (typeof body.env[key] === "string") clientEnv[key] = body.env[key];
74
+ }
75
+ }
69
76
  }
70
77
 
71
78
  let clientSession = "";
@@ -117,6 +124,13 @@ export async function serve() {
117
124
  }
118
125
  }
119
126
 
127
+ // Merge passthrough env: server .env.local defaults, then client overrides
128
+ const passthroughEnv: Record<string, string | undefined> = {};
129
+ for (const key of PASSTHROUGH_ENV_KEYS) {
130
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
131
+ }
132
+ Object.assign(passthroughEnv, clientEnv);
133
+
120
134
  const childEnv: Record<string, string | undefined> = {
121
135
  PATH: process.env.PATH,
122
136
  HOME: process.env.HOME,
@@ -124,6 +138,7 @@ export async function serve() {
124
138
  DISPLAY: process.env.DISPLAY,
125
139
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
126
140
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
141
+ ...passthroughEnv,
127
142
  };
128
143
  const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
129
144
  cwd: workDir,
package/rech.spec.ts DELETED
@@ -1,78 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { parseUrl, authCheck, describeImage, DEFAULT_PORT, ENV_KEY } from "./rech.ts";
3
-
4
- describe("parseUrl", () => {
5
- test("parses key, host, and port from a remote-chrome URL", () => {
6
- const result = parseUrl("remote-chrome://mykey@example.com:9999");
7
- expect(result).toEqual({ key: "mykey", host: "example.com", port: 9999 });
8
- });
9
-
10
- test("falls back to DEFAULT_PORT when port is missing", () => {
11
- const result = parseUrl("remote-chrome://mykey@example.com");
12
- expect(result).toEqual({ key: "mykey", host: "example.com", port: DEFAULT_PORT });
13
- });
14
-
15
- test("handles URL-safe base64 characters in key", () => {
16
- const result = parseUrl("remote-chrome://ab_c-dEf12@host:8080");
17
- expect(result.key).toBe("ab_c-dEf12");
18
- });
19
-
20
- test("parses localhost URLs", () => {
21
- const result = parseUrl("remote-chrome://k@localhost:13775");
22
- expect(result).toEqual({ key: "k", host: "localhost", port: 13775 });
23
- });
24
- });
25
-
26
- describe("authCheck", () => {
27
- test("returns null for valid bearer token", () => {
28
- const req = new Request("http://localhost/run", {
29
- headers: { Authorization: "Bearer secret123" },
30
- });
31
- expect(authCheck(req, "secret123")).toBeNull();
32
- });
33
-
34
- test("returns 401 for wrong bearer token", () => {
35
- const req = new Request("http://localhost/run", {
36
- headers: { Authorization: "Bearer wrong" },
37
- });
38
- const res = authCheck(req, "secret123");
39
- expect(res).not.toBeNull();
40
- expect(res!.status).toBe(401);
41
- });
42
-
43
- test("returns 401 when no authorization header", () => {
44
- const req = new Request("http://localhost/run");
45
- const res = authCheck(req, "secret123");
46
- expect(res).not.toBeNull();
47
- expect(res!.status).toBe(401);
48
- });
49
-
50
- test("returns 401 for empty bearer token", () => {
51
- const req = new Request("http://localhost/run", {
52
- headers: { Authorization: "Bearer " },
53
- });
54
- const res = authCheck(req, "secret123");
55
- expect(res).not.toBeNull();
56
- expect(res!.status).toBe(401);
57
- });
58
- });
59
-
60
- describe("describeImage", () => {
61
- test("returns null when GEMINI_API_KEY is not set", async () => {
62
- const original = process.env.GEMINI_API_KEY;
63
- delete process.env.GEMINI_API_KEY;
64
- const result = await describeImage("/nonexistent/image.png");
65
- expect(result).toBeNull();
66
- if (original) process.env.GEMINI_API_KEY = original;
67
- });
68
- });
69
-
70
- describe("constants", () => {
71
- test("ENV_KEY is REMOTE_CHROME_URL", () => {
72
- expect(ENV_KEY).toBe("REMOTE_CHROME_URL");
73
- });
74
-
75
- test("DEFAULT_PORT is 13775", () => {
76
- expect(DEFAULT_PORT).toBe(13775);
77
- });
78
- });
package/serve.spec.ts DELETED
@@ -1,50 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { isUnderDir } from "./serve.ts";
3
-
4
- describe("isUnderDir", () => {
5
- test("allows simple relative file", () => {
6
- expect(isUnderDir("/app/output", "file.png")).toBe(true);
7
- });
8
-
9
- test("allows nested relative path", () => {
10
- expect(isUnderDir("/app/output", "subdir/file.png")).toBe(true);
11
- });
12
-
13
- test("blocks simple traversal with ../", () => {
14
- expect(isUnderDir("/app/output", "../secret.txt")).toBe(false);
15
- });
16
-
17
- test("blocks traversal that shares prefix (output-evil)", () => {
18
- expect(isUnderDir("/app/output", "../output-evil/secret.txt")).toBe(false);
19
- });
20
-
21
- test("blocks double traversal", () => {
22
- expect(isUnderDir("/app/output", "../../etc/passwd")).toBe(false);
23
- });
24
-
25
- test("blocks traversal hidden in middle of path", () => {
26
- expect(isUnderDir("/app/output", "subdir/../../etc/passwd")).toBe(false);
27
- });
28
-
29
- test("allows deeply nested path", () => {
30
- expect(isUnderDir("/app/output", "a/b/c/d/file.json")).toBe(true);
31
- });
32
-
33
- test("blocks absolute path outside base", () => {
34
- expect(isUnderDir("/app/output", "/etc/passwd")).toBe(false);
35
- });
36
-
37
- test("blocks dot-only path that resolves to base itself", () => {
38
- // "." resolves to base itself, not under it
39
- expect(isUnderDir("/app/output", ".")).toBe(false);
40
- });
41
-
42
- test("allows path starting with dot component", () => {
43
- expect(isUnderDir("/app/output", "./file.png")).toBe(true);
44
- });
45
-
46
- test("blocks percent-encoded traversal after decoding", () => {
47
- // The caller is responsible for decoding; test the resolved path
48
- expect(isUnderDir("/app/output", decodeURIComponent("..%2F..%2Fetc%2Fpasswd"))).toBe(false);
49
- });
50
- });