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.
- package/package.json +1 -1
- package/rech.js +126 -43
- package/rech.ts +126 -43
- package/serve.js +102 -22
- package/serve.ts +102 -22
package/package.json
CHANGED
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
|
-
|
|
198
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
433
|
-
if (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
|
|
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.
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
433
|
-
if (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
|
|
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.
|
|
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
|
-
//
|
|
21
|
-
// -> "
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
//
|
|
21
|
-
// -> "
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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).
|