rechrome 1.0.0 → 1.2.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
@@ -10,7 +10,7 @@ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-mul
10
10
 
11
11
  - **Session isolation** — clients are automatically namespaced by git repo or hostname
12
12
  - **File transfer** — screenshots and PDFs are automatically downloaded to the client
13
- - **Vision descriptions** — optional Gemini-powered screenshot descriptions
13
+ - **Vision descriptions** — opt-in Gemini-powered screenshot descriptions (`--gemini-vision`)
14
14
  - **Hot-reload config** — `.env.local` changes are picked up without restart
15
15
  - **Security** — bearer auth, path traversal protection, env allowlisting for child processes
16
16
 
@@ -61,6 +61,9 @@ rechrome open https://example.com
61
61
  # Take a screenshot
62
62
  rechrome screenshot
63
63
 
64
+ # Take a screenshot with Gemini vision description
65
+ rechrome screenshot --gemini-vision
66
+
64
67
  # List open tabs
65
68
  rechrome tab-list
66
69
 
@@ -76,12 +79,14 @@ Copy `.env.example` to `.env.local` and edit:
76
79
  cp .env.example .env.local
77
80
  ```
78
81
 
79
- | Variable | Description | Default |
80
- |---|---|---|
81
- | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rechrome serve`) | — |
82
- | `GEMINI_API_KEY` | Gemini API key for screenshot vision descriptions | — |
83
- | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
84
- | `RECH_HOST` | Server bind address | `127.0.0.1` |
82
+ | Variable | Description | Default |
83
+ | -------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------- |
84
+ | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rechrome serve`) | — |
85
+ | `GEMINI_API_KEY` | Gemini API key for `--gemini-vision` screenshot descriptions | — |
86
+ | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
87
+ | `RECH_HOST` | Server bind address | `127.0.0.1` |
88
+ | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
89
+ | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token (client overrides server) | — |
85
90
 
86
91
  ### Remote access
87
92
 
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.0.0",
4
- "type": "module",
3
+ "version": "1.2.0",
5
4
  "repository": {
6
5
  "type": "git",
7
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -10,6 +9,16 @@
10
9
  "rech": "./rech.js",
11
10
  "rechrome": "./rech.js"
12
11
  },
12
+ "files": [
13
+ ".env.example",
14
+ "LICENSE",
15
+ "README.md",
16
+ "rech.js",
17
+ "rech.ts",
18
+ "serve.js",
19
+ "serve.ts"
20
+ ],
21
+ "type": "module",
13
22
  "scripts": {
14
23
  "serve": "bun run rech.ts serve",
15
24
  "test": "bun test",
@@ -22,14 +31,5 @@
22
31
  "branches": [
23
32
  "main"
24
33
  ]
25
- },
26
- "files": [
27
- "rech.ts",
28
- "rech.js",
29
- "serve.ts",
30
- "serve.js",
31
- ".env.example",
32
- "README.md",
33
- "LICENSE"
34
- ]
34
+ }
35
35
  }
package/rech.js CHANGED
@@ -70,6 +70,11 @@ export async function describeImage(imagePath: string): Promise<string | null> {
70
70
  }
71
71
  }
72
72
 
73
+ export const PASSTHROUGH_ENV_KEYS = [
74
+ "PLAYWRIGHT_MCP_EXTENSION_ID",
75
+ "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
76
+ ] as const;
77
+
73
78
  export function log(msg: string) {
74
79
  mkdirSync(LOG_DIR, { recursive: true });
75
80
  const ts = new Date().toISOString();
@@ -136,15 +141,33 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
136
141
  }
137
142
  if (branch) gitUrl += `/tree/${branch}`;
138
143
  // Strip any embedded credentials from the URL
139
- try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
144
+ try {
145
+ const u = new URL(gitUrl);
146
+ u.username = "";
147
+ u.password = "";
148
+ gitUrl = u.toString();
149
+ } catch {}
140
150
  return { gitUrl };
141
151
  }
142
152
  } catch {}
143
153
  return { hostname: hostname(), cwd };
144
154
  }
145
155
 
156
+ function getClientEnv(): Record<string, string> {
157
+ const env: Record<string, string> = {};
158
+ for (const key of PASSTHROUGH_ENV_KEYS) {
159
+ if (process.env[key]) env[key] = process.env[key];
160
+ }
161
+ return env;
162
+ }
163
+
146
164
  async function run(url: string, args: string[]) {
147
165
  const { key, host, port } = parseUrl(url);
166
+
167
+ // Extract --gemini-vision flag (not forwarded to server args)
168
+ const geminiVision = args.includes("--gemini-vision");
169
+ const filteredArgs = args.filter((a) => a !== "--gemini-vision");
170
+
148
171
  const identity = await getClientIdentity();
149
172
  console.error(
150
173
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
@@ -152,7 +175,7 @@ async function run(url: string, args: string[]) {
152
175
  const res = await fetch(`http://${host}:${port}/run`, {
153
176
  method: "POST",
154
177
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
155
- body: JSON.stringify({ args, identity }),
178
+ body: JSON.stringify({ args: filteredArgs, identity, env: getClientEnv(), geminiVision }),
156
179
  signal: AbortSignal.timeout(70_000),
157
180
  }).catch((e) => {
158
181
  console.error(`[rech] ${e.message}`);
package/rech.ts CHANGED
@@ -70,6 +70,11 @@ export async function describeImage(imagePath: string): Promise<string | null> {
70
70
  }
71
71
  }
72
72
 
73
+ export const PASSTHROUGH_ENV_KEYS = [
74
+ "PLAYWRIGHT_MCP_EXTENSION_ID",
75
+ "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
76
+ ] as const;
77
+
73
78
  export function log(msg: string) {
74
79
  mkdirSync(LOG_DIR, { recursive: true });
75
80
  const ts = new Date().toISOString();
@@ -136,15 +141,33 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
136
141
  }
137
142
  if (branch) gitUrl += `/tree/${branch}`;
138
143
  // Strip any embedded credentials from the URL
139
- try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
144
+ try {
145
+ const u = new URL(gitUrl);
146
+ u.username = "";
147
+ u.password = "";
148
+ gitUrl = u.toString();
149
+ } catch {}
140
150
  return { gitUrl };
141
151
  }
142
152
  } catch {}
143
153
  return { hostname: hostname(), cwd };
144
154
  }
145
155
 
156
+ function getClientEnv(): Record<string, string> {
157
+ const env: Record<string, string> = {};
158
+ for (const key of PASSTHROUGH_ENV_KEYS) {
159
+ if (process.env[key]) env[key] = process.env[key];
160
+ }
161
+ return env;
162
+ }
163
+
146
164
  async function run(url: string, args: string[]) {
147
165
  const { key, host, port } = parseUrl(url);
166
+
167
+ // Extract --gemini-vision flag (not forwarded to server args)
168
+ const geminiVision = args.includes("--gemini-vision");
169
+ const filteredArgs = args.filter((a) => a !== "--gemini-vision");
170
+
148
171
  const identity = await getClientIdentity();
149
172
  console.error(
150
173
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
@@ -152,7 +175,7 @@ async function run(url: string, args: string[]) {
152
175
  const res = await fetch(`http://${host}:${port}/run`, {
153
176
  method: "POST",
154
177
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
155
- body: JSON.stringify({ args, identity }),
178
+ body: JSON.stringify({ args: filteredArgs, identity, env: getClientEnv(), geminiVision }),
156
179
  signal: AbortSignal.timeout(70_000),
157
180
  }).catch((e) => {
158
181
  console.error(`[rech] ${e.message}`);
package/serve.js CHANGED
@@ -2,7 +2,15 @@ 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.js";
5
+ import {
6
+ log,
7
+ parseUrl,
8
+ getOrCreateUrl,
9
+ authCheck,
10
+ describeImage,
11
+ RECH_DIR,
12
+ PASSTHROUGH_ENV_KEYS,
13
+ } from "./rech.js";
6
14
 
7
15
  export function isUnderDir(base: string, candidate: string): boolean {
8
16
  const absBase = resolve(base) + "/";
@@ -44,6 +52,8 @@ export async function serve() {
44
52
  let args: string[];
45
53
  let sessionId: string;
46
54
  let clientName = "";
55
+ let clientEnv: Record<string, string> = {};
56
+ let geminiVision = false;
47
57
  if (Array.isArray(body)) {
48
58
  args = body;
49
59
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
@@ -66,6 +76,13 @@ export async function serve() {
66
76
  clientName = clientAddr;
67
77
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
68
78
  }
79
+ // Extract allowlisted env vars from client (client overrides server)
80
+ if (body.env && typeof body.env === "object") {
81
+ for (const key of PASSTHROUGH_ENV_KEYS) {
82
+ if (typeof body.env[key] === "string") clientEnv[key] = body.env[key];
83
+ }
84
+ }
85
+ if (body.geminiVision) geminiVision = true;
69
86
  }
70
87
 
71
88
  let clientSession = "";
@@ -117,6 +134,13 @@ export async function serve() {
117
134
  }
118
135
  }
119
136
 
137
+ // Merge passthrough env: server .env.local defaults, then client overrides
138
+ const passthroughEnv: Record<string, string | undefined> = {};
139
+ for (const key of PASSTHROUGH_ENV_KEYS) {
140
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
141
+ }
142
+ Object.assign(passthroughEnv, clientEnv);
143
+
120
144
  const childEnv: Record<string, string | undefined> = {
121
145
  PATH: process.env.PATH,
122
146
  HOME: process.env.HOME,
@@ -124,6 +148,7 @@ export async function serve() {
124
148
  DISPLAY: process.env.DISPLAY,
125
149
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
126
150
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
151
+ ...passthroughEnv,
127
152
  };
128
153
  const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
129
154
  cwd: workDir,
@@ -177,12 +202,14 @@ export async function serve() {
177
202
  }
178
203
  }
179
204
 
180
- // Auto-describe screenshot files with Gemini vision
205
+ // Auto-describe screenshot files with Gemini vision (opt-in via --gemini-vision)
181
206
  const descriptions: Record<string, string> = {};
182
- for (const f of outputFiles) {
183
- if (/\.(?:png|jpe?g)$/i.test(f)) {
184
- const desc = await describeImage(join(workDir, f));
185
- if (desc) descriptions[f] = desc;
207
+ if (geminiVision) {
208
+ for (const f of outputFiles) {
209
+ if (/\.(?:png|jpe?g)$/i.test(f)) {
210
+ const desc = await describeImage(join(workDir, f));
211
+ if (desc) descriptions[f] = desc;
212
+ }
186
213
  }
187
214
  }
188
215
 
package/serve.ts CHANGED
@@ -2,7 +2,15 @@ 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 {
6
+ log,
7
+ parseUrl,
8
+ getOrCreateUrl,
9
+ authCheck,
10
+ describeImage,
11
+ RECH_DIR,
12
+ PASSTHROUGH_ENV_KEYS,
13
+ } from "./rech.ts";
6
14
 
7
15
  export function isUnderDir(base: string, candidate: string): boolean {
8
16
  const absBase = resolve(base) + "/";
@@ -44,6 +52,8 @@ export async function serve() {
44
52
  let args: string[];
45
53
  let sessionId: string;
46
54
  let clientName = "";
55
+ let clientEnv: Record<string, string> = {};
56
+ let geminiVision = false;
47
57
  if (Array.isArray(body)) {
48
58
  args = body;
49
59
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
@@ -66,6 +76,13 @@ export async function serve() {
66
76
  clientName = clientAddr;
67
77
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
68
78
  }
79
+ // Extract allowlisted env vars from client (client overrides server)
80
+ if (body.env && typeof body.env === "object") {
81
+ for (const key of PASSTHROUGH_ENV_KEYS) {
82
+ if (typeof body.env[key] === "string") clientEnv[key] = body.env[key];
83
+ }
84
+ }
85
+ if (body.geminiVision) geminiVision = true;
69
86
  }
70
87
 
71
88
  let clientSession = "";
@@ -117,6 +134,13 @@ export async function serve() {
117
134
  }
118
135
  }
119
136
 
137
+ // Merge passthrough env: server .env.local defaults, then client overrides
138
+ const passthroughEnv: Record<string, string | undefined> = {};
139
+ for (const key of PASSTHROUGH_ENV_KEYS) {
140
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
141
+ }
142
+ Object.assign(passthroughEnv, clientEnv);
143
+
120
144
  const childEnv: Record<string, string | undefined> = {
121
145
  PATH: process.env.PATH,
122
146
  HOME: process.env.HOME,
@@ -124,6 +148,7 @@ export async function serve() {
124
148
  DISPLAY: process.env.DISPLAY,
125
149
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
126
150
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
151
+ ...passthroughEnv,
127
152
  };
128
153
  const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
129
154
  cwd: workDir,
@@ -177,12 +202,14 @@ export async function serve() {
177
202
  }
178
203
  }
179
204
 
180
- // Auto-describe screenshot files with Gemini vision
205
+ // Auto-describe screenshot files with Gemini vision (opt-in via --gemini-vision)
181
206
  const descriptions: Record<string, string> = {};
182
- for (const f of outputFiles) {
183
- if (/\.(?:png|jpe?g)$/i.test(f)) {
184
- const desc = await describeImage(join(workDir, f));
185
- if (desc) descriptions[f] = desc;
207
+ if (geminiVision) {
208
+ for (const f of outputFiles) {
209
+ if (/\.(?:png|jpe?g)$/i.test(f)) {
210
+ const desc = await describeImage(join(workDir, f));
211
+ if (desc) descriptions[f] = desc;
212
+ }
186
213
  }
187
214
  }
188
215