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/rechrome.js CHANGED
@@ -13,8 +13,12 @@ import { join, basename, dirname, resolve } from "path";
13
13
  import { pathToFileURL } from "url";
14
14
  import { createRequire } from "node:module";
15
15
  import { spawn as cpSpawn } from "child_process";
16
- import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
16
+ import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
17
17
  import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
18
+ import { DEFAULT_SIGNAL, WEBRTC, createShare, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
19
+ import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
20
+ import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
21
+ import { startSignalRelay } from "./signal-relay.js";
18
22
 
19
23
  export const ENV_KEY = "RECHROME_URL";
20
24
  export const DEFAULT_PORT = 13775;
@@ -322,8 +326,11 @@ export function parseUrl(raw: string) {
322
326
  const scheme = u.protocol.replace(":", "");
323
327
  const protocol = scheme === "https" ? "https" : "http";
324
328
  const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
329
+ // A rech1_ share link authenticates with the bearer derived from its token (secret.js).
330
+ const shareToken = u.hash.slice(1).split("&")[0];
331
+ const shareBearer = looksLikeToken(shareToken) ? (() => { try { return bearerOf(shareToken); } catch { return undefined; } })() : undefined;
325
332
  return {
326
- key: fragment.get("key") ?? u.username,
333
+ key: shareBearer ?? fragment.get("key") ?? u.username,
327
334
  prefix: normalizePrefix(u.pathname),
328
335
  host: u.hostname,
329
336
  port: parseInt(u.port) || defaultPort,
@@ -828,18 +835,53 @@ const CHROME_LOCAL_STATE_PATHS = () => {
828
835
 
829
836
  type ChromeProfileInfo = { user_name?: string; name?: string };
830
837
 
838
+ /**
839
+ * macOS privacy (TCC) blocks a process launched by launchd/oxmgr from Chrome's data folder unless
840
+ * its binary has Full Disk Access; a Terminal or agent session inherits the grant. Turn that EPERM
841
+ * into the fix. Undefined for any other error (or platform).
842
+ */
843
+ export function chromeDataAccessHint(error: unknown, execPath = process.execPath, platform = process.platform): string | undefined {
844
+ const code = (error as { code?: string } | null)?.code;
845
+ const message = error instanceof Error ? error.message : String(error);
846
+ if (platform !== "darwin" || !(code === "EPERM" || code === "EACCES" || /EPERM|operation not permitted/i.test(message))) return undefined;
847
+ return `macOS privacy settings (TCC) block ${execPath} from reading Chrome's profile folder. ` +
848
+ `Grant it Full Disk Access: System Settings → Privacy & Security → Full Disk Access → +, press ⌘⇧G, paste ${execPath}, ` +
849
+ `then restart rech serve. Or run rech serve from Terminal, which already has access (see docs/macos-daemon.md).`;
850
+ }
851
+
852
+ /** Why the last readChromeProfileCache() returned null (undefined when it succeeded). */
853
+ let profileCacheProblem: string | undefined;
854
+
831
855
  export async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
856
+ profileCacheProblem = undefined;
857
+ const problems: string[] = [];
832
858
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
833
859
  const f = file(statePath);
834
860
  if (!(await f.exists())) continue;
835
861
  try {
836
862
  const data = JSON.parse(await f.text());
837
- return data?.profile?.info_cache ?? null;
838
- } catch {}
863
+ if (data?.profile?.info_cache) return data.profile.info_cache;
864
+ problems.push(`${statePath} has no profile list`);
865
+ } catch (error) {
866
+ const hint = chromeDataAccessHint(error);
867
+ problems.push(`${statePath} is unreadable (${error instanceof Error ? error.message : String(error)})${hint ? `. ${hint}` : ""}`);
868
+ }
839
869
  }
870
+ profileCacheProblem = problems.join("; ") || "Chrome's Local State file was not found";
840
871
  return null;
841
872
  }
842
873
 
874
+ /**
875
+ * Say so (once per process) when profile emails/names are unavailable, so a bare folder name
876
+ * like "Profile 1" is never mistaken for the whole story.
877
+ */
878
+ let warnedProfileCache = false;
879
+ export function warnIfProfileNamesUnavailable(cache: Record<string, ChromeProfileInfo> | null): void {
880
+ if (cache || warnedProfileCache) return;
881
+ warnedProfileCache = true;
882
+ console.error(`[rech] Chrome profile emails and names are unavailable (${profileCacheProblem ?? "Local State not read"}); profiles are shown by folder name only.`);
883
+ }
884
+
843
885
  export function resolveChromeProfileSelector(
844
886
  profiles: Array<[string, ChromeProfileInfo]>,
845
887
  selector: string,
@@ -1294,6 +1336,16 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
1294
1336
 
1295
1337
  /** On a client of a remote host, `rech profile` lists what that host's link shares. */
1296
1338
  async function listRemoteProfiles(url: string): Promise<void> {
1339
+ // Newer hosts list names too (/profiles); older ones only /ping.
1340
+ const detailed = await fetch(serviceUrl(url, "profiles"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) })
1341
+ .then(r => r.ok ? r.json() : null).catch(() => null) as { share?: string; profiles?: { key: string; name: string }[] } | null;
1342
+ if (detailed?.profiles) {
1343
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
1344
+ console.log(`Profiles ${detailed.share ? `share "${detailed.share}" gives you` : `shared by ${serviceUrl(url)}`}:`);
1345
+ for (const p of detailed.profiles) console.log(` ${p.key}${p.name ? ` (${p.name})` : ""}${p.key === current ? " ← current" : ""}`);
1346
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
1347
+ return;
1348
+ }
1297
1349
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
1298
1350
  .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
1299
1351
  if (response.status === 401) throw new Error("The host rejected this link's key; ask for a fresh one (rech share on the host), then rech connect '<url>'.");
@@ -1307,6 +1359,7 @@ async function listRemoteProfiles(url: string): Promise<void> {
1307
1359
 
1308
1360
  async function listProfiles(): Promise<void> {
1309
1361
  const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
1362
+ warnIfProfileNamesUnavailable(cache);
1310
1363
  const profiles = buildProfileRows(cache, registry, root);
1311
1364
  const listeners = await readListeners();
1312
1365
  const url = process.env[ENV_KEY];
@@ -1406,7 +1459,7 @@ export function registeredProfileChoices(registry: Record<string, TokenEntry>, c
1406
1459
  const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
1407
1460
  const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
1408
1461
  const shown = [...new Set(names)];
1409
- return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
1462
+ return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : `${dir} (no email or name known)`, value: key };
1410
1463
  });
1411
1464
  }
1412
1465
 
@@ -1523,7 +1576,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1523
1576
  if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1524
1577
  console.log(uri);
1525
1578
  console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
1526
- if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1579
+ if (opts.save) { const saved = await saveProjectUrl(uri); console.error(`Saved RECHROME_URL to ${saved}`); await warnSecretFile(saved); }
1527
1580
  }
1528
1581
 
1529
1582
  /** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
@@ -1640,6 +1693,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1640
1693
  const interactive = isInteractive();
1641
1694
  const registry = await readTokenRegistry();
1642
1695
  const cache = await readChromeProfileCache();
1696
+ warnIfProfileNamesUnavailable(cache);
1643
1697
  const cancelled = () => new Error("Cancelled; nothing shared.");
1644
1698
  let config = await readListeners();
1645
1699
  const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
@@ -1770,7 +1824,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1770
1824
  const publicUrl = pending?.publicUrl;
1771
1825
  if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
1772
1826
  console.log(uri);
1773
- if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1827
+ if (opts.save) { const saved = await saveProjectUrl(uri); console.error(`Saved RECHROME_URL to ${saved}`); await warnSecretFile(saved); }
1774
1828
  }
1775
1829
 
1776
1830
  export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
@@ -1786,6 +1840,8 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1786
1840
  * profiles itself, and must never receive this machine's own extension tokens.
1787
1841
  */
1788
1842
  export async function isLocalDaemon(url: string): Promise<boolean> {
1843
+ // A WebRTC bridge is on loopback but always leads to another machine.
1844
+ if (bridgedUrls.has(url)) return false;
1789
1845
  const { key, host } = parseUrl(url);
1790
1846
  const config = await readListeners().catch(() => null);
1791
1847
  // Before listeners.json existed, a local install pointed at loopback and had its own registry;
@@ -1999,7 +2055,16 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
1999
2055
  ].join("\n");
2000
2056
  }
2001
2057
 
2002
- async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
2058
+ /**
2059
+ * An `open` whose extension bridge never connected. Typical right after Chrome restarts: the
2060
+ * relay's connect page reaches no live worker, yet a fresh bridge moments later connects at once
2061
+ * (seen 2026-10-17: a timeout, then `rech setup`'s probe connected in 0.1s). Worth one retry.
2062
+ */
2063
+ export function isBridgeTimeout(args: string[], result: { status: number; stderr?: string }): boolean {
2064
+ return args.find(a => !a.startsWith("-")) === "open" && result.status !== 0 && /Extension connection timeout/i.test(result.stderr ?? "");
2065
+ }
2066
+
2067
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean; bridgeRetried?: boolean } = {}) {
2003
2068
  // Match the underlying CLI's command names while accepting the short forms humans
2004
2069
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
2005
2070
  args = normalizeCommandArgs(args);
@@ -2020,6 +2085,13 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2020
2085
  ({ args, upload } = await prepareStdinUpload(args, process.stdin));
2021
2086
  }
2022
2087
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity, false, upload);
2088
+ if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
2089
+ // Close the failed session so the retry gets a fresh cliDaemon and relay. callServe re-reads
2090
+ // the extension's live token from the profile, so a re-minted token heals here too.
2091
+ console.error(`[rech] the Chrome extension bridge did not answer (common right after Chrome restarts); retrying once with a fresh connection…`);
2092
+ await callServe(url, [...args.filter(a => /^-s=/.test(a)), "close"], overrideEnv, identity).catch(() => {});
2093
+ return run(url, args, overrideEnv, { ...opts, bridgeRetried: true });
2094
+ }
2023
2095
 
2024
2096
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
2025
2097
  if (existingSession && isOpenWithUrl) {
@@ -2039,9 +2111,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2039
2111
  // The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
2040
2112
  // only blame a missing install when neither side had credentials.
2041
2113
  const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
2114
+ const profileArg = displayProfile ? ` --profile ${JSON.stringify(displayProfile)}` : "";
2042
2115
  const last = hasToken
2043
- ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
2044
- : ` -> extension[not installed] (run: rech setup)`;
2116
+ ? ` -x: extension did not connect${opts.bridgeRetried ? " (also after an automatic retry)" : ""} -> extension[degraded]\n` +
2117
+ `[rech] reload "Playwright MCP Bridge" at chrome://extensions${displayProfile ? ` in ${displayProfile}'s window` : ""}, then retry; ` +
2118
+ `or run \`rech setup${profileArg}\`, which re-detects the extension and token and verifies the connection.`
2119
+ : ` -> extension[not installed] (run: rech setup${profileArg})`;
2045
2120
  console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
2046
2121
  }
2047
2122
  if (stderr.includes("Browser '") && stderr.includes("is not open")) {
@@ -2076,6 +2151,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2076
2151
  process.exit(status);
2077
2152
  }
2078
2153
 
2154
+ /** The daemon's Playwright CLI predates `open --wait` (rechrome <= 1.35.1 bundled one). */
2155
+ export function cliRejectsWaitOption(output: string): boolean {
2156
+ return /Unknown options?:?[^\n]*?['"`]?--wait\b/i.test(output);
2157
+ }
2158
+
2159
+ /**
2160
+ * setup's bridge probe: open about:blank without waiting for a load. The daemon owns the CLI and
2161
+ * may be older than this client, so feature-check by behavior: if the CLI rejects `--wait`, probe
2162
+ * again without it rather than reporting a broken bridge.
2163
+ */
2164
+ export async function probeBridgeOpen<T extends { status: number; stdout: string; stderr: string }>(
2165
+ call: (args: string[]) => Promise<T>,
2166
+ note: (message: string) => void = () => {},
2167
+ ): Promise<T> {
2168
+ const probe = await call(["open", "about:blank", "--wait", "none"]);
2169
+ if (probe.status === 0 || !cliRejectsWaitOption(`${probe.stderr}\n${probe.stdout}`)) return probe;
2170
+ note("The daemon's Playwright CLI does not support `open --wait`; probing without it (update rechrome on the daemon host).");
2171
+ return call(["open", "about:blank"]);
2172
+ }
2173
+
2079
2174
  export type SetupPhase = "extension" | "token" | "bridge" | "error" | "save" | "ready";
2080
2175
 
2081
2176
  export function createSetupGuide(extDistDir: string, profileDisplay: string) {
@@ -2536,6 +2631,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
2536
2631
  [ENV_KEY]: serveUrl,
2537
2632
  PWMCP_TEST_CONNECTION_TIMEOUT: process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000",
2538
2633
  PLAYWRIGHT_CLI: resolvedPlaywrightCli,
2634
+ RECH_SERVE_MANAGED: "1", // the manager restarts it; never idle-self-exit (see serve.js)
2539
2635
  };
2540
2636
  if (process.env.RECH_HOST) daemonEnv.RECH_HOST = process.env.RECH_HOST;
2541
2637
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
@@ -3093,6 +3189,38 @@ async function urlList(): Promise<void> {
3093
3189
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
3094
3190
  }
3095
3191
 
3192
+ /**
3193
+ * Ways an env file holding a connection secret could leak: tracked or committed in git, not
3194
+ * git-ignored (one `git add .` away), or readable by other users. Warnings only — rech keeps
3195
+ * working; the caller prints them. Empty when the file looks safe or doesn't exist.
3196
+ */
3197
+ export async function secretFileWarnings(path: string, platform = process.platform): Promise<string[]> {
3198
+ const warnings: string[] = [];
3199
+ const st = await stat(path).catch(() => null);
3200
+ if (!st?.isFile()) return warnings;
3201
+ const dir = dirname(path), name = basename(path);
3202
+ if (await gitOutput(["rev-parse", "--is-inside-work-tree"], dir) === "true") {
3203
+ const untrack = `git rm --cached ${JSON.stringify(name)} (in ${dir}) and add it to .gitignore`;
3204
+ // Only a currently tracked file counts: an old commit of a since-untracked file held an older key.
3205
+ if (await gitOutput(["ls-files", "--", name], dir)) {
3206
+ if (await gitOutput(["log", "-1", "--format=%h", "--", name], dir))
3207
+ warnings.push(`${path} is committed to git, so its key is in the repo history. Ask the host for a fresh link (they rotate the key), then ${untrack}.`);
3208
+ else warnings.push(`${path} is staged in git; committing it would publish its key. Run ${untrack}.`);
3209
+ }
3210
+ else if (!(await gitOutput(["check-ignore", "--", name], dir)))
3211
+ warnings.push(`${path} is not git-ignored; a \`git add .\` would commit its key. Add it to .gitignore.`);
3212
+ }
3213
+ if (platform !== "win32" && (st.mode & 0o077))
3214
+ warnings.push(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}). Run: chmod 600 ${JSON.stringify(path)}`);
3215
+ return warnings;
3216
+ }
3217
+
3218
+ /** Print secretFileWarnings for a file that holds RECHROME_URL. Never throws. */
3219
+ async function warnSecretFile(path: string | undefined): Promise<void> {
3220
+ if (!path || path === "environment") return;
3221
+ for (const w of await secretFileWarnings(path).catch(() => [])) console.error(`[rech] ⚠ ${w}`);
3222
+ }
3223
+
3096
3224
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
3097
3225
  async function saveProjectUrl(url: string): Promise<string> {
3098
3226
  const { dataDir } = await getClientIdentity();
@@ -3133,8 +3261,15 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
3133
3261
  }
3134
3262
  }
3135
3263
 
3136
- async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
3264
+ type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean };
3265
+
3266
+ async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
3137
3267
  if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3268
+ // A bare rech1_ token means the default signal server.
3269
+ if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
3270
+ const share = shareLinkParts(url);
3271
+ if (share && !shared) return connectShare(url, share, opts);
3272
+ if (share && shared) return saveConnection(url, opts, shared);
3138
3273
  const parsed = parseUrl(url);
3139
3274
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
3140
3275
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
@@ -3160,6 +3295,7 @@ async function connect(url: string, opts: { global?: boolean; project?: boolean
3160
3295
  console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
3161
3296
  const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3162
3297
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3298
+ await warnSecretFile(saved);
3163
3299
  const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
3164
3300
  if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
3165
3301
  if (Array.isArray(body.profiles) && body.profiles.length > 1) {
@@ -3169,6 +3305,32 @@ async function connect(url: string, opts: { global?: boolean; project?: boolean
3169
3305
  }
3170
3306
  }
3171
3307
 
3308
+ /** Save a share device's link (after connectShare redeemed or checked it) and say what it gives. */
3309
+ async function saveConnection(url: string, opts: { global?: boolean; project?: boolean }, shared: ConnectedShare): Promise<void> {
3310
+ let global = !!opts.global;
3311
+ if (!opts.global && !opts.project && isInteractive()) {
3312
+ const { dataDir } = await getClientIdentity();
3313
+ const choice = await promptChoice("Use this link for:", [
3314
+ { label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
3315
+ { label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
3316
+ ], 0);
3317
+ if (!choice) throw new Error("Cancelled; nothing saved.");
3318
+ global = choice === "global";
3319
+ }
3320
+ const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3321
+ await warnSecretFile(saved);
3322
+ const via = shared.webrtc ? `WebRTC via ${new URL(url).host}` : serviceUrl(url);
3323
+ console.log(`Connected to share "${shared.share ?? "?"}" (${via}) as device ${shared.device?.name ?? "?"} (${shared.device?.id ?? "?"}).`);
3324
+ console.log(`Saved this device's own link ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3325
+ console.log(`The invite you used no longer works for anyone else.`);
3326
+ if (shared.device && !shared.device.approved) console.log(`\nThe host must approve this device before it can drive the browser: rech share approve ${shared.share} ${shared.device.id}`);
3327
+ if (shared.profiles.length > 1) {
3328
+ console.log(`\nThis share has ${shared.profiles.length} profiles:`);
3329
+ for (const p of shared.profiles) console.log(` ${p}`);
3330
+ console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
3331
+ } else if (shared.profiles.length === 1) console.log(`Profile: ${shared.profiles[0]}. Try: rech open https://example.com`);
3332
+ }
3333
+
3172
3334
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
3173
3335
  // These are hints, not proof of identity or permission to automate the desktop.
3174
3336
  if (env.RECH_SETUP_AGENT === "none") return null;
@@ -3294,7 +3456,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3294
3456
  console.log(`✓ [1/5] Local management daemon verified; requested profile access: ${desiredBind}`);
3295
3457
 
3296
3458
  const cache = await readChromeProfileCache();
3297
- if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
3459
+ if (!cache) { console.error(` Chrome profiles not found: ${profileCacheProblem ?? "Local State not read"}`); rl?.close(); process.exit(1); }
3298
3460
  const userDataDir = await findChromeUserDataDir();
3299
3461
 
3300
3462
  async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
@@ -3420,12 +3582,9 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3420
3582
  guide?.takeRetry();
3421
3583
  guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
3422
3584
  try {
3423
- const probe = await callServe(
3424
- rechUrl.toString(),
3425
- [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
3426
- probeEnv,
3427
- probeIdentity,
3428
- true,
3585
+ const probe = await probeBridgeOpen(
3586
+ args => callServe(rechUrl.toString(), [`-s=${probeSession}`, ...args], probeEnv, probeIdentity, true),
3587
+ note => console.log(` ${note}`),
3429
3588
  );
3430
3589
  bridgeVerified = probe.status === 0;
3431
3590
  if (bridgeVerified) {
@@ -3567,30 +3726,326 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3567
3726
  console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
3568
3727
  }
3569
3728
 
3729
+ // ---- shares: invite links, devices, transports (see shares.js) ----------------------------
3730
+
3731
+ const fmtDate = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 16).replace("T", " ") : "-";
3732
+ const fmtDay = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 10) : "never";
3733
+
3734
+ /** A listener to carry a share over lan/tailscale/<IP>/<listener name>; created when missing. */
3735
+ async function shareTransportListener(selector: string, profiles: string[], label: string): Promise<string> {
3736
+ const config = await requireListeners();
3737
+ const named = config.listeners.find(l => l.name === selector);
3738
+ if (named) {
3739
+ if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over lan, tailscale or webrtc instead.`);
3740
+ return named.name;
3741
+ }
3742
+ const address = chooseListenAddress(await detectListenChoices(), selector);
3743
+ const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3744
+ if (existing) return existing.name;
3745
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3746
+ config.listeners.push(listener);
3747
+ await writeListeners(config);
3748
+ if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
3749
+ return listener.name;
3750
+ }
3751
+
3752
+ /** Where a share's invite can be used, as full links (each holds the secret). */
3753
+ async function shareLinks(share: Share): Promise<{ via: string; link: string }[]> {
3754
+ const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
3755
+ return share.via.map(via => {
3756
+ if (via === WEBRTC) return { via: "webrtc", link: inviteLink(share, share.signal ?? DEFAULT_SIGNAL) };
3757
+ const l = listeners.find(x => x.name === via);
3758
+ if (!l) return { via, link: "(listener removed)" };
3759
+ return { via: l.name, link: inviteLink(share, l.publicUrl ?? `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`) };
3760
+ });
3761
+ }
3762
+
3763
+ function printInvite(share: Share, links: { via: string; link: string }[]): void {
3764
+ const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
3765
+ console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
3766
+ console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
3767
+ for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(10)} ` : ""}${link}`);
3768
+ const rtc = links.find(l => l.via === "webrtc");
3769
+ if (rtc && share.signal === DEFAULT_SIGNAL) console.log(`\n or in a terminal: rech connect ${share.invite}`);
3770
+ console.log(`\nThe other machine runs: rech connect '<link>'`);
3771
+ if (share.approve) console.log(`Each new device waits for you: rech share approve ${share.label}`);
3772
+ console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
3773
+ }
3774
+
3775
+ export type ShareNewOptions = { name?: string; via?: string[]; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
3776
+
3777
+ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<void> {
3778
+ if (!selectors.length) throw new Error("Which profiles? rech share new <profile..> (see rech profile)");
3779
+ const profiles = await resolveProfileKeys(selectors);
3780
+ // An explicit --name must be free; a derived one gets -2, -3… so a second share just works.
3781
+ const taken = new Set((await readShares()).shares.map(s => s.label));
3782
+ const base = profileSlug(profiles[0]).slice(0, 36);
3783
+ let label = (opts.name ?? base).toLowerCase();
3784
+ for (let i = 2; !opts.name && taken.has(label); i++) label = `${base}-${i}`;
3785
+ // Fail on the name before binding any listener for it.
3786
+ if (taken.has(label)) throw new Error(`A share named "${label}" already exists. Pick another --name, or: rech share rotate ${label}`);
3787
+ if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(label)) throw new Error(`Share names use a-z, 0-9, "-" and "_" (up to 40), e.g. alice or ci-bot; got "${label}"`);
3788
+ const wanted = (opts.via?.length ? opts.via : [WEBRTC]).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
3789
+ const via: string[] = [];
3790
+ for (const v of wanted) via.push(v === WEBRTC ? WEBRTC : await shareTransportListener(v, profiles, label));
3791
+ const expires = opts.expires ?? (via.includes(WEBRTC) ? "30d" : "never");
3792
+ const expiresMs = parseDuration(expires);
3793
+ const share = await updateShares(config => createShare(config, {
3794
+ label, profiles, via, signal: opts.signal ?? process.env.RECH_SIGNAL,
3795
+ expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
3796
+ inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
3797
+ maxDevices: opts.devices ?? 1, approve: opts.approve,
3798
+ }));
3799
+ // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
3800
+ if (via.includes(WEBRTC)) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
3801
+ printInvite(share, await shareLinks(share));
3802
+ }
3803
+
3804
+ async function shareList(): Promise<void> {
3805
+ const { shares } = await readShares();
3806
+ if (shares.length) {
3807
+ const now = Date.now();
3808
+ const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
3809
+ for (const s of shares) {
3810
+ const pending = s.devices.filter(d => !d.approved).length;
3811
+ const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
3812
+ : inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
3813
+ rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), status]);
3814
+ }
3815
+ printTable(rows);
3816
+ console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
3817
+ }
3818
+ const listeners = await readListeners().catch(() => null);
3819
+ if (listeners) {
3820
+ if (shares.length) console.log("Listener links (legacy #key=…):");
3821
+ await urlList();
3822
+ } else if (!shares.length) console.log("Nothing shared yet. Share a profile: rech share new <profile>");
3823
+ }
3824
+
3825
+ async function shareDevices(name: string, remove?: string): Promise<void> {
3826
+ if (remove) {
3827
+ const device = await updateShares(config => {
3828
+ const share = findShare(config, name);
3829
+ const d = findDevice(share, remove);
3830
+ share.devices = share.devices.filter(x => x !== d);
3831
+ return d;
3832
+ });
3833
+ console.log(`Removed ${device.name} (${device.id}) from "${name}"; its sessions close and its link stops working.`);
3834
+ return;
3835
+ }
3836
+ const share = findShare(await readShares(), name);
3837
+ if (!share.devices.length) { console.log(`No device has used "${share.label}" yet.${inviteProblem(share) ? "" : " Its invite is still open."}`); return; }
3838
+ const rows = [["DEVICE", "NAME", "APPROVED", "ADDED", "LAST USED"]];
3839
+ for (const d of share.devices) rows.push([d.id, d.name, d.approved ? "yes" : "waiting", fmtDate(d.addedAt), fmtDate(d.lastSeen)]);
3840
+ printTable(rows);
3841
+ }
3842
+
3843
+ async function shareEdit(action: string, name: string, args: string[], opts: { expires?: string } = {}): Promise<void> {
3844
+ if (action === "allow" || action === "deny") {
3845
+ if (!args.length) throw new Error(`Which profiles? rech share ${action} ${name} <profile..>`);
3846
+ const keys = await resolveProfileKeys(args);
3847
+ const share = await updateShares(config => {
3848
+ const s = findShare(config, name);
3849
+ const next = action === "allow" ? [...new Set([...s.profiles, ...keys])] : s.profiles.filter(p => !keys.includes(p));
3850
+ if (!next.length) throw new Error(`That would leave "${s.label}" with no profiles; revoke it instead: rech share revoke ${s.label}`);
3851
+ s.profiles = next;
3852
+ return s;
3853
+ });
3854
+ console.log(`"${share.label}" now shares: ${share.profiles.join(", ")} (connected devices see it on their next command; the link is unchanged).`);
3855
+ } else if (action === "rotate") {
3856
+ const share = await updateShares(config => {
3857
+ const s = findShare(config, name);
3858
+ s.invite = newToken();
3859
+ s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
3860
+ if (s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
3861
+ return s;
3862
+ });
3863
+ console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
3864
+ printInvite(share, await shareLinks(share));
3865
+ } else if (action === "revoke") {
3866
+ await updateShares(config => { findShare(config, name).revokedAt = Date.now(); });
3867
+ console.log(`Revoked "${name}": its links and devices stop working, and its sessions close. Remove it entirely: rech share rm ${name}`);
3868
+ } else if (action === "rm") {
3869
+ await updateShares(config => { const s = findShare(config, name); config.shares = config.shares.filter(x => x !== s); });
3870
+ console.log(`Removed "${name}".`);
3871
+ } else if (action === "renew") {
3872
+ const ms = parseDuration(opts.expires ?? args[0] ?? "30d");
3873
+ const share = await updateShares(config => {
3874
+ const s = findShare(config, name);
3875
+ if (s.revokedAt) throw new Error(`"${s.label}" was revoked; create a new share instead: rech share new ${s.profiles.join(" ")}`);
3876
+ s.expiresAt = ms ? Date.now() + ms : undefined;
3877
+ return s;
3878
+ });
3879
+ console.log(`"${share.label}" now expires: ${fmtDay(share.expiresAt)}`);
3880
+ } else if (action === "approve") {
3881
+ const approved = await updateShares(config => {
3882
+ const s = findShare(config, name);
3883
+ const targets = args.length ? args.map(a => findDevice(s, a)) : s.devices.filter(d => !d.approved);
3884
+ for (const d of targets) d.approved = true;
3885
+ return targets;
3886
+ });
3887
+ console.log(approved.length ? `Approved: ${approved.map(d => `${d.name} (${d.id})`).join(", ")}` : `No device of "${name}" is waiting for approval.`);
3888
+ }
3889
+ }
3890
+
3891
+ // ---- client side of a rech1_ link -----------------------------------------------------------
3892
+
3893
+ /** A rech1_ link's parts: the token, the origin it points at, and the transport once known. */
3894
+ export function shareLinkParts(url: string): { token: string; origin: string; webrtc: boolean; profile?: string } | null {
3895
+ let u: URL;
3896
+ try { u = new URL(url); } catch { return null; }
3897
+ const [token, ...rest] = u.hash.slice(1).split("&");
3898
+ if (!looksLikeToken(token)) return null;
3899
+ const params = new URLSearchParams(rest.join("&"));
3900
+ return { token, origin: `${u.protocol}//${u.host}${normalizePrefix(u.pathname)}`, webrtc: params.get("via") === WEBRTC, profile: u.searchParams.get("profile") ?? undefined };
3901
+ }
3902
+
3903
+ /** Call a share's HTTP API over whichever transport its link uses (one-shot, for connect). */
3904
+ async function shareRequest(origin: string, token: string, webrtc: boolean, method: string, path: string, body?: unknown): Promise<Response> {
3905
+ if (!webrtc) return fetch(serviceUrl(origin, path), {
3906
+ method, headers: { Authorization: `Bearer ${bearerOf(token)}`, ...(body ? { "Content-Type": "application/json" } : {}) },
3907
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000),
3908
+ });
3909
+ const client = new WebrtcClient(token, origin, m => console.error(m));
3910
+ await client.connect();
3911
+ try {
3912
+ const res = await client.request(method, `/${path}`, body ? JSON.stringify(body) : undefined, body ? "application/json" : undefined);
3913
+ return new Response(await res.arrayBuffer(), { status: res.status, headers: res.headers });
3914
+ } finally { client.close(); }
3915
+ }
3916
+
3917
+ /** `rech connect` for a rech1_ link: find the transport, redeem the invite, save this device's link. */
3918
+ async function connectShare(url: string, parts: { token: string; origin: string }, opts: { global?: boolean; project?: boolean }): Promise<void> {
3919
+ const parsed = parseToken(parts.token);
3920
+ if (!parsed.ok) throw new Error(parsed.reason === "checksum"
3921
+ ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped. Ask for it again."
3922
+ : "That is not a complete rech1_ link.");
3923
+ // One unauthenticated probe tells a signal server (WebRTC) from a rech daemon (direct HTTP).
3924
+ // The bearer is a working HTTP credential, so it never goes to a signal server.
3925
+ const get = (auth: boolean) => fetch(serviceUrl(parts.origin, "ping"), { headers: auth ? { Authorization: `Bearer ${bearerOf(parts.token)}` } : {}, signal: AbortSignal.timeout(8000) })
3926
+ .catch(error => { throw new Error(`Could not reach ${parts.origin}: ${error instanceof Error ? error.message : error}`); });
3927
+ const read = async (r: Response) => { const text = await r.text().catch(() => ""); try { return { text, body: JSON.parse(text) }; } catch { return { text, body: null as any }; } };
3928
+ let probe = await get(false);
3929
+ let { text: probeText, body: probeBody } = await read(probe);
3930
+ if (probe.status === 503 && !probeBody && probeText.trim()) throw new Error(probeText.trim());
3931
+ const webrtc = !!probeBody?.["rech-signal"];
3932
+ if (!webrtc && probe.status === 401) ({ text: probeText, body: probeBody } = await read(probe = await get(true)));
3933
+ if (!webrtc && probe.status === 401) throw new Error("The host rejected this link. Ask for a fresh one (rech share rotate <share> on the host).");
3934
+ if (!webrtc && !probe.ok) throw new Error(probeBody?.error ?? `${serviceUrl(parts.origin, "ping")} answered ${probe.status}; is this a rech link?`);
3935
+ if (webrtc) console.error(`[rech] connecting to the host over WebRTC via ${new URL(parts.origin).host}…`);
3936
+ const ping = webrtc ? await (await shareRequest(parts.origin, parts.token, true, "GET", "ping")).json() as any : probeBody;
3937
+ if (ping?.error) throw new Error(ping.error);
3938
+ let token = parts.token, device = ping?.device, profiles: string[] = Array.isArray(ping?.profiles) ? ping.profiles : [];
3939
+ const share = ping?.share;
3940
+ if (ping?.kind === "invite") {
3941
+ const res = await shareRequest(parts.origin, parts.token, webrtc, "POST", "redeem", { name: hostname().replace(/\.local$/, "") });
3942
+ const body = await res.json().catch(() => ({})) as any;
3943
+ if (!res.ok || !body.token) throw new Error(body.error ?? `The host refused this invite (HTTP ${res.status}).`);
3944
+ token = body.token; device = body.device; profiles = body.profiles ?? profiles;
3945
+ }
3946
+ const saved = new URL(parts.origin);
3947
+ if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
3948
+ saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
3949
+ await connect(saved.toString(), opts, { share, device, profiles, webrtc });
3950
+ }
3951
+
3952
+ // ---- WebRTC client bridge -------------------------------------------------------------------
3953
+
3954
+ /** The command that runs this rech (for spawning `rech bridge`). */
3955
+ const selfCommand = () => [process.execPath, Bun.main];
3956
+
3957
+ /**
3958
+ * For a WebRTC link, the loopback URL of a running bridge to it (started when missing); any other
3959
+ * URL comes back unchanged. Everything downstream then speaks plain HTTP.
3960
+ */
3961
+ const bridgedUrls = new Set<string>();
3962
+
3963
+ export async function transportUrl(url: string): Promise<string> {
3964
+ const parts = shareLinkParts(url);
3965
+ if (!parts?.webrtc) return url;
3966
+ const info = await spawnBridge(parts.token, parts.origin, selfCommand());
3967
+ const bridged = new URL(`http://127.0.0.1:${info.port}/`);
3968
+ new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
3969
+ bridged.hash = `key=${info.key}`;
3970
+ bridgedUrls.add(bridged.toString());
3971
+ return bridged.toString();
3972
+ }
3973
+
3974
+ async function bridgeCommand(): Promise<void> {
3975
+ const token = process.env.RECH_BRIDGE_TOKEN, signal = process.env.RECH_BRIDGE_SIGNAL;
3976
+ if (!token || !signal) throw new Error("rech bridge is started by rech itself for WebRTC links.");
3977
+ delete process.env.RECH_BRIDGE_TOKEN;
3978
+ await runBridge(token, signal, { log: m => console.error(m), onStop: () => process.exit(0) });
3979
+ }
3980
+
3981
+ // ---- sessions (client view) -----------------------------------------------------------------
3982
+
3983
+ async function sessionCommand(action: string, name?: string): Promise<void> {
3984
+ const raw = process.env[ENV_KEY];
3985
+ if (!raw) throw new Error(notConnectedMessage());
3986
+ const url = await transportUrl(raw);
3987
+ if (action === "close") {
3988
+ if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
3989
+ return run(url, ["close", `-s=${name}`]);
3990
+ }
3991
+ const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
3992
+ const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
3993
+ if (!res.ok || !body?.sessions) throw new Error(body?.stderr?.trim() || `The host does not list sessions (HTTP ${res.status}); it may run an older rech.`);
3994
+ if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
3995
+ const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
3996
+ for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
3997
+ printTable(rows);
3998
+ console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
3999
+ }
4000
+
4001
+ async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
4002
+ const server = startSignalRelay({ host: opts.host ?? "127.0.0.1", port: opts.port ?? 8787, log });
4003
+ console.log(`rech signal server (rech-signal-1) on http://${server.hostname}:${server.port}/`);
4004
+ console.log(`Put HTTPS in front of it, then share through it: rech share new <profile> --signal https://<your-host>/`);
4005
+ }
4006
+
3570
4007
  async function status(): Promise<void> {
3571
- const url = process.env[ENV_KEY];
3572
- if (!url) {
4008
+ const rawUrl = process.env[ENV_KEY];
4009
+ if (!rawUrl) {
3573
4010
  console.log(`serve: not configured`);
3574
4011
  console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
3575
4012
  return;
3576
4013
  }
4014
+ // A WebRTC share link is checked through its local bridge, like every command.
4015
+ const link = shareLinkParts(rawUrl);
4016
+ let url = rawUrl;
4017
+ if (link?.webrtc) {
4018
+ try {
4019
+ url = await transportUrl(rawUrl);
4020
+ console.log(`via: WebRTC through ${new URL(link.origin).host} (local bridge ${serviceUrl(url)})`);
4021
+ } catch (error) {
4022
+ console.log(`via: ✗ WebRTC through ${new URL(link.origin).host}: ${error instanceof Error ? error.message : error}`);
4023
+ if (rechromeUrlSource && rechromeUrlSource !== "environment") console.log(`config: ${rechromeUrlSource}`);
4024
+ return;
4025
+ }
4026
+ }
3577
4027
  const parsed = parseUrl(url);
3578
4028
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
3579
4029
  // The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
3580
4030
  const pingResponse = ping
3581
4031
  ? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
3582
4032
  : null;
3583
- const pingBody = pingResponse?.ok
3584
- ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
4033
+ const pingBody = pingResponse?.ok || pingResponse?.status === 403
4034
+ ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number; chromeAccessProblem?: string; share?: string; kind?: string; device?: { id: string; name: string; approved: boolean }; expiresAt?: number; error?: string } | null
3585
4035
  : null;
3586
4036
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3587
4037
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3588
4038
  // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
3589
- const answered = pingResponse?.ok || pingResponse?.status === 401;
4039
+ const answered = pingResponse?.ok || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
3590
4040
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3591
4041
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3592
4042
  : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3593
4043
  if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
4044
+ if (rechromeUrlSource && rechromeUrlSource !== "environment")
4045
+ for (const w of await secretFileWarnings(rechromeUrlSource).catch(() => [])) console.log(`secret: ⚠ ${w}`);
4046
+ if (pingBody?.share)
4047
+ console.log(`share: ${pingBody.share}${pingBody.device ? `, device ${pingBody.device.name} (${pingBody.device.id})${pingBody.device.approved ? "" : " — ⚠ waiting for the host's approval"}` : pingBody.kind === "invite" ? " — an unused invite: run rech connect '<link>' to register this device" : ""}${pingBody.expiresAt ? `, expires ${fmtDay(pingBody.expiresAt)}` : ""}`);
4048
+ if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
3594
4049
  if (pingResponse?.status === 401)
3595
4050
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3596
4051
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3602,6 +4057,8 @@ async function status(): Promise<void> {
3602
4057
  // No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
3603
4058
  let mgr: DaemonManager | undefined;
3604
4059
  if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
4060
+ if (pingBody?.chromeAccessProblem)
4061
+ console.log(`chrome: ⚠ daemon ${pingBody.chromeAccessProblem}`);
3605
4062
  if (pingBody?.degraded)
3606
4063
  console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; ${isHost ? `force it now with \`${mgr?.id ?? "oxmgr"} restart ${PM_PROCESS_NAME}\`` : "the daemon host can restart it"}`);
3607
4064
  if (isHost) {
@@ -3643,10 +4100,19 @@ export type RechHandlers = {
3643
4100
  tray(action?: string): Promise<void>;
3644
4101
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
3645
4102
  uninstall(): Promise<void>;
4103
+ shareNew(profiles: string[], opts: ShareNewOptions): Promise<void>;
4104
+ shareList(): Promise<void>;
4105
+ shareDevices(name: string, remove?: string): Promise<void>;
4106
+ shareEdit(action: string, name: string, args: string[], opts?: { expires?: string }): Promise<void>;
4107
+ session(action: string, name?: string): Promise<void>;
4108
+ signalServe(opts: { host?: string; port?: number }): Promise<void>;
4109
+ bridge(): Promise<void>;
3646
4110
  };
3647
4111
 
4112
+ const SHARE_ACTIONS = new Set(["new", "allow", "deny", "rotate", "revoke", "rm", "renew", "approve", "devices"]);
4113
+
3648
4114
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
3649
- export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "setup", "tray", "provision-profile", "uninstall"]);
4115
+ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "session", "sessions", "signal", "bridge", "setup", "tray", "provision-profile", "uninstall"]);
3650
4116
 
3651
4117
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
3652
4118
 
@@ -3674,8 +4140,9 @@ Examples:
3674
4140
  rech setup --profile you@example.com set up Chrome on this machine
3675
4141
  rech open https://example.com open a page in this project's session
3676
4142
  rech screenshot saved to <project>/.rechrome/output/
3677
- rech share you@example.com URL to give another machine (secret)
3678
- rech connect '<url>' use that URL in this project
4143
+ rech share new you@example.com invite link for another machine (WebRTC, no open port)
4144
+ rech connect '<link>' use that link in this project
4145
+ rech share ls shares, devices, expiry
3679
4146
 
3680
4147
  Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
3681
4148
 
@@ -3714,15 +4181,30 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3714
4181
  throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
3715
4182
  })
3716
4183
  // Share with and connect from other machines
3717
- .command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
3718
- .positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
4184
+ .command("share [profiles..]", "Share profiles: `share new <profile..>` makes an invite link (WebRTC by default); `share ls` lists all", y => y
4185
+ .positional("profiles", { type: "string", array: true, describe: "new <profile..> | ls | devices|approve|allow|deny|rotate|renew|revoke|rm <share> […]; or a profile for its legacy listener link" })
4186
+ .option("name", { type: "string", requiresArg: true, describe: "With new: the share's name (default: from the first profile)" })
4187
+ .option("via", { type: "string", array: true, requiresArg: true, describe: "With new: webrtc (default) | lan | tailscale | <IP> | <listener>; repeat or comma-separate for several" })
4188
+ .option("signal", { type: "string", requiresArg: true, describe: `With new: signal server for webrtc (default ${DEFAULT_SIGNAL}, or RECH_SIGNAL)` })
4189
+ .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4190
+ .option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
4191
+ .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
4192
+ .option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
4193
+ .option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
3719
4194
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
3720
4195
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
3721
4196
  .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
3722
4197
  .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
3723
4198
  a => {
3724
4199
  const profiles = (a.profiles ?? []) as string[];
3725
- if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
4200
+ const [action, target, ...rest] = profiles;
4201
+ if (action === "new") return handlers.shareNew(profiles.slice(1), { name: a.name, via: a.via as string[] | undefined, signal: a.signal, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve });
4202
+ if (action && SHARE_ACTIONS.has(action)) {
4203
+ if (!target) throw new Error(`Which share? rech share ${action} <share> (see rech share ls)`);
4204
+ if (action === "devices") return handlers.shareDevices(target, a.rm);
4205
+ return handlers.shareEdit(action, target, rest, { expires: a.expires });
4206
+ }
4207
+ if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.shareList();
3726
4208
  if (profiles.length > 1) {
3727
4209
  if (a.all) throw new Error("Pass profiles or --all, not both.");
3728
4210
  return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
@@ -3730,11 +4212,12 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3730
4212
  return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3731
4213
  })
3732
4214
  .command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
3733
- .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
4215
+ .positional("url", { type: "string", demandOption: true, describe: "The link from `rech share` on the machine with Chrome (or a bare rech1_ token). Quote it: it contains #" })
3734
4216
  .option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
3735
4217
  .option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
3736
4218
  .conflicts("project", "global")
3737
4219
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", "")
4220
+ .example("rech connect 'https://rechrome.pages.dev/#rech1_…'", "An invite: registers this device, then saves its own link")
3738
4221
  .example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
3739
4222
  a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
3740
4223
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
@@ -3765,6 +4248,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3765
4248
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
3766
4249
  a => handlers.removeListener(a.name))
3767
4250
  .demandCommand(1).strict())
4251
+ .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
4252
+ .positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
4253
+ .positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
4254
+ a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
4255
+ .command("signal [action]", "Run a WebRTC signal server for your own shares (see docs/signal-selfhost.md)", y => y
4256
+ .positional("action", { type: "string", choices: ["serve"], default: "serve" })
4257
+ .option("host", { type: "string", requiresArg: true, describe: "Address to bind (default 127.0.0.1; put HTTPS in front)" })
4258
+ .option("port", { ...portOption, describe: "Port (default 8787)" }),
4259
+ a => handlers.signalServe({ host: a.host, port: a.port }))
4260
+ .command("bridge", false, {}, () => handlers.bridge())
3768
4261
  // Daemon and extras
3769
4262
  .command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
3770
4263
  .positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
@@ -3800,7 +4293,8 @@ if (import.meta.main) {
3800
4293
  status,
3801
4294
  listListeners, addListener, removeListener, printProfileUri,
3802
4295
  listProfiles: async () => {
3803
- const url = process.env[ENV_KEY];
4296
+ const raw = process.env[ENV_KEY];
4297
+ const url = raw ? await transportUrl(raw) : raw;
3804
4298
  return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3805
4299
  },
3806
4300
  urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
@@ -3823,6 +4317,10 @@ if (import.meta.main) {
3823
4317
  await provisionProfile(name, { headed });
3824
4318
  },
3825
4319
  uninstall: daemonUninstall,
4320
+ shareNew, shareList, shareDevices, shareEdit,
4321
+ session: sessionCommand,
4322
+ signalServe: async opts => { await signalServe(opts); await new Promise(() => {}); },
4323
+ bridge: async () => { await bridgeCommand(); await new Promise(() => {}); },
3826
4324
  };
3827
4325
 
3828
4326
  if (cmd && RECH_COMMANDS.has(cmd)) {
@@ -3832,7 +4330,7 @@ if (import.meta.main) {
3832
4330
  console.error(error instanceof Error ? error.message : String(error));
3833
4331
  process.exitCode = 1;
3834
4332
  } finally {
3835
- if (cmd !== "serve") envWatcher?.close();
4333
+ if (!["serve", "signal", "bridge"].includes(cmd)) envWatcher?.close();
3836
4334
  }
3837
4335
  } else if (cmd === "--version") {
3838
4336
  console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
@@ -3841,11 +4339,16 @@ if (import.meta.main) {
3841
4339
  try { await rechCli(["--help"], handlers).parseAsync(); }
3842
4340
  finally { envWatcher?.close(); }
3843
4341
  } else {
3844
- const url = process.env[ENV_KEY];
3845
- if (!url) {
4342
+ const rawUrl = process.env[ENV_KEY];
4343
+ if (!rawUrl) {
3846
4344
  console.error(notConnectedMessage());
3847
4345
  process.exit(1);
3848
4346
  }
4347
+ // A WebRTC share link goes through a local bridge; from here on it is plain HTTP.
4348
+ const url = await transportUrl(rawUrl).catch(error => {
4349
+ console.error(`[rech] ${error instanceof Error ? error.message : error}`);
4350
+ process.exit(1);
4351
+ });
3849
4352
  // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
3850
4353
  // the leading-flags-only rule that protects the forwarded CLI's own --profile).
3851
4354
  let profileSelector: string | undefined;