rechrome 0.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 ADDED
@@ -0,0 +1,12 @@
1
+ # Remote Chrome connection URL (auto-generated by `rech serve` if not set)
2
+ # REMOTE_CHROME_URL=remote-chrome://key@host:13775
3
+
4
+ # Gemini API key for screenshot vision descriptions (optional)
5
+ # GEMINI_API_KEY=your-gemini-api-key
6
+
7
+ # Custom playwright-cli binary path (default: playwright-cli)
8
+ # For full multi-tab & multi-session support, use playwright-cli-multi-tab:
9
+ # PLAYWRIGHT_CLI=playwright-cli-multi-tab
10
+
11
+ # Bind address for the server (default: 127.0.0.1, use 0.0.0.0 for remote access)
12
+ # RECH_HOST=127.0.0.1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sno
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # rech — Remote Chrome
2
+
3
+ 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
+
5
+ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — a patched Playwright fork with multi-tab and multi-session browser automation.
6
+
7
+ ## Features
8
+
9
+ - **Session isolation** — clients are automatically namespaced by git repo or hostname
10
+ - **File transfer** — screenshots and PDFs are automatically downloaded to the client
11
+ - **Vision descriptions** — optional Gemini-powered screenshot descriptions
12
+ - **Hot-reload config** — `.env.local` changes are picked up without restart
13
+ - **Security** — bearer auth, path traversal protection, env allowlisting for child processes
14
+
15
+ ## Prerequisites
16
+
17
+ - [Bun](https://bun.sh/) ≥ 1.0
18
+ - [playwright-cli](https://www.npmjs.com/package/playwright-cli) (works out of the box)
19
+
20
+ > **💡 Recommended:** For full multi-tab and multi-session support, install [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) and set `PLAYWRIGHT_CLI=playwright-cli-multi-tab` in your `.env.local`.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ # Clone and link globally
26
+ git clone https://github.com/snomiao/rech.git
27
+ cd rech
28
+ bun install
29
+ bun link
30
+ ```
31
+
32
+ Now `rech` is available globally.
33
+
34
+ ## Quick start
35
+
36
+ ### 1. Start the server
37
+
38
+ On the machine with a browser:
39
+
40
+ ```bash
41
+ rech serve
42
+ ```
43
+
44
+ This auto-generates a connection URL in `.env.local` (with an auth key).
45
+
46
+ ### 2. Run commands from a client
47
+
48
+ Copy the `REMOTE_CHROME_URL` from the server's `.env.local` to the client:
49
+
50
+ ```bash
51
+ export REMOTE_CHROME_URL=remote-chrome://YOUR_KEY@server-host:13775
52
+
53
+ # Open a URL
54
+ rech open https://example.com
55
+
56
+ # Take a screenshot
57
+ rech screenshot
58
+
59
+ # List open tabs
60
+ rech tab-list
61
+
62
+ # Any playwright-cli command works
63
+ rech --help
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ Copy `.env.example` to `.env.local` and edit:
69
+
70
+ ```bash
71
+ cp .env.example .env.local
72
+ ```
73
+
74
+ | Variable | Description | Default |
75
+ |---|---|---|
76
+ | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rech serve`) | — |
77
+ | `GEMINI_API_KEY` | Gemini API key for screenshot vision descriptions | — |
78
+ | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
79
+ | `RECH_HOST` | Server bind address | `127.0.0.1` |
80
+
81
+ ### Remote access
82
+
83
+ By default the server binds to `127.0.0.1` (localhost only). For remote access, either:
84
+
85
+ - Use an SSH tunnel: `ssh -L 13775:localhost:13775 server-host`
86
+ - Or set `RECH_HOST=0.0.0.0` (⚠️ ensure network is trusted — traffic is plain HTTP)
87
+
88
+ ## Session namespacing
89
+
90
+ Each client gets an isolated browser session based on:
91
+
92
+ 1. **Git repo URL + branch** (if in a git repo)
93
+ 2. **Hostname + working directory** (fallback)
94
+
95
+ Clients can also pass `-s=name` to create named sub-sessions within their namespace.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ bun install
101
+ bun test
102
+ ```
103
+
104
+ ## Related
105
+
106
+ - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rech's multi-tab and multi-session browser control
107
+
108
+ ## License
109
+
110
+ MIT
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "rechrome",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "rech": "./rech.ts"
7
+ },
8
+ "scripts": {
9
+ "serve": "bun run rech.ts serve",
10
+ "test": "bun test"
11
+ },
12
+ "devDependencies": {
13
+ "@types/bun": "latest"
14
+ }
15
+ }
package/rech.spec.ts ADDED
@@ -0,0 +1,78 @@
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/rech.ts ADDED
@@ -0,0 +1,220 @@
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
+ // Load .env.local from script's directory (works even when invoked from elsewhere)
10
+ const envFile = join(import.meta.dir, ".env.local");
11
+
12
+ /** Load .env.local into process.env. */
13
+ async function loadEnv() {
14
+ const envRaw = await file(envFile)
15
+ .text()
16
+ .catch(() => "");
17
+ for (const line of envRaw.split("\n")) {
18
+ const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
19
+ if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
20
+ }
21
+ }
22
+ await loadEnv();
23
+
24
+ // Watch .env.local for changes and hot-reload
25
+ import { watch } from "node:fs";
26
+ watch(envFile, async () => {
27
+ log(".env.local changed, reloading");
28
+ await loadEnv();
29
+ });
30
+
31
+ export const ENV_KEY = "REMOTE_CHROME_URL";
32
+ export const DEFAULT_PORT = 13775;
33
+ export const RECH_DIR = join(import.meta.dir, ".rech");
34
+ export const LOG_DIR = join(RECH_DIR, "logs");
35
+
36
+ /** Describe an image using Gemini vision API. Returns description or null on failure. */
37
+ export async function describeImage(imagePath: string): Promise<string | null> {
38
+ const apiKey = process.env.GEMINI_API_KEY;
39
+ if (!apiKey) return null;
40
+ try {
41
+ const imageData = await file(imagePath).arrayBuffer();
42
+ const base64 = Buffer.from(imageData).toString("base64");
43
+ const mimeType = imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
44
+ const res = await fetch(
45
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent?key=${apiKey}`,
46
+ {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ contents: [
51
+ {
52
+ parts: [
53
+ {
54
+ text: "Describe this browser screenshot concisely in 2-3 sentences. Focus on what's visible: page layout, content, any errors or issues.",
55
+ },
56
+ { inline_data: { mime_type: mimeType, data: base64 } },
57
+ ],
58
+ },
59
+ ],
60
+ }),
61
+ },
62
+ );
63
+ if (!res.ok) return null;
64
+ const json = await res.json();
65
+ return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function log(msg: string) {
72
+ mkdirSync(LOG_DIR, { recursive: true });
73
+ const ts = new Date().toISOString();
74
+ const line = `[${ts}] ${msg}\n`;
75
+ console.error(line.trimEnd());
76
+ const logFile = join(LOG_DIR, `${ts.slice(0, 10)}.log`);
77
+ appendFileSync(logFile, line);
78
+ }
79
+
80
+ export function parseUrl(raw: string) {
81
+ const u = new URL(raw);
82
+ return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
83
+ }
84
+
85
+ export async function getOrCreateUrl(): Promise<string> {
86
+ if (process.env[ENV_KEY]) return process.env[ENV_KEY];
87
+ const key = randomBytes(9).toString("base64url"); // 12 chars
88
+ const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
89
+ const newLine = `${ENV_KEY}=${url}`;
90
+ const envRaw = await file(envFile)
91
+ .text()
92
+ .catch(() => "");
93
+ const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
94
+ Bun.write(envFile, content);
95
+ process.env[ENV_KEY] = url;
96
+ return url;
97
+ }
98
+
99
+ export function authCheck(req: Request, key: string): Response | null {
100
+ const bearer = req.headers.get("authorization")?.replace("Bearer ", "");
101
+ if (bearer !== key) return new Response("Unauthorized", { status: 401 });
102
+ return null;
103
+ }
104
+
105
+ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
106
+ const cwd = process.cwd();
107
+ try {
108
+ const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
109
+ cwd,
110
+ stdout: "pipe",
111
+ stderr: "ignore",
112
+ });
113
+ const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
114
+ await remoteProc.exited;
115
+
116
+ const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
117
+ cwd,
118
+ stdout: "pipe",
119
+ stderr: "ignore",
120
+ });
121
+ const branch = (await new Response(branchProc.stdout).text()).trim();
122
+ await branchProc.exited;
123
+
124
+ if (remoteUrl) {
125
+ let gitUrl: string;
126
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
127
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
128
+ if (sshMatch) {
129
+ gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
130
+ } else if (httpsMatch) {
131
+ gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
132
+ } else {
133
+ gitUrl = remoteUrl.replace(/\.git$/, "");
134
+ }
135
+ if (branch) gitUrl += `/tree/${branch}`;
136
+ // Strip any embedded credentials from the URL
137
+ try { const u = new URL(gitUrl); u.username = ""; u.password = ""; gitUrl = u.toString(); } catch {}
138
+ return { gitUrl };
139
+ }
140
+ } catch {}
141
+ return { hostname: hostname(), cwd };
142
+ }
143
+
144
+ async function run(url: string, args: string[]) {
145
+ const { key, host, port } = parseUrl(url);
146
+ const identity = await getClientIdentity();
147
+ console.error(
148
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
149
+ );
150
+ const res = await fetch(`http://${host}:${port}/run`, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
153
+ body: JSON.stringify({ args, identity }),
154
+ signal: AbortSignal.timeout(70_000),
155
+ }).catch((e) => {
156
+ console.error(`[rech] ${e.message}`);
157
+ process.exit(1);
158
+ });
159
+
160
+ if (res.status === 401) {
161
+ console.error("Unauthorized: bad key");
162
+ process.exit(1);
163
+ }
164
+
165
+ const { status, stdout, stderr, files, descriptions, existingSession } = (await res.json()) as {
166
+ status: number;
167
+ stdout: string;
168
+ stderr: string;
169
+ files?: string[];
170
+ descriptions?: Record<string, string>;
171
+ existingSession?: boolean;
172
+ };
173
+
174
+ if (existingSession) {
175
+ console.error(
176
+ `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
177
+ );
178
+ }
179
+ if (stderr) process.stderr.write(stderr);
180
+ if (stdout) process.stdout.write(stdout);
181
+
182
+ if (files?.length) {
183
+ const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
184
+ mkdirSync(dlDir, { recursive: true });
185
+ const gitignorePath = join(dlDir, ".gitignore");
186
+ if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
187
+ for (const name of files) {
188
+ const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
189
+ headers: { Authorization: `Bearer ${key}` },
190
+ });
191
+ if (!fileRes.ok) continue;
192
+ const dest = join(dlDir, basename(name));
193
+ await Bun.write(dest, fileRes);
194
+ console.error(`[rech] downloaded: ${dest}`);
195
+ if (descriptions?.[name]) {
196
+ console.error(`[rech] vision: ${descriptions[name]}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ process.exit(status);
202
+ }
203
+
204
+ if (import.meta.main) {
205
+ const args = process.argv.slice(2);
206
+
207
+ if (args[0] === "serve") {
208
+ const { serve } = await import("./serve.ts");
209
+ serve();
210
+ } else {
211
+ const url = process.env[ENV_KEY];
212
+ if (!url) {
213
+ console.error(
214
+ `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
215
+ );
216
+ process.exit(1);
217
+ }
218
+ run(url, args);
219
+ }
220
+ }
package/serve.spec.ts ADDED
@@ -0,0 +1,50 @@
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
+ });
package/serve.ts 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.ts";
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
+ }