rechrome 1.36.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -3
- package/e2e.js +164 -0
- package/e2e.ts +106 -0
- package/package.json +19 -5
- package/rechrome.js +537 -34
- package/rechrome.ts +537 -34
- package/secret.js +116 -0
- package/secret.ts +113 -0
- package/serve.js +259 -51
- package/serve.ts +259 -51
- package/shares.js +349 -0
- package/shares.ts +257 -0
- package/signal-relay.js +112 -0
- package/signal-relay.ts +99 -0
- package/webrtc.js +879 -0
- package/webrtc.ts +535 -0
package/serve.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { receiveUpload, readRequestJson } from "./scoped-upload.ts";
|
|
2
2
|
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.ts";
|
|
3
|
+
import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
|
|
4
|
+
import { bearerOf, safeEqual } from "./secret.ts";
|
|
5
|
+
import { WebrtcHost } from "./webrtc.ts";
|
|
3
6
|
import { file } from "bun";
|
|
4
|
-
import { createHash, X509Certificate } from "crypto";
|
|
7
|
+
import { createHash, randomBytes, X509Certificate } from "crypto";
|
|
5
8
|
import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
6
9
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
7
10
|
import { tmpdir } from "os";
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
readTokenRegistry,
|
|
20
23
|
readChromeProfileCache,
|
|
21
24
|
checkTailscaleServe,
|
|
25
|
+
chromeDataAccessHint,
|
|
22
26
|
} from "./rechrome.ts";
|
|
23
27
|
|
|
24
28
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -122,6 +126,16 @@ export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean
|
|
|
122
126
|
return true;
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The relay checks Chrome's data folder for the installed extension before connecting. Under
|
|
131
|
+
* macOS TCC that read fails and it reports "Playwright Extension not found" although the
|
|
132
|
+
* extension is installed — name the real cause instead of sending the user to reinstall.
|
|
133
|
+
*/
|
|
134
|
+
export function explainExtensionNotFound(output: string, accessProblem: string | undefined): string | undefined {
|
|
135
|
+
if (!accessProblem || !/Playwright Extension not found in/i.test(output)) return undefined;
|
|
136
|
+
return `[rech] the extension is probably installed; this daemon ${accessProblem}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
125
139
|
export function inferSilentExtensionFailure(options: {
|
|
126
140
|
status: number;
|
|
127
141
|
stdout: string;
|
|
@@ -256,24 +270,63 @@ export function splitCommand(cmd: string): string[] {
|
|
|
256
270
|
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
257
271
|
}
|
|
258
272
|
|
|
259
|
-
|
|
273
|
+
/** Why this daemon cannot read Chrome's data folder; reported by /ping so `rech status` shows it. */
|
|
274
|
+
let chromeAccessProblem: string | undefined;
|
|
275
|
+
/** Log (once) why Chrome's data folder is unreadable — under launchd/oxmgr on macOS this is TCC. */
|
|
276
|
+
export function warnChromeDataAccess(path: string, error: unknown): void {
|
|
277
|
+
if (chromeAccessProblem) return;
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
chromeAccessProblem = `cannot read ${path} (${message}). ${chromeDataAccessHint(error) ?? "Profile names and emails cannot be resolved here."}`;
|
|
280
|
+
log(chromeAccessProblem);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A profile name or email could not be mapped to its Chrome folder because Local State is unreadable. */
|
|
284
|
+
export class ProfileResolutionError extends Error {
|
|
285
|
+
constructor(readonly profile: string, statePath: string, cause: unknown) {
|
|
286
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
287
|
+
const hint = chromeDataAccessHint(cause);
|
|
288
|
+
super(`[rech] cannot find the Chrome folder for profile "${profile}": ${statePath} is unreadable (${message}). Refusing, so Chrome does not create a new empty profile named "${profile}".\n`
|
|
289
|
+
+ ` Register it once (records its folder in ~/.rechrome): rech setup --profile ${JSON.stringify(profile)}\n`
|
|
290
|
+
+ ` Or select the folder directly, e.g. rech --profile "Profile 1" …\n`
|
|
291
|
+
+ (hint ? ` ${hint}\n` : ""));
|
|
292
|
+
this.name = "ProfileResolutionError";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function resolveProfileDirectory(
|
|
297
|
+
nameOrEmail: string,
|
|
298
|
+
registry?: Record<string, { profileDir?: string }>,
|
|
299
|
+
statePaths: string[] = [
|
|
300
|
+
join(HOME || "~", "Library/Application Support/Google/Chrome/Local State"),
|
|
301
|
+
join(HOME || "~", ".config/google-chrome/Local State"),
|
|
302
|
+
join(HOME || "~", "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
303
|
+
],
|
|
304
|
+
): Promise<string> {
|
|
260
305
|
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
306
|
+
// setup recorded each registered profile's folder in ~/.rechrome, which macOS privacy (TCC)
|
|
307
|
+
// never guards, so a launchd/oxmgr daemon resolves it without touching Chrome's data folder.
|
|
308
|
+
const entry = (registry ?? await readTokenRegistry().catch(() => ({})) as Record<string, { profileDir?: string }>)[nameOrEmail];
|
|
309
|
+
if (entry?.profileDir) return entry.profileDir;
|
|
310
|
+
let unreadable: { path: string; error: unknown } | undefined;
|
|
311
|
+
for (const statePath of statePaths) {
|
|
312
|
+
try {
|
|
313
|
+
// Read without an exists() probe: under TCC the stat fails too, and exists() would report
|
|
314
|
+
// "missing" and hide the denial.
|
|
315
|
+
const data = JSON.parse(await file(statePath).text());
|
|
316
|
+
const cache: Record<string, any> = data?.profile?.info_cache ?? {};
|
|
317
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
318
|
+
if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
|
|
319
|
+
return dir;
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") continue;
|
|
323
|
+
warnChromeDataAccess(statePath, error);
|
|
324
|
+
unreadable ??= { path: statePath, error };
|
|
275
325
|
}
|
|
276
326
|
}
|
|
327
|
+
// Passing an email or display name through would make Chrome create a NEW empty profile folder
|
|
328
|
+
// literally named after it (--profile-directory=me@example.com) instead of opening the real one.
|
|
329
|
+
if (unreadable) throw new ProfileResolutionError(nameOrEmail, unreadable.path, unreadable.error);
|
|
277
330
|
return nameOrEmail;
|
|
278
331
|
}
|
|
279
332
|
|
|
@@ -397,15 +450,34 @@ async function freeStalePort(port: number, host: string): Promise<void> {
|
|
|
397
450
|
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
398
451
|
// and would otherwise keep an orphan alive forever).
|
|
399
452
|
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
400
|
-
const ORPHAN_IDLE_EXIT_MS =
|
|
453
|
+
const ORPHAN_IDLE_EXIT_MS = parseIdleTimeout(process.env.RECH_SERVE_IDLE_TIMEOUT_MS);
|
|
454
|
+
|
|
455
|
+
// `RECH_SERVE_IDLE_TIMEOUT_MS=0` must disable self-exit; `Number(v) || default` turned 0 back into 5 min.
|
|
456
|
+
export function parseIdleTimeout(raw: string | undefined, fallback = 5 * 60_000): number {
|
|
457
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
458
|
+
const value = Number(raw);
|
|
459
|
+
return Number.isFinite(value) ? value : fallback;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A supervisor owns this serve's lifetime, so it is never a leaked foreground serve:
|
|
463
|
+
// RECH_SERVE_MANAGED=1 (set by `rech setup`'s oxmgr/pm2 install), or a LaunchAgent — launchd
|
|
464
|
+
// starts it with ppid 1 and its job label in XPC_SERVICE_NAME. ppid 1 alone is not enough:
|
|
465
|
+
// `setsid rech serve` or `(rech serve &)` also starts with ppid 1 and must still self-exit.
|
|
466
|
+
// Terminal apps set XPC_SERVICE_NAME to "0" or "application.<bundle id>…".
|
|
467
|
+
export function isManagedServe(startPpid: number, env: Record<string, string | undefined> = process.env): boolean {
|
|
468
|
+
if (env.RECH_SERVE_MANAGED === "1") return true;
|
|
469
|
+
const label = env.XPC_SERVICE_NAME;
|
|
470
|
+
return startPpid === 1 && !!label && label !== "0" && !label.startsWith("application.");
|
|
471
|
+
}
|
|
401
472
|
|
|
402
473
|
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
403
474
|
export function shouldExitOrphanedServe(opts: {
|
|
404
475
|
orphaned: boolean;
|
|
405
476
|
idleMs: number;
|
|
406
477
|
idleTimeoutMs: number;
|
|
478
|
+
managed?: boolean;
|
|
407
479
|
}): boolean {
|
|
408
|
-
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
480
|
+
return !opts.managed && opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
409
481
|
}
|
|
410
482
|
|
|
411
483
|
export const LANDING_HEADERS = {
|
|
@@ -449,7 +521,7 @@ a { color: var(--accent); }
|
|
|
449
521
|
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
450
522
|
On your computer, inside the project folder that should use it, run these two lines
|
|
451
523
|
(the same in macOS/Linux terminals, PowerShell and cmd):</p>
|
|
452
|
-
<p id="nokey" class="warn" hidden>This link is missing its key (the part after #key
|
|
524
|
+
<p id="nokey" class="warn" hidden>This link is missing its key (the part after # — #key=… or #rech1_…). Ask for the full link from <code>rech share</code>.</p>
|
|
453
525
|
<div id="cmds"></div>
|
|
454
526
|
<div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
|
|
455
527
|
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
@@ -459,13 +531,14 @@ This link contains a secret key: anyone with it can use this browser profile, so
|
|
|
459
531
|
(() => {
|
|
460
532
|
let url = location.href;
|
|
461
533
|
const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
|
|
462
|
-
|
|
534
|
+
const invite = /^#rech1_[1-9A-HJ-NP-Za-km-z]{28}$/.test(location.hash);
|
|
535
|
+
if (!/[#&?]key=/.test(location.hash) && !invite) document.getElementById("nokey").hidden = false;
|
|
463
536
|
// One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
|
|
464
537
|
// shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
|
|
465
538
|
// %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
|
|
466
539
|
const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
|
|
467
540
|
const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
|
|
468
|
-
const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
|
|
541
|
+
const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…").replace(/(#rech1_[1-9A-HJ-NP-Za-km-z]{4})[1-9A-HJ-NP-Za-km-z]+/, "$1…");
|
|
469
542
|
const root = document.getElementById("cmds");
|
|
470
543
|
const row = document.createElement("div"); row.className = "row";
|
|
471
544
|
const line = document.createElement("div"); line.className = "cmd";
|
|
@@ -533,14 +606,24 @@ export async function serve() {
|
|
|
533
606
|
let orphaned = false;
|
|
534
607
|
let idleSince = Date.now();
|
|
535
608
|
const markActivity = () => { idleSince = Date.now(); };
|
|
609
|
+
const managed = isManagedServe(process.ppid);
|
|
610
|
+
if (managed) log("serve is supervised (launchd, a process manager, or RECH_SERVE_MANAGED=1) — idle self-exit disabled");
|
|
536
611
|
setInterval(() => {
|
|
537
612
|
if (process.ppid === 1) orphaned = true;
|
|
538
|
-
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
613
|
+
if (shouldExitOrphanedServe({ orphaned, managed, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
539
614
|
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
540
615
|
process.exit(0);
|
|
541
616
|
}
|
|
542
617
|
}, ORPHAN_POLL_INTERVAL_MS);
|
|
543
618
|
|
|
619
|
+
// Say at startup, not on the first failed open, when macOS privacy blocks Chrome's data folder.
|
|
620
|
+
if (process.platform === "darwin") {
|
|
621
|
+
const chromeDir = join(HOME, "Library/Application Support/Google/Chrome");
|
|
622
|
+
try { readdirSync(chromeDir); } catch (error) {
|
|
623
|
+
if ((error as { code?: string }).code !== "ENOENT") warnChromeDataAccess(chromeDir, error);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
544
627
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
545
628
|
adoptOrphanedIsoSessions();
|
|
546
629
|
setInterval(() => {
|
|
@@ -564,24 +647,23 @@ export async function serve() {
|
|
|
564
647
|
const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
|
|
565
648
|
const policies = new Map<string, Listener>();
|
|
566
649
|
const servers = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const scoped = listener.profiles !== "*";
|
|
650
|
+
// Shares (shares.json) are reloaded with the listeners below. sessionOwners remembers which
|
|
651
|
+
// credential drove each session, for `rech session ls` and for closing a revoked share's sessions.
|
|
652
|
+
let shares: ShareConfig = { version: 1, shares: [] };
|
|
653
|
+
const sessionOwners = new Map<string, { owner: string; label: string; profile?: string; name: string; lastUsed: number }>();
|
|
654
|
+
const deviceSeen = new Map<string, number>();
|
|
655
|
+
const noteDeviceUse = (cred: Credential & { kind: "device" }) => {
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
if (now - (deviceSeen.get(cred.device.id) ?? cred.device.lastSeen ?? 0) < 10 * 60_000) return;
|
|
658
|
+
deviceSeen.set(cred.device.id, now);
|
|
659
|
+
void updateShares(config => {
|
|
660
|
+
const device = config.shares.find(s => s.id === cred.share.id)?.devices.find(d => d.id === cred.device.id);
|
|
661
|
+
if (device) device.lastSeen = now;
|
|
662
|
+
}).catch(error => log(`could not record device use: ${error}`));
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
|
|
666
|
+
let listener = initialListener;
|
|
585
667
|
const reqUrl = new URL(req.url);
|
|
586
668
|
const prefix = normalizePrefix(listener.prefix);
|
|
587
669
|
// Accept the path with or without the prefix, so a proxy works whether it keeps the
|
|
@@ -592,18 +674,70 @@ export async function serve() {
|
|
|
592
674
|
else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
|
|
593
675
|
}
|
|
594
676
|
|
|
677
|
+
// Who is asking: the listener's own key (legacy links, the local management key), or a
|
|
678
|
+
// share credential (an invite or a device, see shares.ts) offered on this transport. A
|
|
679
|
+
// share narrows the listener to the share's own profiles.
|
|
680
|
+
const presented = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
|
681
|
+
const cred = presented && !safeEqual(presented, listener.key) ? credentialForBearer(shares, presented) : null;
|
|
682
|
+
if (cred) {
|
|
683
|
+
const route = reqUrl.pathname === "/ping" ? "ping" : reqUrl.pathname === "/redeem" ? "redeem" : "use";
|
|
684
|
+
const denial = authorizeCredential(cred, listener.name, route);
|
|
685
|
+
if (denial) return Response.json({ status: 1, stdout: "", stderr: `[rech] ${denial.message}\n`, error: denial.message, pending: denial.pending }, { status: denial.status });
|
|
686
|
+
if (cred.kind === "device") noteDeviceUse(cred);
|
|
687
|
+
listener = { ...listener, key: presented, profiles: cred.share.profiles };
|
|
688
|
+
}
|
|
689
|
+
const key = listener.key;
|
|
690
|
+
const listenHost = listener.host;
|
|
691
|
+
const scoped = listener.profiles !== "*";
|
|
692
|
+
const owner = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}` : `listener:${listener.name}`;
|
|
693
|
+
|
|
694
|
+
if (reqUrl.pathname === "/redeem" && req.method === "POST") {
|
|
695
|
+
if (cred?.kind !== "invite") return Response.json({ error: "Only an invite link can be redeemed." }, { status: 400 });
|
|
696
|
+
const { name } = await req.json().catch(() => ({})) as { name?: string };
|
|
697
|
+
try {
|
|
698
|
+
const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
|
|
699
|
+
shares = await readShares();
|
|
700
|
+
log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
|
|
701
|
+
return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
|
|
702
|
+
} catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (reqUrl.pathname === "/profiles") {
|
|
706
|
+
const denied = authCheck(req, key);
|
|
707
|
+
if (denied) return denied;
|
|
708
|
+
const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
|
|
709
|
+
const cache = await readChromeProfileCache().catch(() => null) ?? {};
|
|
710
|
+
const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
|
|
711
|
+
return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (reqUrl.pathname === "/sessions") {
|
|
715
|
+
const denied = authCheck(req, key);
|
|
716
|
+
if (denied) return denied;
|
|
717
|
+
let live = new Set<string>();
|
|
718
|
+
try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
|
|
719
|
+
const all = listener.profiles === "*" && !cred;
|
|
720
|
+
const rows = [...sessionOwners.entries()]
|
|
721
|
+
.filter(([, o]) => all || o.owner === owner)
|
|
722
|
+
.map(([session, o]) => ({ session: o.name, profile: o.profile, client: o.label, owner: all ? o.owner : undefined, lastUsed: o.lastUsed, live: live.has(session) }))
|
|
723
|
+
.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
724
|
+
return Response.json({ sessions: rows });
|
|
725
|
+
}
|
|
726
|
+
|
|
595
727
|
// Serve files from output dir
|
|
596
728
|
if (reqUrl.pathname.startsWith("/files/")) {
|
|
597
729
|
const denied = authCheck(req, key);
|
|
598
730
|
if (denied) return denied;
|
|
599
731
|
const name = decodeURIComponent(reqUrl.pathname.slice(7));
|
|
600
732
|
if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
|
|
601
|
-
|
|
733
|
+
const readable = (path: string) => canReadProfileFile(listener, path)
|
|
734
|
+
&& (cred?.kind !== "device" || cred.share.profiles.some(p => path.startsWith(`${profileOutputPrefix(p)}d-${cred.device.id}/`)));
|
|
735
|
+
if (!readable(name)) return new Response("Forbidden", { status: 403 });
|
|
602
736
|
const resolved = resolve(workDir, name);
|
|
603
737
|
const f = file(resolved);
|
|
604
738
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
605
739
|
const real = realpathSync(resolved);
|
|
606
|
-
if (!isUnderDir(realpathSync(workDir), real) || !
|
|
740
|
+
if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
607
741
|
return new Response(f);
|
|
608
742
|
}
|
|
609
743
|
|
|
@@ -615,12 +749,15 @@ export async function serve() {
|
|
|
615
749
|
// clients / `rech status` can see trouble without an active probe.
|
|
616
750
|
const degraded = consecutiveTimeouts > 0;
|
|
617
751
|
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
618
|
-
return Response.json({
|
|
752
|
+
return Response.json({
|
|
753
|
+
ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
|
|
754
|
+
...(cred ? { share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : {}), expiresAt: cred.share.expiresAt } : {}),
|
|
755
|
+
});
|
|
619
756
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
620
757
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
621
758
|
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
622
759
|
if (!target)
|
|
623
|
-
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
760
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded, chromeAccessProblem });
|
|
624
761
|
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
625
762
|
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
626
763
|
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
@@ -634,7 +771,7 @@ export async function serve() {
|
|
|
634
771
|
const healthy = probeStatus !== -1;
|
|
635
772
|
return Response.json({
|
|
636
773
|
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
637
|
-
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
774
|
+
consecutiveTimeouts, degraded: degraded || !healthy, chromeAccessProblem,
|
|
638
775
|
});
|
|
639
776
|
}
|
|
640
777
|
// A browser opening a shared URL gets copyable connect instructions. The key stays in the
|
|
@@ -675,7 +812,9 @@ export async function serve() {
|
|
|
675
812
|
PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
|
|
676
813
|
PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
|
|
677
814
|
};
|
|
678
|
-
|
|
815
|
+
// A share's devices never share browser state with each other or with legacy links.
|
|
816
|
+
body.identity.key = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}:${body.identity.key}` : `scoped:${body.identity.key}`;
|
|
817
|
+
if (cred) body.identity.label = cred.share.label;
|
|
679
818
|
body.env = {}; // Server-owned profile configuration wins over every client override.
|
|
680
819
|
} catch (error) {
|
|
681
820
|
if (Array.isArray(body?.args) && body.args.filter((a: unknown) => typeof a !== "string" || !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a))[0] === "upload")
|
|
@@ -683,7 +822,9 @@ export async function serve() {
|
|
|
683
822
|
return Response.json({ status: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` }, { status: 403 });
|
|
684
823
|
}
|
|
685
824
|
}
|
|
686
|
-
|
|
825
|
+
// A share device's files live under its own folder, so other devices and links on the same
|
|
826
|
+
// profile can't fetch them (see the /files check above).
|
|
827
|
+
const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) + (cred?.kind === "device" ? `d-${cred.device.id}/` : "") : "";
|
|
687
828
|
const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
|
|
688
829
|
mkdirSync(runWorkDir, { recursive: true });
|
|
689
830
|
let args: string[];
|
|
@@ -692,7 +833,7 @@ export async function serve() {
|
|
|
692
833
|
let clientEnv: Record<string, string> = {};
|
|
693
834
|
if (Array.isArray(body)) {
|
|
694
835
|
args = body;
|
|
695
|
-
const clientAddr =
|
|
836
|
+
const clientAddr = remoteAddr();
|
|
696
837
|
sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
|
|
697
838
|
clientName = clientAddr;
|
|
698
839
|
log(`session from client IP: ${clientAddr} -> ${sessionId}`);
|
|
@@ -713,7 +854,7 @@ export async function serve() {
|
|
|
713
854
|
clientName = labelBase || keyBase;
|
|
714
855
|
log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
|
|
715
856
|
} else {
|
|
716
|
-
const clientAddr =
|
|
857
|
+
const clientAddr = remoteAddr();
|
|
717
858
|
sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
|
|
718
859
|
clientName = clientAddr;
|
|
719
860
|
log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
|
|
@@ -738,6 +879,8 @@ export async function serve() {
|
|
|
738
879
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
739
880
|
// Track --isolate sessions so the idle-TTL reaper can close them later.
|
|
740
881
|
const nowMs = Date.now();
|
|
882
|
+
sessionOwners.set(namespacedSession, { owner, label: cred ? `${cred.share.label}/${(cred as any).device?.name ?? ""}` : clientName, profile: scopedProfile ?? (body as any)?.identity?.profile, name: clientSession || "default", lastUsed: nowMs });
|
|
883
|
+
if (sessionOwners.size > 512) sessionOwners.delete(sessionOwners.keys().next().value!);
|
|
741
884
|
if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
|
|
742
885
|
else noteSession(namespacedSession, nowMs); // for the deep health probe to target
|
|
743
886
|
|
|
@@ -765,7 +908,14 @@ export async function serve() {
|
|
|
765
908
|
|
|
766
909
|
// Resolve profile name/email → directory name
|
|
767
910
|
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
768
|
-
|
|
911
|
+
let resolved: string;
|
|
912
|
+
try {
|
|
913
|
+
resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
if (!(error instanceof ProfileResolutionError)) throw error;
|
|
916
|
+
log(`refused unresolvable profile "${error.profile}" (session=${namespacedSession})`);
|
|
917
|
+
return Response.json({ status: 1, stdout: "", stderr: error.message, files: [] });
|
|
918
|
+
}
|
|
769
919
|
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
770
920
|
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
771
921
|
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
@@ -907,7 +1057,8 @@ export async function serve() {
|
|
|
907
1057
|
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
908
1058
|
? configuredHandshakeTimeout
|
|
909
1059
|
: 30_000;
|
|
910
|
-
const
|
|
1060
|
+
const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
|
|
1061
|
+
const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
|
|
911
1062
|
status,
|
|
912
1063
|
stdout,
|
|
913
1064
|
stderr: rawStderr,
|
|
@@ -988,6 +1139,33 @@ export async function serve() {
|
|
|
988
1139
|
// a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
|
|
989
1140
|
files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
|
|
990
1141
|
});
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
|
|
1145
|
+
// bearer of the credential whose room it came in on, so share checks apply unchanged.
|
|
1146
|
+
const webrtcListener: Listener = { name: WEBRTC, host: "webrtc", port: 0, key: randomBytes(24).toString("base64url"), profiles: [] };
|
|
1147
|
+
const webrtc = new WebrtcHost(async (req, token) => {
|
|
1148
|
+
const headers = new Headers(req.headers);
|
|
1149
|
+
headers.set("Authorization", `Bearer ${bearerOf(token)}`);
|
|
1150
|
+
return handle(new Request(req, { headers }), webrtcListener, () => "webrtc");
|
|
1151
|
+
}, log);
|
|
1152
|
+
|
|
1153
|
+
const startServer = (initial: Listener, reusePort = false) => Bun.serve({
|
|
1154
|
+
hostname: initial.host,
|
|
1155
|
+
port: initial.port,
|
|
1156
|
+
// reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
|
|
1157
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
1158
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
1159
|
+
reusePort,
|
|
1160
|
+
tls,
|
|
1161
|
+
error(err) {
|
|
1162
|
+
log(`unhandled error: ${err.message}`);
|
|
1163
|
+
return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
|
|
1164
|
+
},
|
|
1165
|
+
async fetch(req, server) {
|
|
1166
|
+
const listener = policies.get(listenerAddress(initial));
|
|
1167
|
+
if (!listener) return new Response("Listener removed", { status: 403 });
|
|
1168
|
+
return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
|
|
991
1169
|
},
|
|
992
1170
|
});
|
|
993
1171
|
|
|
@@ -1056,12 +1234,42 @@ export async function serve() {
|
|
|
1056
1234
|
configured ||= !!config;
|
|
1057
1235
|
applied = fingerprint;
|
|
1058
1236
|
};
|
|
1237
|
+
// Shares change independently of listeners (redeem, approve, revoke); a bad shares.json keeps
|
|
1238
|
+
// the previous one, like listeners.json.
|
|
1239
|
+
let appliedShares = "";
|
|
1240
|
+
const reloadShares = async () => {
|
|
1241
|
+
const next = await readShares();
|
|
1242
|
+
// Expiry is time-based: fold "what is live right now" into the fingerprint so an expiring
|
|
1243
|
+
// share or invite closes its rooms and sessions without anyone touching the file.
|
|
1244
|
+
const now = Date.now();
|
|
1245
|
+
const fingerprint = JSON.stringify(next) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
|
|
1246
|
+
if (fingerprint === appliedShares) return;
|
|
1247
|
+
const allowed = new Set(next.shares.filter(s => !shareProblem(s, now)).flatMap(s => s.devices.map(d => `share:${s.id}:${d.id}`)));
|
|
1248
|
+
const dropped = [...sessionOwners.entries()].filter(([, o]) => o.owner.startsWith("share:") && !allowed.has(o.owner));
|
|
1249
|
+
shares = next;
|
|
1250
|
+
appliedShares = fingerprint;
|
|
1251
|
+
// A revoked share or a removed device loses its browser sessions too.
|
|
1252
|
+
if (dropped.length) {
|
|
1253
|
+
const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
|
|
1254
|
+
for (const [session, o] of dropped) {
|
|
1255
|
+
sessionOwners.delete(session);
|
|
1256
|
+
try { Bun.spawn([bin, ...binArgs, "close", `-s=${session}`], { cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true, env: playwrightCliEnv() }); } catch {}
|
|
1257
|
+
log(`closed session ${session} of ${o.label}: its share was revoked or its device removed`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const creds = webrtcCredentials(next);
|
|
1261
|
+
webrtc.sync(creds.map(c => ({ token: c.token, signal: c.share.signal!, label: c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)` })));
|
|
1262
|
+
if (creds.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s)`);
|
|
1263
|
+
};
|
|
1264
|
+
await reloadShares().catch(error => log(`shares.json rejected: ${error}`));
|
|
1059
1265
|
await reload(true);
|
|
1060
1266
|
let reloading = false;
|
|
1061
1267
|
let lastError = "";
|
|
1062
1268
|
setInterval(async () => {
|
|
1063
1269
|
if (reloading) return;
|
|
1064
1270
|
reloading = true;
|
|
1271
|
+
// Independent: a broken listeners.json must not freeze revocations (and vice versa).
|
|
1272
|
+
try { await reloadShares(); } catch (error) { log(`shares.json reload rejected; previous shares retained: ${error}`); }
|
|
1065
1273
|
try { await reload(); lastError = ""; }
|
|
1066
1274
|
catch (error) {
|
|
1067
1275
|
const message = String(error);
|