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 +4 -0
- package/README.md +19 -12
- package/package.json +25 -5
- package/rech.js +235 -0
- package/rech.ts +25 -10
- package/serve.js +217 -0
- package/serve.ts +16 -1
- package/rech.spec.ts +0 -78
- package/serve.spec.ts +0 -50
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
|
-
#
|
|
1
|
+
# rechrome — Remote Chrome
|
|
2
|
+
|
|
3
|
+
[](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
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
rechrome open https://example.com
|
|
55
60
|
|
|
56
61
|
# Take a screenshot
|
|
57
|
-
|
|
62
|
+
rechrome screenshot
|
|
58
63
|
|
|
59
64
|
# List open tabs
|
|
60
|
-
|
|
65
|
+
rechrome tab-list
|
|
61
66
|
|
|
62
67
|
# Any playwright-cli command works
|
|
63
|
-
|
|
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 `
|
|
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
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
});
|