rechrome 1.24.1 → 1.26.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 +106 -12
- package/daemon-manager.js +71 -0
- package/daemon-manager.ts +99 -0
- package/extension/LICENSE +202 -0
- package/extension/MODIFICATIONS.md +19 -0
- package/extension/NOTICE +10 -0
- package/extension/THIRD_PARTY_NOTICES.txt +81 -0
- package/extension/lib/background.mjs +3 -0
- package/extension/lib/ui/authToken.js +3 -0
- package/extension/lib/ui/connect.js +3 -0
- package/extension/lib/ui/status.js +3 -0
- package/extension/manifest.json +1 -1
- package/extension-token.js +177 -0
- package/extension-token.ts +133 -0
- package/listeners.js +104 -0
- package/listeners.ts +84 -0
- package/package.json +19 -8
- package/{rech.js → rechrome.js} +1043 -458
- package/{rech.ts → rechrome.ts} +1043 -458
- package/serve.js +107 -89
- package/serve.ts +107 -89
- package/skills/rechrome-setup/SKILL.md +53 -0
package/serve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.js";
|
|
1
2
|
import { file } from "bun";
|
|
2
3
|
import { createHash, X509Certificate } from "crypto";
|
|
3
|
-
import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
|
|
4
|
+
import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
4
5
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
6
|
import {
|
|
6
7
|
log,
|
|
@@ -8,10 +9,13 @@ import {
|
|
|
8
9
|
getOrCreateUrl,
|
|
9
10
|
authCheck,
|
|
10
11
|
RECH_DIR,
|
|
12
|
+
LEGACY_RECH_DIR,
|
|
13
|
+
migrateLegacyDataDir,
|
|
11
14
|
HOME,
|
|
12
15
|
PASSTHROUGH_ENV_KEYS,
|
|
13
16
|
resolvePlaywrightCli,
|
|
14
|
-
|
|
17
|
+
readTokenRegistry,
|
|
18
|
+
} from "./rechrome.js";
|
|
15
19
|
|
|
16
20
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
17
21
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -239,45 +243,6 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
239
243
|
return nameOrEmail;
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
243
|
-
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
244
|
-
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
245
|
-
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
246
|
-
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
247
|
-
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
248
|
-
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
249
|
-
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
250
|
-
async function freeStalePort(port: number): Promise<void> {
|
|
251
|
-
try {
|
|
252
|
-
if (process.platform === "win32") {
|
|
253
|
-
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
254
|
-
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
255
|
-
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
256
|
-
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
257
|
-
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
258
|
-
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
259
|
-
const ps = [
|
|
260
|
-
"$ErrorActionPreference='SilentlyContinue';",
|
|
261
|
-
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
262
|
-
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
263
|
-
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
264
|
-
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
265
|
-
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
266
|
-
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
267
|
-
"}",
|
|
268
|
-
].join(" ");
|
|
269
|
-
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
270
|
-
const out = r.stdout?.toString().trim();
|
|
271
|
-
if (out) log(out);
|
|
272
|
-
} else {
|
|
273
|
-
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
274
|
-
}
|
|
275
|
-
} catch {
|
|
276
|
-
// best effort — the retry will surface a clear error if the port is still held
|
|
277
|
-
}
|
|
278
|
-
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
279
|
-
}
|
|
280
|
-
|
|
281
246
|
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
247
|
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
248
|
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
@@ -300,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
|
|
|
300
265
|
}
|
|
301
266
|
|
|
302
267
|
export async function serve() {
|
|
268
|
+
// The daemon owns logs/ and output/, so it migrates them before writing anything.
|
|
269
|
+
const migrated = migrateLegacyDataDir();
|
|
303
270
|
const url = await getOrCreateUrl();
|
|
304
271
|
const { key, port } = parseUrl(url);
|
|
305
272
|
|
|
306
273
|
const workDir = join(RECH_DIR, "output");
|
|
307
274
|
mkdirSync(workDir, { recursive: true });
|
|
275
|
+
if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
|
|
308
276
|
|
|
309
277
|
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
278
|
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
@@ -341,20 +309,30 @@ export async function serve() {
|
|
|
341
309
|
}, 86_400_000);
|
|
342
310
|
}
|
|
343
311
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
reusePort,
|
|
312
|
+
const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
|
|
313
|
+
const policies = new Map<string, Listener>();
|
|
314
|
+
const servers = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
315
|
+
const startServer = (initial: Listener) => Bun.serve({
|
|
316
|
+
hostname: initial.host,
|
|
317
|
+
port: initial.port,
|
|
351
318
|
tls,
|
|
352
319
|
error(err) {
|
|
353
320
|
log(`unhandled error: ${err.message}`);
|
|
354
321
|
return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
|
|
355
322
|
},
|
|
356
|
-
async fetch(req) {
|
|
323
|
+
async fetch(req, server) {
|
|
324
|
+
const listener = policies.get(listenerAddress(initial));
|
|
325
|
+
if (!listener) return new Response("Listener removed", { status: 403 });
|
|
326
|
+
const key = listener.key;
|
|
327
|
+
const listenHost = listener.host;
|
|
328
|
+
const scoped = listener.profiles !== "*";
|
|
357
329
|
const reqUrl = new URL(req.url);
|
|
330
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
331
|
+
if (prefix !== "/") {
|
|
332
|
+
if (reqUrl.pathname === prefix.slice(0, -1)) reqUrl.pathname = "/";
|
|
333
|
+
else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
|
|
334
|
+
else return new Response("Not found", { status: 404 });
|
|
335
|
+
}
|
|
358
336
|
|
|
359
337
|
// Serve files from output dir
|
|
360
338
|
if (reqUrl.pathname.startsWith("/files/")) {
|
|
@@ -362,9 +340,12 @@ export async function serve() {
|
|
|
362
340
|
if (denied) return denied;
|
|
363
341
|
const name = decodeURIComponent(reqUrl.pathname.slice(7));
|
|
364
342
|
if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
|
|
343
|
+
if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
|
|
365
344
|
const resolved = resolve(workDir, name);
|
|
366
345
|
const f = file(resolved);
|
|
367
346
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
347
|
+
const real = realpathSync(resolved);
|
|
348
|
+
if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
368
349
|
return new Response(f);
|
|
369
350
|
}
|
|
370
351
|
|
|
@@ -375,8 +356,8 @@ export async function serve() {
|
|
|
375
356
|
// relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
|
|
376
357
|
// clients / `rech status` can see trouble without an active probe.
|
|
377
358
|
const degraded = consecutiveTimeouts > 0;
|
|
378
|
-
if (!reqUrl.searchParams.get("deep"))
|
|
379
|
-
return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
|
|
359
|
+
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
360
|
+
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
|
|
380
361
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
381
362
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
382
363
|
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
@@ -404,6 +385,28 @@ export async function serve() {
|
|
|
404
385
|
markActivity(); // a real command: this serve is not idle
|
|
405
386
|
|
|
406
387
|
const body = await req.json();
|
|
388
|
+
let scopedProfile: string | undefined;
|
|
389
|
+
let profileEnv: Record<string, string> = {};
|
|
390
|
+
if (scoped) {
|
|
391
|
+
try {
|
|
392
|
+
scopedProfile = authorizeProfileRequest(listener, body);
|
|
393
|
+
const registry = await readTokenRegistry();
|
|
394
|
+
const entry = registry[scopedProfile];
|
|
395
|
+
if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
|
|
396
|
+
profileEnv = {
|
|
397
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: entry.profileDir,
|
|
398
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: entry.extensionId,
|
|
399
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: entry.token,
|
|
400
|
+
PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
|
|
401
|
+
PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
|
|
402
|
+
};
|
|
403
|
+
body.identity.key = `scoped:${body.identity.key}`;
|
|
404
|
+
body.env = {}; // Server-owned profile configuration wins over every client override.
|
|
405
|
+
} catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
|
|
406
|
+
}
|
|
407
|
+
const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
|
|
408
|
+
const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
|
|
409
|
+
mkdirSync(runWorkDir, { recursive: true });
|
|
407
410
|
let args: string[];
|
|
408
411
|
let sessionId: string;
|
|
409
412
|
let clientName = "";
|
|
@@ -480,7 +483,7 @@ export async function serve() {
|
|
|
480
483
|
if (isOpenCmd) {
|
|
481
484
|
try {
|
|
482
485
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
483
|
-
cwd:
|
|
486
|
+
cwd: runWorkDir,
|
|
484
487
|
stdin: "ignore",
|
|
485
488
|
stdout: "pipe",
|
|
486
489
|
stderr: "pipe",
|
|
@@ -518,10 +521,10 @@ export async function serve() {
|
|
|
518
521
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
519
522
|
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
520
523
|
}
|
|
521
|
-
Object.assign(passthroughEnv, clientEnv);
|
|
524
|
+
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
522
525
|
|
|
523
526
|
// Resolve profile name/email → directory name
|
|
524
|
-
if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
527
|
+
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
525
528
|
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
526
529
|
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
527
530
|
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
@@ -564,7 +567,7 @@ export async function serve() {
|
|
|
564
567
|
|
|
565
568
|
const commandStartedAt = Date.now();
|
|
566
569
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
567
|
-
cwd:
|
|
570
|
+
cwd: runWorkDir,
|
|
568
571
|
stdin: "ignore",
|
|
569
572
|
stdout: "pipe",
|
|
570
573
|
stderr: "pipe",
|
|
@@ -623,7 +626,7 @@ export async function serve() {
|
|
|
623
626
|
log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
|
|
624
627
|
try {
|
|
625
628
|
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
626
|
-
cwd:
|
|
629
|
+
cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
627
630
|
windowsHide: true,
|
|
628
631
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
629
632
|
});
|
|
@@ -653,16 +656,16 @@ export async function serve() {
|
|
|
653
656
|
];
|
|
654
657
|
const outputFiles: string[] = [];
|
|
655
658
|
for (const f of mentionedFiles) {
|
|
656
|
-
if (!isUnderDir(
|
|
657
|
-
if (await file(join(
|
|
659
|
+
if (!isUnderDir(runWorkDir, f)) continue;
|
|
660
|
+
if (await file(join(runWorkDir, f)).exists()) {
|
|
658
661
|
outputFiles.push(f);
|
|
659
662
|
} else {
|
|
660
663
|
const basename = f.split("/").pop()!;
|
|
661
|
-
for (const subdir of [".playwright-cli", ".
|
|
664
|
+
for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
|
|
662
665
|
// Forward-slash for the wire: join() would use "\" on the Windows daemon, which
|
|
663
666
|
// a POSIX client can't treat as a separator (it builds a literal-backslash path).
|
|
664
667
|
const subpath = `${subdir}/${basename}`;
|
|
665
|
-
if (await file(join(
|
|
668
|
+
if (await file(join(runWorkDir, subpath)).exists()) {
|
|
666
669
|
outputFiles.push(subpath);
|
|
667
670
|
break;
|
|
668
671
|
}
|
|
@@ -677,40 +680,55 @@ export async function serve() {
|
|
|
677
680
|
stderr: rebrand(stderr),
|
|
678
681
|
// Normalize any platform separators to "/" so relative paths are portable across
|
|
679
682
|
// a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
|
|
680
|
-
files: outputFiles.map((p) => p.replaceAll("\\", "/")),
|
|
683
|
+
files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
|
|
681
684
|
});
|
|
682
685
|
},
|
|
683
686
|
});
|
|
684
687
|
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
690
|
-
// Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
|
|
691
|
-
// released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
|
|
692
|
-
// last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
|
|
693
|
-
// the port" rather than a permanent EADDRINUSE crash-loop.
|
|
694
|
-
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
695
|
-
const MAX_BIND_ATTEMPTS = 4;
|
|
696
|
-
let server: ReturnType<typeof startServer> | undefined;
|
|
697
|
-
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
|
688
|
+
// Stage all new sockets before changing policy. Bind failure leaves existing
|
|
689
|
+
// listeners and browser sessions intact; never kill a process holding another port.
|
|
690
|
+
const reconcile = async (listeners: Listener[]) => {
|
|
691
|
+
const staged = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
698
692
|
try {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (!isEaddrInUse(e)) throw e;
|
|
703
|
-
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
704
|
-
log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
705
|
-
server = startServer(true);
|
|
706
|
-
break;
|
|
693
|
+
for (const listener of listeners) {
|
|
694
|
+
const address = listenerAddress(listener);
|
|
695
|
+
if (!servers.has(address)) staged.set(address, startServer(listener));
|
|
707
696
|
}
|
|
708
|
-
|
|
709
|
-
|
|
697
|
+
} catch (error) {
|
|
698
|
+
for (const server of staged.values()) server.stop(true);
|
|
699
|
+
throw error;
|
|
710
700
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
701
|
+
policies.clear();
|
|
702
|
+
for (const listener of listeners) policies.set(listenerAddress(listener), listener);
|
|
703
|
+
for (const [address, server] of staged) { servers.set(address, server); log(`listening on ${address}`); }
|
|
704
|
+
for (const [address, server] of servers) {
|
|
705
|
+
if (!policies.has(address)) { server.stop(false); servers.delete(address); log(`listener removed: ${address}`); }
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
let applied = "";
|
|
709
|
+
let configured = false;
|
|
710
|
+
const reload = async () => {
|
|
711
|
+
const config = await readListeners();
|
|
712
|
+
if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
|
|
713
|
+
const listeners = config?.listeners ?? [legacy];
|
|
714
|
+
const fingerprint = JSON.stringify(listeners);
|
|
715
|
+
if (applied === fingerprint) return;
|
|
716
|
+
await reconcile(listeners);
|
|
717
|
+
configured ||= !!config;
|
|
718
|
+
applied = fingerprint;
|
|
719
|
+
};
|
|
720
|
+
await reload();
|
|
721
|
+
let reloading = false;
|
|
722
|
+
let lastError = "";
|
|
723
|
+
setInterval(async () => {
|
|
724
|
+
if (reloading) return;
|
|
725
|
+
reloading = true;
|
|
726
|
+
try { await reload(); lastError = ""; }
|
|
727
|
+
catch (error) {
|
|
728
|
+
const message = String(error);
|
|
729
|
+
if (message !== lastError) log(`listener reload rejected; previous configuration retained: ${message}`);
|
|
730
|
+
lastError = message;
|
|
731
|
+
} finally { reloading = false; }
|
|
732
|
+
}, 1000);
|
|
733
|
+
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
716
734
|
}
|
package/serve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.ts";
|
|
1
2
|
import { file } from "bun";
|
|
2
3
|
import { createHash, X509Certificate } from "crypto";
|
|
3
|
-
import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
|
|
4
|
+
import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
4
5
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
6
|
import {
|
|
6
7
|
log,
|
|
@@ -8,10 +9,13 @@ import {
|
|
|
8
9
|
getOrCreateUrl,
|
|
9
10
|
authCheck,
|
|
10
11
|
RECH_DIR,
|
|
12
|
+
LEGACY_RECH_DIR,
|
|
13
|
+
migrateLegacyDataDir,
|
|
11
14
|
HOME,
|
|
12
15
|
PASSTHROUGH_ENV_KEYS,
|
|
13
16
|
resolvePlaywrightCli,
|
|
14
|
-
|
|
17
|
+
readTokenRegistry,
|
|
18
|
+
} from "./rechrome.ts";
|
|
15
19
|
|
|
16
20
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
17
21
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -239,45 +243,6 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
239
243
|
return nameOrEmail;
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
243
|
-
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
244
|
-
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
245
|
-
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
246
|
-
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
247
|
-
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
248
|
-
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
249
|
-
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
250
|
-
async function freeStalePort(port: number): Promise<void> {
|
|
251
|
-
try {
|
|
252
|
-
if (process.platform === "win32") {
|
|
253
|
-
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
254
|
-
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
255
|
-
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
256
|
-
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
257
|
-
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
258
|
-
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
259
|
-
const ps = [
|
|
260
|
-
"$ErrorActionPreference='SilentlyContinue';",
|
|
261
|
-
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
262
|
-
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
263
|
-
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
264
|
-
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
265
|
-
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
266
|
-
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
267
|
-
"}",
|
|
268
|
-
].join(" ");
|
|
269
|
-
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
270
|
-
const out = r.stdout?.toString().trim();
|
|
271
|
-
if (out) log(out);
|
|
272
|
-
} else {
|
|
273
|
-
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
274
|
-
}
|
|
275
|
-
} catch {
|
|
276
|
-
// best effort — the retry will surface a clear error if the port is still held
|
|
277
|
-
}
|
|
278
|
-
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
279
|
-
}
|
|
280
|
-
|
|
281
246
|
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
247
|
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
248
|
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
@@ -300,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
|
|
|
300
265
|
}
|
|
301
266
|
|
|
302
267
|
export async function serve() {
|
|
268
|
+
// The daemon owns logs/ and output/, so it migrates them before writing anything.
|
|
269
|
+
const migrated = migrateLegacyDataDir();
|
|
303
270
|
const url = await getOrCreateUrl();
|
|
304
271
|
const { key, port } = parseUrl(url);
|
|
305
272
|
|
|
306
273
|
const workDir = join(RECH_DIR, "output");
|
|
307
274
|
mkdirSync(workDir, { recursive: true });
|
|
275
|
+
if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
|
|
308
276
|
|
|
309
277
|
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
278
|
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
@@ -341,20 +309,30 @@ export async function serve() {
|
|
|
341
309
|
}, 86_400_000);
|
|
342
310
|
}
|
|
343
311
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
reusePort,
|
|
312
|
+
const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
|
|
313
|
+
const policies = new Map<string, Listener>();
|
|
314
|
+
const servers = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
315
|
+
const startServer = (initial: Listener) => Bun.serve({
|
|
316
|
+
hostname: initial.host,
|
|
317
|
+
port: initial.port,
|
|
351
318
|
tls,
|
|
352
319
|
error(err) {
|
|
353
320
|
log(`unhandled error: ${err.message}`);
|
|
354
321
|
return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
|
|
355
322
|
},
|
|
356
|
-
async fetch(req) {
|
|
323
|
+
async fetch(req, server) {
|
|
324
|
+
const listener = policies.get(listenerAddress(initial));
|
|
325
|
+
if (!listener) return new Response("Listener removed", { status: 403 });
|
|
326
|
+
const key = listener.key;
|
|
327
|
+
const listenHost = listener.host;
|
|
328
|
+
const scoped = listener.profiles !== "*";
|
|
357
329
|
const reqUrl = new URL(req.url);
|
|
330
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
331
|
+
if (prefix !== "/") {
|
|
332
|
+
if (reqUrl.pathname === prefix.slice(0, -1)) reqUrl.pathname = "/";
|
|
333
|
+
else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
|
|
334
|
+
else return new Response("Not found", { status: 404 });
|
|
335
|
+
}
|
|
358
336
|
|
|
359
337
|
// Serve files from output dir
|
|
360
338
|
if (reqUrl.pathname.startsWith("/files/")) {
|
|
@@ -362,9 +340,12 @@ export async function serve() {
|
|
|
362
340
|
if (denied) return denied;
|
|
363
341
|
const name = decodeURIComponent(reqUrl.pathname.slice(7));
|
|
364
342
|
if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
|
|
343
|
+
if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
|
|
365
344
|
const resolved = resolve(workDir, name);
|
|
366
345
|
const f = file(resolved);
|
|
367
346
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
347
|
+
const real = realpathSync(resolved);
|
|
348
|
+
if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
368
349
|
return new Response(f);
|
|
369
350
|
}
|
|
370
351
|
|
|
@@ -375,8 +356,8 @@ export async function serve() {
|
|
|
375
356
|
// relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
|
|
376
357
|
// clients / `rech status` can see trouble without an active probe.
|
|
377
358
|
const degraded = consecutiveTimeouts > 0;
|
|
378
|
-
if (!reqUrl.searchParams.get("deep"))
|
|
379
|
-
return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
|
|
359
|
+
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
360
|
+
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
|
|
380
361
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
381
362
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
382
363
|
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
@@ -404,6 +385,28 @@ export async function serve() {
|
|
|
404
385
|
markActivity(); // a real command: this serve is not idle
|
|
405
386
|
|
|
406
387
|
const body = await req.json();
|
|
388
|
+
let scopedProfile: string | undefined;
|
|
389
|
+
let profileEnv: Record<string, string> = {};
|
|
390
|
+
if (scoped) {
|
|
391
|
+
try {
|
|
392
|
+
scopedProfile = authorizeProfileRequest(listener, body);
|
|
393
|
+
const registry = await readTokenRegistry();
|
|
394
|
+
const entry = registry[scopedProfile];
|
|
395
|
+
if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
|
|
396
|
+
profileEnv = {
|
|
397
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: entry.profileDir,
|
|
398
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: entry.extensionId,
|
|
399
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: entry.token,
|
|
400
|
+
PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
|
|
401
|
+
PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
|
|
402
|
+
};
|
|
403
|
+
body.identity.key = `scoped:${body.identity.key}`;
|
|
404
|
+
body.env = {}; // Server-owned profile configuration wins over every client override.
|
|
405
|
+
} catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
|
|
406
|
+
}
|
|
407
|
+
const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
|
|
408
|
+
const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
|
|
409
|
+
mkdirSync(runWorkDir, { recursive: true });
|
|
407
410
|
let args: string[];
|
|
408
411
|
let sessionId: string;
|
|
409
412
|
let clientName = "";
|
|
@@ -480,7 +483,7 @@ export async function serve() {
|
|
|
480
483
|
if (isOpenCmd) {
|
|
481
484
|
try {
|
|
482
485
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
483
|
-
cwd:
|
|
486
|
+
cwd: runWorkDir,
|
|
484
487
|
stdin: "ignore",
|
|
485
488
|
stdout: "pipe",
|
|
486
489
|
stderr: "pipe",
|
|
@@ -518,10 +521,10 @@ export async function serve() {
|
|
|
518
521
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
519
522
|
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
520
523
|
}
|
|
521
|
-
Object.assign(passthroughEnv, clientEnv);
|
|
524
|
+
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
522
525
|
|
|
523
526
|
// Resolve profile name/email → directory name
|
|
524
|
-
if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
527
|
+
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
525
528
|
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
526
529
|
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
527
530
|
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
@@ -564,7 +567,7 @@ export async function serve() {
|
|
|
564
567
|
|
|
565
568
|
const commandStartedAt = Date.now();
|
|
566
569
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
567
|
-
cwd:
|
|
570
|
+
cwd: runWorkDir,
|
|
568
571
|
stdin: "ignore",
|
|
569
572
|
stdout: "pipe",
|
|
570
573
|
stderr: "pipe",
|
|
@@ -623,7 +626,7 @@ export async function serve() {
|
|
|
623
626
|
log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
|
|
624
627
|
try {
|
|
625
628
|
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
626
|
-
cwd:
|
|
629
|
+
cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
627
630
|
windowsHide: true,
|
|
628
631
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
629
632
|
});
|
|
@@ -653,16 +656,16 @@ export async function serve() {
|
|
|
653
656
|
];
|
|
654
657
|
const outputFiles: string[] = [];
|
|
655
658
|
for (const f of mentionedFiles) {
|
|
656
|
-
if (!isUnderDir(
|
|
657
|
-
if (await file(join(
|
|
659
|
+
if (!isUnderDir(runWorkDir, f)) continue;
|
|
660
|
+
if (await file(join(runWorkDir, f)).exists()) {
|
|
658
661
|
outputFiles.push(f);
|
|
659
662
|
} else {
|
|
660
663
|
const basename = f.split("/").pop()!;
|
|
661
|
-
for (const subdir of [".playwright-cli", ".
|
|
664
|
+
for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
|
|
662
665
|
// Forward-slash for the wire: join() would use "\" on the Windows daemon, which
|
|
663
666
|
// a POSIX client can't treat as a separator (it builds a literal-backslash path).
|
|
664
667
|
const subpath = `${subdir}/${basename}`;
|
|
665
|
-
if (await file(join(
|
|
668
|
+
if (await file(join(runWorkDir, subpath)).exists()) {
|
|
666
669
|
outputFiles.push(subpath);
|
|
667
670
|
break;
|
|
668
671
|
}
|
|
@@ -677,40 +680,55 @@ export async function serve() {
|
|
|
677
680
|
stderr: rebrand(stderr),
|
|
678
681
|
// Normalize any platform separators to "/" so relative paths are portable across
|
|
679
682
|
// a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
|
|
680
|
-
files: outputFiles.map((p) => p.replaceAll("\\", "/")),
|
|
683
|
+
files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
|
|
681
684
|
});
|
|
682
685
|
},
|
|
683
686
|
});
|
|
684
687
|
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
690
|
-
// Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
|
|
691
|
-
// released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
|
|
692
|
-
// last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
|
|
693
|
-
// the port" rather than a permanent EADDRINUSE crash-loop.
|
|
694
|
-
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
695
|
-
const MAX_BIND_ATTEMPTS = 4;
|
|
696
|
-
let server: ReturnType<typeof startServer> | undefined;
|
|
697
|
-
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
|
688
|
+
// Stage all new sockets before changing policy. Bind failure leaves existing
|
|
689
|
+
// listeners and browser sessions intact; never kill a process holding another port.
|
|
690
|
+
const reconcile = async (listeners: Listener[]) => {
|
|
691
|
+
const staged = new Map<string, ReturnType<typeof Bun.serve>>();
|
|
698
692
|
try {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (!isEaddrInUse(e)) throw e;
|
|
703
|
-
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
704
|
-
log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
705
|
-
server = startServer(true);
|
|
706
|
-
break;
|
|
693
|
+
for (const listener of listeners) {
|
|
694
|
+
const address = listenerAddress(listener);
|
|
695
|
+
if (!servers.has(address)) staged.set(address, startServer(listener));
|
|
707
696
|
}
|
|
708
|
-
|
|
709
|
-
|
|
697
|
+
} catch (error) {
|
|
698
|
+
for (const server of staged.values()) server.stop(true);
|
|
699
|
+
throw error;
|
|
710
700
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
701
|
+
policies.clear();
|
|
702
|
+
for (const listener of listeners) policies.set(listenerAddress(listener), listener);
|
|
703
|
+
for (const [address, server] of staged) { servers.set(address, server); log(`listening on ${address}`); }
|
|
704
|
+
for (const [address, server] of servers) {
|
|
705
|
+
if (!policies.has(address)) { server.stop(false); servers.delete(address); log(`listener removed: ${address}`); }
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
let applied = "";
|
|
709
|
+
let configured = false;
|
|
710
|
+
const reload = async () => {
|
|
711
|
+
const config = await readListeners();
|
|
712
|
+
if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
|
|
713
|
+
const listeners = config?.listeners ?? [legacy];
|
|
714
|
+
const fingerprint = JSON.stringify(listeners);
|
|
715
|
+
if (applied === fingerprint) return;
|
|
716
|
+
await reconcile(listeners);
|
|
717
|
+
configured ||= !!config;
|
|
718
|
+
applied = fingerprint;
|
|
719
|
+
};
|
|
720
|
+
await reload();
|
|
721
|
+
let reloading = false;
|
|
722
|
+
let lastError = "";
|
|
723
|
+
setInterval(async () => {
|
|
724
|
+
if (reloading) return;
|
|
725
|
+
reloading = true;
|
|
726
|
+
try { await reload(); lastError = ""; }
|
|
727
|
+
catch (error) {
|
|
728
|
+
const message = String(error);
|
|
729
|
+
if (message !== lastError) log(`listener reload rejected; previous configuration retained: ${message}`);
|
|
730
|
+
lastError = message;
|
|
731
|
+
} finally { reloading = false; }
|
|
732
|
+
}, 1000);
|
|
733
|
+
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
716
734
|
}
|