rechrome 0.1.0 → 1.0.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/README.md +17 -12
- package/package.json +25 -5
- package/rech.js +222 -0
- package/rech.ts +11 -9
- package/serve.js +202 -0
- package/rech.spec.ts +0 -78
- package/serve.spec.ts +0 -50
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,7 +78,7 @@ 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` |
|
|
@@ -103,7 +108,7 @@ bun test
|
|
|
103
108
|
|
|
104
109
|
## Related
|
|
105
110
|
|
|
106
|
-
- [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering
|
|
111
|
+
- [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
112
|
|
|
108
113
|
## License
|
|
109
114
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.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,222 @@
|
|
|
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 function log(msg: string) {
|
|
74
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
75
|
+
const ts = new Date().toISOString();
|
|
76
|
+
const line = `[${ts}] ${msg}\n`;
|
|
77
|
+
console.error(line.trimEnd());
|
|
78
|
+
const logFile = join(LOG_DIR, `${ts.slice(0, 10)}.log`);
|
|
79
|
+
appendFileSync(logFile, line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseUrl(raw: string) {
|
|
83
|
+
const u = new URL(raw);
|
|
84
|
+
return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getOrCreateUrl(): Promise<string> {
|
|
88
|
+
if (process.env[ENV_KEY]) return process.env[ENV_KEY];
|
|
89
|
+
const key = randomBytes(9).toString("base64url"); // 12 chars
|
|
90
|
+
const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
|
|
91
|
+
const newLine = `${ENV_KEY}=${url}`;
|
|
92
|
+
const envRaw = await file(envFile)
|
|
93
|
+
.text()
|
|
94
|
+
.catch(() => "");
|
|
95
|
+
const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
|
|
96
|
+
Bun.write(envFile, content);
|
|
97
|
+
process.env[ENV_KEY] = url;
|
|
98
|
+
return url;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function authCheck(req: Request, key: string): Response | null {
|
|
102
|
+
const bearer = req.headers.get("authorization")?.replace("Bearer ", "");
|
|
103
|
+
if (bearer !== key) return new Response("Unauthorized", { status: 401 });
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
|
|
108
|
+
const cwd = process.cwd();
|
|
109
|
+
try {
|
|
110
|
+
const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
111
|
+
cwd,
|
|
112
|
+
stdout: "pipe",
|
|
113
|
+
stderr: "ignore",
|
|
114
|
+
});
|
|
115
|
+
const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
|
|
116
|
+
await remoteProc.exited;
|
|
117
|
+
|
|
118
|
+
const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
119
|
+
cwd,
|
|
120
|
+
stdout: "pipe",
|
|
121
|
+
stderr: "ignore",
|
|
122
|
+
});
|
|
123
|
+
const branch = (await new Response(branchProc.stdout).text()).trim();
|
|
124
|
+
await branchProc.exited;
|
|
125
|
+
|
|
126
|
+
if (remoteUrl) {
|
|
127
|
+
let gitUrl: string;
|
|
128
|
+
const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
129
|
+
const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
130
|
+
if (sshMatch) {
|
|
131
|
+
gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
132
|
+
} else if (httpsMatch) {
|
|
133
|
+
gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
|
|
134
|
+
} else {
|
|
135
|
+
gitUrl = remoteUrl.replace(/\.git$/, "");
|
|
136
|
+
}
|
|
137
|
+
if (branch) gitUrl += `/tree/${branch}`;
|
|
138
|
+
// Strip any embedded credentials from the URL
|
|
139
|
+
try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
|
|
140
|
+
return { gitUrl };
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
return { hostname: hostname(), cwd };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function run(url: string, args: string[]) {
|
|
147
|
+
const { key, host, port } = parseUrl(url);
|
|
148
|
+
const identity = await getClientIdentity();
|
|
149
|
+
console.error(
|
|
150
|
+
`[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
|
|
151
|
+
);
|
|
152
|
+
const res = await fetch(`http://${host}:${port}/run`, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
155
|
+
body: JSON.stringify({ args, identity }),
|
|
156
|
+
signal: AbortSignal.timeout(70_000),
|
|
157
|
+
}).catch((e) => {
|
|
158
|
+
console.error(`[rech] ${e.message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (res.status === 401) {
|
|
163
|
+
console.error("Unauthorized: bad key");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { status, stdout, stderr, files, descriptions, existingSession } = (await res.json()) as {
|
|
168
|
+
status: number;
|
|
169
|
+
stdout: string;
|
|
170
|
+
stderr: string;
|
|
171
|
+
files?: string[];
|
|
172
|
+
descriptions?: Record<string, string>;
|
|
173
|
+
existingSession?: boolean;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (existingSession) {
|
|
177
|
+
console.error(
|
|
178
|
+
`[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (stderr) process.stderr.write(stderr);
|
|
182
|
+
if (stdout) process.stdout.write(stdout);
|
|
183
|
+
|
|
184
|
+
if (files?.length) {
|
|
185
|
+
const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
|
|
186
|
+
mkdirSync(dlDir, { recursive: true });
|
|
187
|
+
const gitignorePath = join(dlDir, ".gitignore");
|
|
188
|
+
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
189
|
+
for (const name of files) {
|
|
190
|
+
const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
|
|
191
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
192
|
+
});
|
|
193
|
+
if (!fileRes.ok) continue;
|
|
194
|
+
const dest = join(dlDir, basename(name));
|
|
195
|
+
await Bun.write(dest, fileRes);
|
|
196
|
+
console.error(`[rech] downloaded: ${dest}`);
|
|
197
|
+
if (descriptions?.[name]) {
|
|
198
|
+
console.error(`[rech] vision: ${descriptions[name]}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
process.exit(status);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (import.meta.main) {
|
|
207
|
+
const args = process.argv.slice(2);
|
|
208
|
+
|
|
209
|
+
if (args[0] === "serve") {
|
|
210
|
+
const { serve } = await import("./serve.js");
|
|
211
|
+
serve();
|
|
212
|
+
} else {
|
|
213
|
+
const url = process.env[ENV_KEY];
|
|
214
|
+
if (!url) {
|
|
215
|
+
console.error(
|
|
216
|
+
`Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
|
|
217
|
+
);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
run(url, args);
|
|
221
|
+
}
|
|
222
|
+
}
|
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> {
|
package/serve.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
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 } 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
|
+
if (Array.isArray(body)) {
|
|
48
|
+
args = body;
|
|
49
|
+
const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
|
|
50
|
+
sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
|
|
51
|
+
clientName = clientAddr;
|
|
52
|
+
log(`session from client IP: ${clientAddr} -> ${sessionId}`);
|
|
53
|
+
} else {
|
|
54
|
+
args = body.args;
|
|
55
|
+
const id = body.identity as
|
|
56
|
+
| { gitUrl?: string; hostname?: string; cwd?: string }
|
|
57
|
+
| undefined;
|
|
58
|
+
const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
|
|
59
|
+
if (raw) {
|
|
60
|
+
sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
|
|
61
|
+
clientName = raw;
|
|
62
|
+
log(`session from identity: ${raw} -> ${sessionId}`);
|
|
63
|
+
} else {
|
|
64
|
+
const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
|
|
65
|
+
sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
|
|
66
|
+
clientName = clientAddr;
|
|
67
|
+
log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let clientSession = "";
|
|
72
|
+
const filteredArgs = args.filter((a) => {
|
|
73
|
+
const m = a.match(/^-s=(.+)$/);
|
|
74
|
+
if (m) {
|
|
75
|
+
clientSession = m[1];
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
81
|
+
|
|
82
|
+
const bin = process.env.PLAYWRIGHT_CLI || "playwright-cli";
|
|
83
|
+
|
|
84
|
+
if (filteredArgs.length === 0) {
|
|
85
|
+
filteredArgs.push("--help");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
|
|
89
|
+
|
|
90
|
+
// For open commands, check if this session already has tabs open
|
|
91
|
+
const isOpenCmd = filteredArgs[0] === "open";
|
|
92
|
+
if (isOpenCmd) {
|
|
93
|
+
try {
|
|
94
|
+
const listProc = Bun.spawn([bin, "tab-list", "--extension", `-s=${namespacedSession}`], {
|
|
95
|
+
cwd: workDir,
|
|
96
|
+
stdin: "ignore",
|
|
97
|
+
stdout: "pipe",
|
|
98
|
+
stderr: "pipe",
|
|
99
|
+
env: { PATH: process.env.PATH, HOME: process.env.HOME },
|
|
100
|
+
});
|
|
101
|
+
const [listStatus, listOut] = await Promise.all([
|
|
102
|
+
listProc.exited,
|
|
103
|
+
new Response(listProc.stdout).text(),
|
|
104
|
+
]);
|
|
105
|
+
if (listStatus === 0 && listOut.trim()) {
|
|
106
|
+
log(`session ${namespacedSession} already has tabs, returning tab-list hint`);
|
|
107
|
+
return Response.json({
|
|
108
|
+
status: 0,
|
|
109
|
+
stdout: listOut,
|
|
110
|
+
stderr: `[rech] session "${namespacedSession}" already has open tabs:\n`,
|
|
111
|
+
files: [],
|
|
112
|
+
existingSession: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
log(`tab-list check failed: ${e}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const childEnv: Record<string, string | undefined> = {
|
|
121
|
+
PATH: process.env.PATH,
|
|
122
|
+
HOME: process.env.HOME,
|
|
123
|
+
TMPDIR: process.env.TMPDIR,
|
|
124
|
+
DISPLAY: process.env.DISPLAY,
|
|
125
|
+
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
126
|
+
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
|
|
127
|
+
};
|
|
128
|
+
const proc = Bun.spawn([bin, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
|
|
129
|
+
cwd: workDir,
|
|
130
|
+
stdin: "ignore",
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
env: childEnv,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const TIMEOUT = 60_000;
|
|
137
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
proc.kill();
|
|
140
|
+
reject(new Error("timeout"));
|
|
141
|
+
}, TIMEOUT),
|
|
142
|
+
);
|
|
143
|
+
const [status, stdout, stderr] = await Promise.race([
|
|
144
|
+
Promise.all([
|
|
145
|
+
proc.exited,
|
|
146
|
+
new Response(proc.stdout).text(),
|
|
147
|
+
new Response(proc.stderr).text(),
|
|
148
|
+
]),
|
|
149
|
+
timeout.then(() => [1, "", ""] as [number, string, string]),
|
|
150
|
+
]).catch(
|
|
151
|
+
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
155
|
+
|
|
156
|
+
// Detect files mentioned in output
|
|
157
|
+
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
|
|
158
|
+
const mentionedFiles = [
|
|
159
|
+
...new Set(
|
|
160
|
+
[...stdout.matchAll(filePattern), ...stderr.matchAll(filePattern)].map((m) => m[0]),
|
|
161
|
+
),
|
|
162
|
+
];
|
|
163
|
+
const outputFiles: string[] = [];
|
|
164
|
+
for (const f of mentionedFiles) {
|
|
165
|
+
if (!isUnderDir(workDir, f)) continue;
|
|
166
|
+
if (await file(join(workDir, f)).exists()) {
|
|
167
|
+
outputFiles.push(f);
|
|
168
|
+
} else {
|
|
169
|
+
const basename = f.split("/").pop()!;
|
|
170
|
+
for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
|
|
171
|
+
const subpath = join(subdir, basename);
|
|
172
|
+
if (await file(join(workDir, subpath)).exists()) {
|
|
173
|
+
outputFiles.push(subpath);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Auto-describe screenshot files with Gemini vision
|
|
181
|
+
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;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rebrand = (s: string) => s.replaceAll("npx playwright-cli", "rech");
|
|
190
|
+
return Response.json({
|
|
191
|
+
status,
|
|
192
|
+
stdout: rebrand(stdout),
|
|
193
|
+
stderr: rebrand(stderr),
|
|
194
|
+
files: outputFiles,
|
|
195
|
+
descriptions,
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
log(`serving on http://${server.hostname}:${server.port}`);
|
|
201
|
+
log(`Connection URL set (use .env.local to view)`);
|
|
202
|
+
}
|
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
|
-
});
|