rechrome 1.20.0 → 1.21.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 (5) hide show
  1. package/package.json +1 -1
  2. package/rech.js +126 -43
  3. package/rech.ts +126 -43
  4. package/serve.js +102 -22
  5. package/serve.ts +102 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -194,48 +194,116 @@ export function authCheck(req: Request, key: string): Response | null {
194
194
  return null;
195
195
  }
196
196
 
197
- async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
198
- const cwd = process.cwd();
197
+ function realpathSafe(p: string): string {
198
+ try { return realpathSync(p); } catch { return p; }
199
+ }
200
+
201
+ async function gitOutput(args: string[], cwd: string): Promise<string | null> {
199
202
  try {
200
- const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
201
- cwd,
202
- stdout: "pipe",
203
- stderr: "ignore",
204
- });
205
- const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
206
- await remoteProc.exited;
203
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
204
+ const out = (await new Response(proc.stdout).text()).trim();
205
+ await proc.exited;
206
+ return out || null;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
207
211
 
208
- const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
209
- cwd,
210
- stdout: "pipe",
211
- stderr: "ignore",
212
- });
213
- const branch = (await new Response(branchProc.stdout).text()).trim();
214
- await branchProc.exited;
215
-
216
- if (remoteUrl) {
217
- let gitUrl: string;
218
- const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
219
- const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
220
- if (sshMatch) {
221
- gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
222
- } else if (httpsMatch) {
223
- gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
224
- } else {
225
- gitUrl = remoteUrl.replace(/\.git$/, "");
226
- }
227
- if (branch) gitUrl += `/tree/${branch}`;
228
- // Strip any embedded credentials from the URL
229
- try {
230
- const u = new URL(gitUrl);
231
- u.username = "";
232
- u.password = "";
233
- gitUrl = u.toString();
234
- } catch {}
235
- return { gitUrl };
212
+ // Normalize a git remote URL to "host/owner/repo" — no scheme, no .git, no credentials.
213
+ export function normalizeRemote(remoteUrl: string): string {
214
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
215
+ const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?$/);
216
+ if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
217
+ if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
218
+ return remoteUrl.replace(/^[^/]*:\/\//, "").replace(/^[^@]*@/, "").replace(/\.git$/, "");
219
+ }
220
+
221
+ // Derive the session bucket KEY (what the server hashes) and a human LABEL (logs / tab group)
222
+ // from already-gathered git facts. KEY and LABEL are deliberately decoupled: the key is keyed
223
+ // on the *worktree root path* for predictability (a human can tell which browser they drive),
224
+ // while the label renders a pretty <remote>#<basename>@<branch> for display only.
225
+ // - mode "worktree" (default): key = realpath(worktree root). Stable across `cd` within the
226
+ // project, distinct per worktree (no same-branch collisions), survives `git checkout`
227
+ // (no mutable-branch surprise), and has no branch so detached HEAD doesn't degrade it.
228
+ // - mode "branch": legacy opt-in — key = <remote>/tree/<branch> (the old behavior).
229
+ // - mode "cwd": key = realpath(cwd).
230
+ // Pass realpath'd `cwd` and `root`.
231
+ export function deriveIdentity(opts: {
232
+ mode: string;
233
+ cwd: string;
234
+ host: string;
235
+ root?: string | null; // worktree root (already realpath'd), null when not in git
236
+ remote?: string | null; // normalized host/owner/repo
237
+ branch?: string | null; // branch name, or short SHA when detached
238
+ }): { key: string; label: string } {
239
+ const { mode, cwd, host } = opts;
240
+ const root = opts.root || null;
241
+ const remote = opts.remote || null;
242
+ const branch = opts.branch || null;
243
+
244
+ let key: string;
245
+ if (mode === "branch") {
246
+ key = remote ? `https://${remote}${branch ? `/tree/${branch}` : ""}` : `${host}:${cwd}`;
247
+ } else if (mode === "cwd") {
248
+ key = `cwd:${cwd}`;
249
+ } else {
250
+ key = `worktree:${root || cwd}`;
251
+ }
252
+
253
+ let label: string;
254
+ if (root) {
255
+ label = `${remote ? `${remote}#` : ""}${basename(root)}${branch ? `@${branch}` : ""}`;
256
+ } else if (remote) {
257
+ label = `${remote}${branch ? `/tree/${branch}` : ""}`;
258
+ } else {
259
+ label = `${host}:${cwd}`;
260
+ }
261
+ return { key, label };
262
+ }
263
+
264
+ async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
265
+ const cwd = realpathSafe(process.cwd());
266
+ const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
267
+ let root: string | null = null;
268
+ let remote: string | null = null;
269
+ let branch: string | null = null;
270
+
271
+ const top = await gitOutput(["rev-parse", "--show-toplevel"], cwd);
272
+ if (top) {
273
+ // Roll a submodule cwd up to the outermost superproject working tree, so submodule work
274
+ // shares the parent worktree's browser session (monorepo-friendly). Bounded loop guards
275
+ // against pathological nesting.
276
+ let superCwd = top;
277
+ for (let i = 0; i < 16; i++) {
278
+ const sup = await gitOutput(["rev-parse", "--show-superproject-working-tree"], superCwd);
279
+ if (!sup) break;
280
+ superCwd = sup;
236
281
  }
237
- } catch {}
238
- return { hostname: hostname(), cwd };
282
+ root = realpathSafe(superCwd);
283
+ branch = await gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], root);
284
+ if (!branch || branch === "HEAD")
285
+ branch = await gitOutput(["rev-parse", "--short", "HEAD"], root); // detached HEAD
286
+ const remoteUrl = await gitOutput(["remote", "get-url", "origin"], root);
287
+ if (remoteUrl) remote = normalizeRemote(remoteUrl);
288
+ }
289
+
290
+ return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
291
+ }
292
+
293
+ // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
294
+ // PLAYWRIGHT_MCP_PROFILE_DIRECTORY shell env is the fallback. When both are set and differ,
295
+ // warn once — a silent mismatch here is how an OAuth/login flow can target the WRONG account.
296
+ let _profileMismatchWarned = false;
297
+ function resolveEffectiveProfile(urlProfile?: string): string | undefined {
298
+ const envProfile = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
299
+ if (urlProfile && envProfile && urlProfile !== envProfile && !_profileMismatchWarned) {
300
+ _profileMismatchWarned = true;
301
+ console.error(
302
+ `[rech] warning: profile mismatch — RECHROME_URL profile="${urlProfile}" wins over ` +
303
+ `PLAYWRIGHT_MCP_PROFILE_DIRECTORY="${envProfile}". Unset the env var to silence this.`,
304
+ );
305
+ }
306
+ return urlProfile || envProfile;
239
307
  }
240
308
 
241
309
  async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string; loadExtension?: string }): Promise<Record<string, string>> {
@@ -429,8 +497,8 @@ async function callServe(
429
497
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
430
498
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
431
499
  const identity = await getClientIdentity();
432
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
433
- if (effectiveProfile) (identity as any).profile = effectiveProfile;
500
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
+ if (effectiveProfile) identity.profile = effectiveProfile;
434
502
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
435
503
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
436
504
  method: "POST",
@@ -468,12 +536,12 @@ async function callServe(
468
536
 
469
537
  async function run(url: string, args: string[]) {
470
538
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
471
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
539
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
472
540
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
473
541
  const identity = await getClientIdentity();
474
542
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
475
543
  console.error(
476
- `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
544
+ `[rech] connecting to ${host}:${port} (identity: ${identity.label}${profileSuffix})`,
477
545
  );
478
546
 
479
547
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
@@ -1396,10 +1464,16 @@ Usage:
1396
1464
  rech serve Start the serve server manually (foreground)
1397
1465
  rech profiles List Chrome profiles
1398
1466
  rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
1467
+ rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
1468
+ fragile single-shot flow (OAuth/login) never shares tabs
1469
+ with the worktree's default session
1399
1470
 
1400
1471
  Environment:
1401
1472
  ${ENV_KEY} Server URL set by \`rech setup\`
1402
1473
  RECH_TOKEN Auth token for \`rech setup\` (same as --token)
1474
+ RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
1475
+ client reuses is keyed on the worktree root path; \`branch\` restores the
1476
+ old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
1403
1477
 
1404
1478
  Examples:
1405
1479
  rech setup
@@ -1468,6 +1542,15 @@ if (import.meta.main) {
1468
1542
  printHelp();
1469
1543
  process.exit(1);
1470
1544
  }
1545
+ // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1546
+ // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1547
+ // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
1548
+ // OAuth drive can't leak an orphaned browser context.
1549
+ const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
1550
+ if (isolateIdx !== -1) {
1551
+ args.splice(isolateIdx, 1);
1552
+ args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1553
+ }
1471
1554
  await run(url, args);
1472
1555
  envWatcher?.close();
1473
1556
  }
package/rech.ts CHANGED
@@ -194,48 +194,116 @@ export function authCheck(req: Request, key: string): Response | null {
194
194
  return null;
195
195
  }
196
196
 
197
- async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
198
- const cwd = process.cwd();
197
+ function realpathSafe(p: string): string {
198
+ try { return realpathSync(p); } catch { return p; }
199
+ }
200
+
201
+ async function gitOutput(args: string[], cwd: string): Promise<string | null> {
199
202
  try {
200
- const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
201
- cwd,
202
- stdout: "pipe",
203
- stderr: "ignore",
204
- });
205
- const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
206
- await remoteProc.exited;
203
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
204
+ const out = (await new Response(proc.stdout).text()).trim();
205
+ await proc.exited;
206
+ return out || null;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
207
211
 
208
- const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
209
- cwd,
210
- stdout: "pipe",
211
- stderr: "ignore",
212
- });
213
- const branch = (await new Response(branchProc.stdout).text()).trim();
214
- await branchProc.exited;
215
-
216
- if (remoteUrl) {
217
- let gitUrl: string;
218
- const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
219
- const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
220
- if (sshMatch) {
221
- gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
222
- } else if (httpsMatch) {
223
- gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
224
- } else {
225
- gitUrl = remoteUrl.replace(/\.git$/, "");
226
- }
227
- if (branch) gitUrl += `/tree/${branch}`;
228
- // Strip any embedded credentials from the URL
229
- try {
230
- const u = new URL(gitUrl);
231
- u.username = "";
232
- u.password = "";
233
- gitUrl = u.toString();
234
- } catch {}
235
- return { gitUrl };
212
+ // Normalize a git remote URL to "host/owner/repo" — no scheme, no .git, no credentials.
213
+ export function normalizeRemote(remoteUrl: string): string {
214
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
215
+ const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?$/);
216
+ if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
217
+ if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
218
+ return remoteUrl.replace(/^[^/]*:\/\//, "").replace(/^[^@]*@/, "").replace(/\.git$/, "");
219
+ }
220
+
221
+ // Derive the session bucket KEY (what the server hashes) and a human LABEL (logs / tab group)
222
+ // from already-gathered git facts. KEY and LABEL are deliberately decoupled: the key is keyed
223
+ // on the *worktree root path* for predictability (a human can tell which browser they drive),
224
+ // while the label renders a pretty <remote>#<basename>@<branch> for display only.
225
+ // - mode "worktree" (default): key = realpath(worktree root). Stable across `cd` within the
226
+ // project, distinct per worktree (no same-branch collisions), survives `git checkout`
227
+ // (no mutable-branch surprise), and has no branch so detached HEAD doesn't degrade it.
228
+ // - mode "branch": legacy opt-in — key = <remote>/tree/<branch> (the old behavior).
229
+ // - mode "cwd": key = realpath(cwd).
230
+ // Pass realpath'd `cwd` and `root`.
231
+ export function deriveIdentity(opts: {
232
+ mode: string;
233
+ cwd: string;
234
+ host: string;
235
+ root?: string | null; // worktree root (already realpath'd), null when not in git
236
+ remote?: string | null; // normalized host/owner/repo
237
+ branch?: string | null; // branch name, or short SHA when detached
238
+ }): { key: string; label: string } {
239
+ const { mode, cwd, host } = opts;
240
+ const root = opts.root || null;
241
+ const remote = opts.remote || null;
242
+ const branch = opts.branch || null;
243
+
244
+ let key: string;
245
+ if (mode === "branch") {
246
+ key = remote ? `https://${remote}${branch ? `/tree/${branch}` : ""}` : `${host}:${cwd}`;
247
+ } else if (mode === "cwd") {
248
+ key = `cwd:${cwd}`;
249
+ } else {
250
+ key = `worktree:${root || cwd}`;
251
+ }
252
+
253
+ let label: string;
254
+ if (root) {
255
+ label = `${remote ? `${remote}#` : ""}${basename(root)}${branch ? `@${branch}` : ""}`;
256
+ } else if (remote) {
257
+ label = `${remote}${branch ? `/tree/${branch}` : ""}`;
258
+ } else {
259
+ label = `${host}:${cwd}`;
260
+ }
261
+ return { key, label };
262
+ }
263
+
264
+ async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
265
+ const cwd = realpathSafe(process.cwd());
266
+ const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
267
+ let root: string | null = null;
268
+ let remote: string | null = null;
269
+ let branch: string | null = null;
270
+
271
+ const top = await gitOutput(["rev-parse", "--show-toplevel"], cwd);
272
+ if (top) {
273
+ // Roll a submodule cwd up to the outermost superproject working tree, so submodule work
274
+ // shares the parent worktree's browser session (monorepo-friendly). Bounded loop guards
275
+ // against pathological nesting.
276
+ let superCwd = top;
277
+ for (let i = 0; i < 16; i++) {
278
+ const sup = await gitOutput(["rev-parse", "--show-superproject-working-tree"], superCwd);
279
+ if (!sup) break;
280
+ superCwd = sup;
236
281
  }
237
- } catch {}
238
- return { hostname: hostname(), cwd };
282
+ root = realpathSafe(superCwd);
283
+ branch = await gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], root);
284
+ if (!branch || branch === "HEAD")
285
+ branch = await gitOutput(["rev-parse", "--short", "HEAD"], root); // detached HEAD
286
+ const remoteUrl = await gitOutput(["remote", "get-url", "origin"], root);
287
+ if (remoteUrl) remote = normalizeRemote(remoteUrl);
288
+ }
289
+
290
+ return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
291
+ }
292
+
293
+ // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
294
+ // PLAYWRIGHT_MCP_PROFILE_DIRECTORY shell env is the fallback. When both are set and differ,
295
+ // warn once — a silent mismatch here is how an OAuth/login flow can target the WRONG account.
296
+ let _profileMismatchWarned = false;
297
+ function resolveEffectiveProfile(urlProfile?: string): string | undefined {
298
+ const envProfile = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
299
+ if (urlProfile && envProfile && urlProfile !== envProfile && !_profileMismatchWarned) {
300
+ _profileMismatchWarned = true;
301
+ console.error(
302
+ `[rech] warning: profile mismatch — RECHROME_URL profile="${urlProfile}" wins over ` +
303
+ `PLAYWRIGHT_MCP_PROFILE_DIRECTORY="${envProfile}". Unset the env var to silence this.`,
304
+ );
305
+ }
306
+ return urlProfile || envProfile;
239
307
  }
240
308
 
241
309
  async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string; loadExtension?: string }): Promise<Record<string, string>> {
@@ -429,8 +497,8 @@ async function callServe(
429
497
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
430
498
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
431
499
  const identity = await getClientIdentity();
432
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
433
- if (effectiveProfile) (identity as any).profile = effectiveProfile;
500
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
501
+ if (effectiveProfile) identity.profile = effectiveProfile;
434
502
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
435
503
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
436
504
  method: "POST",
@@ -468,12 +536,12 @@ async function callServe(
468
536
 
469
537
  async function run(url: string, args: string[]) {
470
538
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
471
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
539
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
472
540
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
473
541
  const identity = await getClientIdentity();
474
542
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
475
543
  console.error(
476
- `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
544
+ `[rech] connecting to ${host}:${port} (identity: ${identity.label}${profileSuffix})`,
477
545
  );
478
546
 
479
547
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
@@ -1396,10 +1464,16 @@ Usage:
1396
1464
  rech serve Start the serve server manually (foreground)
1397
1465
  rech profiles List Chrome profiles
1398
1466
  rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
1467
+ rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
1468
+ fragile single-shot flow (OAuth/login) never shares tabs
1469
+ with the worktree's default session
1399
1470
 
1400
1471
  Environment:
1401
1472
  ${ENV_KEY} Server URL set by \`rech setup\`
1402
1473
  RECH_TOKEN Auth token for \`rech setup\` (same as --token)
1474
+ RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
1475
+ client reuses is keyed on the worktree root path; \`branch\` restores the
1476
+ old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
1403
1477
 
1404
1478
  Examples:
1405
1479
  rech setup
@@ -1468,6 +1542,15 @@ if (import.meta.main) {
1468
1542
  printHelp();
1469
1543
  process.exit(1);
1470
1544
  }
1545
+ // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1546
+ // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1547
+ // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
1548
+ // OAuth drive can't leak an orphaned browser context.
1549
+ const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
1550
+ if (isolateIdx !== -1) {
1551
+ args.splice(isolateIdx, 1);
1552
+ args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1553
+ }
1471
1554
  await run(url, args);
1472
1555
  envWatcher?.close();
1473
1556
  }
package/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
2
  import { createHash, X509Certificate } from "crypto";
3
- import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
3
+ import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -16,25 +16,91 @@ import {
16
16
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
17
17
  const CERT_RENEW_THRESHOLD_DAYS = 7;
18
18
 
19
- // Short label for a client identity, used as the Chrome tab-group name (the tab
20
- // strip is space-constrained, so cap at 7 chars). gitUrl ".../owner/repo/tree/branch"
21
- // -> "rep:bra" (3+3); "host:/path/to/dir" -> "dir"; bare host/IP -> as-is. Strips a
22
- // trailing "@profile" suffix first.
23
- const MAX_GROUP_LABEL_LEN = 7;
24
- function shortClientLabel(raw: string): string {
19
+ // Short label for a client identity, used as the Chrome tab-group name (the tab strip is
20
+ // space-constrained, so cap at 7 chars). Handles the current label shape and the legacy gitUrl:
21
+ // "host/owner/repo#<basename>@<branch>" -> "bas:bra" (3+3) (current)
22
+ // "host/owner/repo/tree/branch" -> "rep:bra" (3+3) (legacy gitUrl)
23
+ // "host:/path/to/dir" -> "dir" (non-git)
24
+ // bare host/IP -> as-is
25
+ export const MAX_GROUP_LABEL_LEN = 7;
26
+ export function shortClientLabel(raw: string): string {
25
27
  if (!raw) return raw;
26
- const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
27
- const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
28
- let label: string;
29
- if (git)
30
- label = git[2] ? `${git[1].slice(0, 3)}:${git[2].slice(0, 3)}` : git[1];
31
- else {
32
- const hostCwd = baseId.match(/^[^:]+:(.+)$/);
33
- label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || baseId) : baseId;
28
+ const join3 = (a: string, b?: string) => (b ? `${a.slice(0, 3)}:${b.slice(0, 3)}` : a);
29
+ // Current label: "[remote#]<worktree-basename>[@<branch>]"
30
+ if (raw.includes("#") || (raw.includes("@") && !raw.startsWith("http"))) {
31
+ const afterHash = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
32
+ const [base, branch] = afterHash.split("@");
33
+ return join3(base, branch).slice(0, MAX_GROUP_LABEL_LEN);
34
34
  }
35
+ // Legacy gitUrl: ".../owner/repo/tree/branch"
36
+ const git = raw.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
37
+ if (git) return join3(git[1], git[2]).slice(0, MAX_GROUP_LABEL_LEN);
38
+ // "host:/path/to/dir" -> basename
39
+ const hostCwd = raw.match(/^[^:]+:(.+)$/);
40
+ const label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || raw) : raw;
35
41
  return label.slice(0, MAX_GROUP_LABEL_LEN);
36
42
  }
37
43
 
44
+ // --isolate sessions (`rech --isolate ...` -> `-s=iso-<rand>`) are throwaway, single-flow
45
+ // buckets. We reap them on an idle TTL so an OAuth/login drive can't leak an orphaned browser
46
+ // context. A TTL (not close-on-exit-per-command) is required because the flows are multi-step
47
+ // (open -> click -> consent): closing after each command would break them. The reaper runs the
48
+ // CLI's `close` for the specific iso session only — it never touches the user's other sessions
49
+ // or quits Chrome.
50
+ const ISO_SESSION_TTL_MS = Number(process.env.RECH_ISOLATE_TTL_MS) || 15 * 60_000;
51
+ const ISO_REAP_INTERVAL_MS = 60_000;
52
+ const isoLastUsed = new Map<string, number>();
53
+
54
+ export function isIsoSession(namespacedSession: string): boolean {
55
+ return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
56
+ }
57
+
58
+ function tmpSocketRoot(): string {
59
+ return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
60
+ }
61
+
62
+ // On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
63
+ // (in-memory tracking alone would miss pre-restart orphans). Best-effort: a mis-derived session
64
+ // just yields a harmless no-op `close`. Socket files are named with the session as their prefix.
65
+ function adoptOrphanedIsoSessions(): void {
66
+ try {
67
+ const root = tmpSocketRoot();
68
+ for (const sub of readdirSync(root)) {
69
+ let entries: string[];
70
+ try { entries = readdirSync(`${root}/${sub}`); } catch { continue; }
71
+ for (const f of entries) {
72
+ const m = f.match(/^([0-9a-f]+-iso-[0-9a-f]+)/) || f.match(/^(iso-[0-9a-f]+)/);
73
+ if (m && !isoLastUsed.has(m[1])) {
74
+ isoLastUsed.set(m[1], Date.now());
75
+ log(`adopted orphaned isolated session for reaping: ${m[1]}`);
76
+ }
77
+ }
78
+ }
79
+ } catch {
80
+ // socket dir may not exist yet — nothing to adopt
81
+ }
82
+ }
83
+
84
+ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): void {
85
+ const now = Date.now();
86
+ for (const [sess, last] of isoLastUsed) {
87
+ if (now - last < ISO_SESSION_TTL_MS) continue;
88
+ isoLastUsed.delete(sess);
89
+ try {
90
+ Bun.spawn([bin, ...binArgs, "close", `-s=${sess}`], {
91
+ cwd: workDir,
92
+ stdin: "ignore",
93
+ stdout: "ignore",
94
+ stderr: "ignore",
95
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
96
+ });
97
+ log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
98
+ } catch (e) {
99
+ log(`reap failed for ${sess}: ${e}`);
100
+ }
101
+ }
102
+ }
103
+
38
104
  async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
39
105
  const certContent = await file(certPath).text().catch(() => null);
40
106
  if (!certContent) return false;
@@ -142,6 +208,13 @@ export async function serve() {
142
208
  const workDir = join(RECH_DIR, "output");
143
209
  mkdirSync(workDir, { recursive: true });
144
210
 
211
+ // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
212
+ adoptOrphanedIsoSessions();
213
+ setInterval(() => {
214
+ const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
215
+ reapIdleIsoSessions(bin, binArgs, workDir);
216
+ }, ISO_REAP_INTERVAL_MS);
217
+
145
218
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
146
219
  const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
147
220
  const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
@@ -201,14 +274,19 @@ export async function serve() {
201
274
  } else {
202
275
  args = body.args;
203
276
  const id = body.identity as
204
- | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
277
+ | { key?: string; label?: string; gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
205
278
  | undefined;
206
- const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
207
- const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
208
- if (raw) {
209
- sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 8);
210
- clientName = raw;
211
- log(`session from identity: ${raw} -> ${sessionId}`);
279
+ // New clients send {key,label} (key = worktree-root-based, decoupled from the pretty
280
+ // label). Fall back to the legacy {gitUrl|hostname:cwd} shape for older clients.
281
+ const legacy = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
282
+ const keyBase = id?.key || legacy;
283
+ const labelBase = id?.label || legacy || keyBase;
284
+ if (keyBase) {
285
+ // Hash the key (+ profile via a NUL separator that never appears in a label).
286
+ const hashInput = id?.profile ? `${keyBase}\u0000${id.profile}` : keyBase;
287
+ sessionId = createHash("sha256").update(hashInput).digest("hex").slice(0, 8);
288
+ clientName = labelBase || keyBase;
289
+ log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
212
290
  } else {
213
291
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
214
292
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
@@ -233,6 +311,8 @@ export async function serve() {
233
311
  return true;
234
312
  });
235
313
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
314
+ // Track --isolate sessions so the idle-TTL reaper can close them later.
315
+ if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, Date.now());
236
316
 
237
317
  // daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
238
318
  // fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
package/serve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
2
  import { createHash, X509Certificate } from "crypto";
3
- import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
3
+ import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -16,25 +16,91 @@ import {
16
16
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
17
17
  const CERT_RENEW_THRESHOLD_DAYS = 7;
18
18
 
19
- // Short label for a client identity, used as the Chrome tab-group name (the tab
20
- // strip is space-constrained, so cap at 7 chars). gitUrl ".../owner/repo/tree/branch"
21
- // -> "rep:bra" (3+3); "host:/path/to/dir" -> "dir"; bare host/IP -> as-is. Strips a
22
- // trailing "@profile" suffix first.
23
- const MAX_GROUP_LABEL_LEN = 7;
24
- function shortClientLabel(raw: string): string {
19
+ // Short label for a client identity, used as the Chrome tab-group name (the tab strip is
20
+ // space-constrained, so cap at 7 chars). Handles the current label shape and the legacy gitUrl:
21
+ // "host/owner/repo#<basename>@<branch>" -> "bas:bra" (3+3) (current)
22
+ // "host/owner/repo/tree/branch" -> "rep:bra" (3+3) (legacy gitUrl)
23
+ // "host:/path/to/dir" -> "dir" (non-git)
24
+ // bare host/IP -> as-is
25
+ export const MAX_GROUP_LABEL_LEN = 7;
26
+ export function shortClientLabel(raw: string): string {
25
27
  if (!raw) return raw;
26
- const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
27
- const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
28
- let label: string;
29
- if (git)
30
- label = git[2] ? `${git[1].slice(0, 3)}:${git[2].slice(0, 3)}` : git[1];
31
- else {
32
- const hostCwd = baseId.match(/^[^:]+:(.+)$/);
33
- label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || baseId) : baseId;
28
+ const join3 = (a: string, b?: string) => (b ? `${a.slice(0, 3)}:${b.slice(0, 3)}` : a);
29
+ // Current label: "[remote#]<worktree-basename>[@<branch>]"
30
+ if (raw.includes("#") || (raw.includes("@") && !raw.startsWith("http"))) {
31
+ const afterHash = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
32
+ const [base, branch] = afterHash.split("@");
33
+ return join3(base, branch).slice(0, MAX_GROUP_LABEL_LEN);
34
34
  }
35
+ // Legacy gitUrl: ".../owner/repo/tree/branch"
36
+ const git = raw.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
37
+ if (git) return join3(git[1], git[2]).slice(0, MAX_GROUP_LABEL_LEN);
38
+ // "host:/path/to/dir" -> basename
39
+ const hostCwd = raw.match(/^[^:]+:(.+)$/);
40
+ const label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || raw) : raw;
35
41
  return label.slice(0, MAX_GROUP_LABEL_LEN);
36
42
  }
37
43
 
44
+ // --isolate sessions (`rech --isolate ...` -> `-s=iso-<rand>`) are throwaway, single-flow
45
+ // buckets. We reap them on an idle TTL so an OAuth/login drive can't leak an orphaned browser
46
+ // context. A TTL (not close-on-exit-per-command) is required because the flows are multi-step
47
+ // (open -> click -> consent): closing after each command would break them. The reaper runs the
48
+ // CLI's `close` for the specific iso session only — it never touches the user's other sessions
49
+ // or quits Chrome.
50
+ const ISO_SESSION_TTL_MS = Number(process.env.RECH_ISOLATE_TTL_MS) || 15 * 60_000;
51
+ const ISO_REAP_INTERVAL_MS = 60_000;
52
+ const isoLastUsed = new Map<string, number>();
53
+
54
+ export function isIsoSession(namespacedSession: string): boolean {
55
+ return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
56
+ }
57
+
58
+ function tmpSocketRoot(): string {
59
+ return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
60
+ }
61
+
62
+ // On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
63
+ // (in-memory tracking alone would miss pre-restart orphans). Best-effort: a mis-derived session
64
+ // just yields a harmless no-op `close`. Socket files are named with the session as their prefix.
65
+ function adoptOrphanedIsoSessions(): void {
66
+ try {
67
+ const root = tmpSocketRoot();
68
+ for (const sub of readdirSync(root)) {
69
+ let entries: string[];
70
+ try { entries = readdirSync(`${root}/${sub}`); } catch { continue; }
71
+ for (const f of entries) {
72
+ const m = f.match(/^([0-9a-f]+-iso-[0-9a-f]+)/) || f.match(/^(iso-[0-9a-f]+)/);
73
+ if (m && !isoLastUsed.has(m[1])) {
74
+ isoLastUsed.set(m[1], Date.now());
75
+ log(`adopted orphaned isolated session for reaping: ${m[1]}`);
76
+ }
77
+ }
78
+ }
79
+ } catch {
80
+ // socket dir may not exist yet — nothing to adopt
81
+ }
82
+ }
83
+
84
+ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): void {
85
+ const now = Date.now();
86
+ for (const [sess, last] of isoLastUsed) {
87
+ if (now - last < ISO_SESSION_TTL_MS) continue;
88
+ isoLastUsed.delete(sess);
89
+ try {
90
+ Bun.spawn([bin, ...binArgs, "close", `-s=${sess}`], {
91
+ cwd: workDir,
92
+ stdin: "ignore",
93
+ stdout: "ignore",
94
+ stderr: "ignore",
95
+ env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
96
+ });
97
+ log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
98
+ } catch (e) {
99
+ log(`reap failed for ${sess}: ${e}`);
100
+ }
101
+ }
102
+ }
103
+
38
104
  async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
39
105
  const certContent = await file(certPath).text().catch(() => null);
40
106
  if (!certContent) return false;
@@ -142,6 +208,13 @@ export async function serve() {
142
208
  const workDir = join(RECH_DIR, "output");
143
209
  mkdirSync(workDir, { recursive: true });
144
210
 
211
+ // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
212
+ adoptOrphanedIsoSessions();
213
+ setInterval(() => {
214
+ const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
215
+ reapIdleIsoSessions(bin, binArgs, workDir);
216
+ }, ISO_REAP_INTERVAL_MS);
217
+
145
218
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
146
219
  const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
147
220
  const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
@@ -201,14 +274,19 @@ export async function serve() {
201
274
  } else {
202
275
  args = body.args;
203
276
  const id = body.identity as
204
- | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
277
+ | { key?: string; label?: string; gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
205
278
  | undefined;
206
- const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
207
- const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
208
- if (raw) {
209
- sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 8);
210
- clientName = raw;
211
- log(`session from identity: ${raw} -> ${sessionId}`);
279
+ // New clients send {key,label} (key = worktree-root-based, decoupled from the pretty
280
+ // label). Fall back to the legacy {gitUrl|hostname:cwd} shape for older clients.
281
+ const legacy = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
282
+ const keyBase = id?.key || legacy;
283
+ const labelBase = id?.label || legacy || keyBase;
284
+ if (keyBase) {
285
+ // Hash the key (+ profile via a NUL separator that never appears in a label).
286
+ const hashInput = id?.profile ? `${keyBase}\u0000${id.profile}` : keyBase;
287
+ sessionId = createHash("sha256").update(hashInput).digest("hex").slice(0, 8);
288
+ clientName = labelBase || keyBase;
289
+ log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
212
290
  } else {
213
291
  const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
214
292
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
@@ -233,6 +311,8 @@ export async function serve() {
233
311
  return true;
234
312
  });
235
313
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
314
+ // Track --isolate sessions so the idle-TTL reaper can close them later.
315
+ if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, Date.now());
236
316
 
237
317
  // daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
238
318
  // fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).