rechrome 1.5.0 → 1.7.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.
Files changed (6) hide show
  1. package/README.md +40 -20
  2. package/package.json +3 -3
  3. package/rech.js +608 -65
  4. package/rech.ts +608 -65
  5. package/serve.js +112 -13
  6. package/serve.ts +112 -13
package/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
- import { createHash } from "crypto";
3
- import { mkdirSync } from "fs";
2
+ import { createHash, X509Certificate } from "crypto";
3
+ import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -11,12 +11,59 @@ import {
11
11
  PASSTHROUGH_ENV_KEYS,
12
12
  } from "./rech.js";
13
13
 
14
+ const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
+ const CERT_RENEW_THRESHOLD_DAYS = 7;
16
+
17
+ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
+ const certContent = await file(certPath).text().catch(() => null);
19
+ if (!certContent) return false;
20
+ try {
21
+ const cert = new X509Certificate(certContent);
22
+ const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
23
+ if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
24
+ const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
25
+ if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
26
+ log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
27
+ const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
28
+ stdout: "pipe", stderr: "pipe",
29
+ });
30
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
31
+ if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
32
+ log(`TLS cert renewed for ${domain}`);
33
+ return true;
34
+ } catch (e) {
35
+ log(`TLS cert check error: ${e}`);
36
+ return false;
37
+ }
38
+ }
39
+
14
40
  export function isUnderDir(base: string, candidate: string): boolean {
15
41
  const absBase = resolve(base) + "/";
16
42
  const absCandidate = resolve(base, candidate);
17
43
  return absCandidate.startsWith(absBase);
18
44
  }
19
45
 
46
+ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
47
+ if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
48
+ const home = process.env.HOME || "~";
49
+ const candidates = [
50
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
51
+ join(home, ".config/google-chrome/Local State"),
52
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
53
+ ];
54
+ for (const statePath of candidates) {
55
+ const f = file(statePath);
56
+ if (!(await f.exists())) continue;
57
+ const data = JSON.parse(await f.text());
58
+ const cache: Record<string, any> = data?.profile?.info_cache ?? {};
59
+ for (const [dir, info] of Object.entries(cache)) {
60
+ if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
61
+ return dir;
62
+ }
63
+ }
64
+ return nameOrEmail;
65
+ }
66
+
20
67
  export async function serve() {
21
68
  const url = await getOrCreateUrl();
22
69
  const { key, port } = parseUrl(url);
@@ -25,9 +72,26 @@ export async function serve() {
25
72
  mkdirSync(workDir, { recursive: true });
26
73
 
27
74
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
75
+ const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
76
+ const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
77
+ const keyPath = canRead(process.env.RECH_TLS_KEY) ? process.env.RECH_TLS_KEY : undefined;
78
+ if (certPath && keyPath) {
79
+ const renewed = await renewCertIfNeeded(certPath, keyPath);
80
+ if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
81
+ // Check daily; pm2 restarts cleanly after exit(0)
82
+ setInterval(async () => {
83
+ if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
84
+ }, 86_400_000);
85
+ }
86
+ const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
28
87
  const server = Bun.serve({
29
88
  hostname: listenHost,
30
89
  port,
90
+ tls,
91
+ error(err) {
92
+ log(`unhandled error: ${err.message}`);
93
+ return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
94
+ },
31
95
  async fetch(req) {
32
96
  const reqUrl = new URL(req.url);
33
97
 
@@ -55,22 +119,23 @@ export async function serve() {
55
119
  if (Array.isArray(body)) {
56
120
  args = body;
57
121
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
58
- sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
122
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
59
123
  clientName = clientAddr;
60
124
  log(`session from client IP: ${clientAddr} -> ${sessionId}`);
61
125
  } else {
62
126
  args = body.args;
63
127
  const id = body.identity as
64
- | { gitUrl?: string; hostname?: string; cwd?: string }
128
+ | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
65
129
  | undefined;
66
- const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
130
+ const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
131
+ const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
67
132
  if (raw) {
68
- sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
133
+ sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 8);
69
134
  clientName = raw;
70
135
  log(`session from identity: ${raw} -> ${sessionId}`);
71
136
  } else {
72
137
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
73
- sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
138
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
74
139
  clientName = clientAddr;
75
140
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
76
141
  }
@@ -101,11 +166,15 @@ export async function serve() {
101
166
 
102
167
  log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
103
168
 
104
- // For open commands, check if this session already has tabs open
169
+ // For open commands, default to about:blank to avoid leaving connect.html visible
105
170
  const isOpenCmd = filteredArgs[0] === "open";
106
- if (isOpenCmd) {
171
+ if (isOpenCmd && filteredArgs.length === 1)
172
+ filteredArgs.push("about:blank");
173
+
174
+ // bare `rech open` with no URL: warn if session already has tabs
175
+ if (isOpenCmd && filteredArgs.length === 1) {
107
176
  try {
108
- const listProc = Bun.spawn([bin, ...binArgs, "tab-list", "--extension", `-s=${namespacedSession}`], {
177
+ const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
109
178
  cwd: workDir,
110
179
  stdin: "ignore",
111
180
  stdout: "pipe",
@@ -138,6 +207,14 @@ export async function serve() {
138
207
  }
139
208
  Object.assign(passthroughEnv, clientEnv);
140
209
 
210
+ // Resolve profile name/email → directory name
211
+ if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
212
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
213
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
214
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
215
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
216
+ }
217
+
141
218
  const childEnv: Record<string, string | undefined> = {
142
219
  PATH: process.env.PATH,
143
220
  HOME: process.env.HOME,
@@ -146,8 +223,30 @@ export async function serve() {
146
223
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
147
224
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
148
225
  ...passthroughEnv,
226
+ // Enable extension bridge when credentials are present
227
+ ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
228
+ ? { PLAYWRIGHT_MCP_EXTENSION: "1" }
229
+ : {}),
149
230
  };
150
- const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
231
+ // For open commands: clean up stale sockets so a closed browser can be reopened
232
+ if (isOpenCmd) {
233
+ const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
234
+ const playwrightTmpDir = `${tmpDir}/playwright-cli`;
235
+ try {
236
+ const { readdirSync } = await import("fs");
237
+ for (const sub of readdirSync(playwrightTmpDir)) {
238
+ const subDir = `${playwrightTmpDir}/${sub}`;
239
+ for (const f of readdirSync(subDir)) {
240
+ if (f.startsWith(namespacedSession)) {
241
+ const sockPath = `${subDir}/${f}`;
242
+ try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
243
+ }
244
+ }
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
151
250
  cwd: workDir,
152
251
  stdin: "ignore",
153
252
  stdout: "pipe",
@@ -171,7 +270,7 @@ export async function serve() {
171
270
  timeout.then(() => [1, "", ""] as [number, string, string]),
172
271
  ]).catch(
173
272
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
174
- );
273
+ ) as [number, string, string];
175
274
 
176
275
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
177
276
 
@@ -209,6 +308,6 @@ export async function serve() {
209
308
  },
210
309
  });
211
310
 
212
- log(`serving on http://${server.hostname}:${server.port}`);
311
+ log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
213
312
  log(`Connection URL set (use .env.local to view)`);
214
313
  }
package/serve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
- import { createHash } from "crypto";
3
- import { mkdirSync } from "fs";
2
+ import { createHash, X509Certificate } from "crypto";
3
+ import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -11,12 +11,59 @@ import {
11
11
  PASSTHROUGH_ENV_KEYS,
12
12
  } from "./rech.ts";
13
13
 
14
+ const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
+ const CERT_RENEW_THRESHOLD_DAYS = 7;
16
+
17
+ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
+ const certContent = await file(certPath).text().catch(() => null);
19
+ if (!certContent) return false;
20
+ try {
21
+ const cert = new X509Certificate(certContent);
22
+ const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
23
+ if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
24
+ const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
25
+ if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
26
+ log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
27
+ const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
28
+ stdout: "pipe", stderr: "pipe",
29
+ });
30
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
31
+ if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
32
+ log(`TLS cert renewed for ${domain}`);
33
+ return true;
34
+ } catch (e) {
35
+ log(`TLS cert check error: ${e}`);
36
+ return false;
37
+ }
38
+ }
39
+
14
40
  export function isUnderDir(base: string, candidate: string): boolean {
15
41
  const absBase = resolve(base) + "/";
16
42
  const absCandidate = resolve(base, candidate);
17
43
  return absCandidate.startsWith(absBase);
18
44
  }
19
45
 
46
+ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
47
+ if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
48
+ const home = process.env.HOME || "~";
49
+ const candidates = [
50
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
51
+ join(home, ".config/google-chrome/Local State"),
52
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
53
+ ];
54
+ for (const statePath of candidates) {
55
+ const f = file(statePath);
56
+ if (!(await f.exists())) continue;
57
+ const data = JSON.parse(await f.text());
58
+ const cache: Record<string, any> = data?.profile?.info_cache ?? {};
59
+ for (const [dir, info] of Object.entries(cache)) {
60
+ if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
61
+ return dir;
62
+ }
63
+ }
64
+ return nameOrEmail;
65
+ }
66
+
20
67
  export async function serve() {
21
68
  const url = await getOrCreateUrl();
22
69
  const { key, port } = parseUrl(url);
@@ -25,9 +72,26 @@ export async function serve() {
25
72
  mkdirSync(workDir, { recursive: true });
26
73
 
27
74
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
75
+ const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
76
+ const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
77
+ const keyPath = canRead(process.env.RECH_TLS_KEY) ? process.env.RECH_TLS_KEY : undefined;
78
+ if (certPath && keyPath) {
79
+ const renewed = await renewCertIfNeeded(certPath, keyPath);
80
+ if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
81
+ // Check daily; pm2 restarts cleanly after exit(0)
82
+ setInterval(async () => {
83
+ if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
84
+ }, 86_400_000);
85
+ }
86
+ const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
28
87
  const server = Bun.serve({
29
88
  hostname: listenHost,
30
89
  port,
90
+ tls,
91
+ error(err) {
92
+ log(`unhandled error: ${err.message}`);
93
+ return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
94
+ },
31
95
  async fetch(req) {
32
96
  const reqUrl = new URL(req.url);
33
97
 
@@ -55,22 +119,23 @@ export async function serve() {
55
119
  if (Array.isArray(body)) {
56
120
  args = body;
57
121
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
58
- sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
122
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
59
123
  clientName = clientAddr;
60
124
  log(`session from client IP: ${clientAddr} -> ${sessionId}`);
61
125
  } else {
62
126
  args = body.args;
63
127
  const id = body.identity as
64
- | { gitUrl?: string; hostname?: string; cwd?: string }
128
+ | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
65
129
  | undefined;
66
- const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
130
+ const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
131
+ const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
67
132
  if (raw) {
68
- sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
133
+ sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 8);
69
134
  clientName = raw;
70
135
  log(`session from identity: ${raw} -> ${sessionId}`);
71
136
  } else {
72
137
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
73
- sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 12);
138
+ sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
74
139
  clientName = clientAddr;
75
140
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
76
141
  }
@@ -101,11 +166,15 @@ export async function serve() {
101
166
 
102
167
  log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
103
168
 
104
- // For open commands, check if this session already has tabs open
169
+ // For open commands, default to about:blank to avoid leaving connect.html visible
105
170
  const isOpenCmd = filteredArgs[0] === "open";
106
- if (isOpenCmd) {
171
+ if (isOpenCmd && filteredArgs.length === 1)
172
+ filteredArgs.push("about:blank");
173
+
174
+ // bare `rech open` with no URL: warn if session already has tabs
175
+ if (isOpenCmd && filteredArgs.length === 1) {
107
176
  try {
108
- const listProc = Bun.spawn([bin, ...binArgs, "tab-list", "--extension", `-s=${namespacedSession}`], {
177
+ const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
109
178
  cwd: workDir,
110
179
  stdin: "ignore",
111
180
  stdout: "pipe",
@@ -138,6 +207,14 @@ export async function serve() {
138
207
  }
139
208
  Object.assign(passthroughEnv, clientEnv);
140
209
 
210
+ // Resolve profile name/email → directory name
211
+ if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
212
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
213
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
214
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
215
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
216
+ }
217
+
141
218
  const childEnv: Record<string, string | undefined> = {
142
219
  PATH: process.env.PATH,
143
220
  HOME: process.env.HOME,
@@ -146,8 +223,30 @@ export async function serve() {
146
223
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
147
224
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
148
225
  ...passthroughEnv,
226
+ // Enable extension bridge when credentials are present
227
+ ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
228
+ ? { PLAYWRIGHT_MCP_EXTENSION: "1" }
229
+ : {}),
149
230
  };
150
- const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
231
+ // For open commands: clean up stale sockets so a closed browser can be reopened
232
+ if (isOpenCmd) {
233
+ const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
234
+ const playwrightTmpDir = `${tmpDir}/playwright-cli`;
235
+ try {
236
+ const { readdirSync } = await import("fs");
237
+ for (const sub of readdirSync(playwrightTmpDir)) {
238
+ const subDir = `${playwrightTmpDir}/${sub}`;
239
+ for (const f of readdirSync(subDir)) {
240
+ if (f.startsWith(namespacedSession)) {
241
+ const sockPath = `${subDir}/${f}`;
242
+ try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
243
+ }
244
+ }
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
151
250
  cwd: workDir,
152
251
  stdin: "ignore",
153
252
  stdout: "pipe",
@@ -171,7 +270,7 @@ export async function serve() {
171
270
  timeout.then(() => [1, "", ""] as [number, string, string]),
172
271
  ]).catch(
173
272
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
174
- );
273
+ ) as [number, string, string];
175
274
 
176
275
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
177
276
 
@@ -209,6 +308,6 @@ export async function serve() {
209
308
  },
210
309
  });
211
310
 
212
- log(`serving on http://${server.hostname}:${server.port}`);
311
+ log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
213
312
  log(`Connection URL set (use .env.local to view)`);
214
313
  }