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 +4 -0
- package/README.md +12 -7
- package/package.json +12 -12
- package/rech.js +25 -2
- package/rech.ts +25 -2
- package/serve.js +33 -6
- package/serve.ts +33 -6
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** —
|
|
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
|
|
80
|
-
|
|
81
|
-
| `REMOTE_CHROME_URL`
|
|
82
|
-
| `GEMINI_API_KEY`
|
|
83
|
-
| `PLAYWRIGHT_CLI`
|
|
84
|
-
| `RECH_HOST`
|
|
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.
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|