rechrome 1.46.0 → 1.48.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 +12 -1
- package/e2e.js +2 -2
- package/e2e.ts +2 -2
- package/extension-token.js +37 -1
- package/extension-token.ts +37 -1
- package/fill-secret.js +542 -40
- package/fill-secret.ts +293 -33
- package/listeners.js +9 -5
- package/listeners.ts +10 -4
- package/package.json +9 -2
- package/rechrome.js +152 -34
- package/rechrome.ts +152 -34
- package/record.js +281 -0
- package/record.ts +251 -0
- package/rtc-channel.js +303 -0
- package/rtc-channel.ts +130 -0
- package/secret.js +15 -10
- package/secret.ts +2 -70
- package/serve.js +137 -13
- package/serve.ts +137 -13
- package/shares.js +49 -12
- package/shares.ts +39 -6
- package/token.js +88 -0
- package/token.ts +74 -0
- package/webrtc.js +92 -85
- package/webrtc.ts +6 -128
package/rechrome.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.js";
|
|
3
3
|
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, parseIceUrl, iceLabel, rtcListeners, shareRtcListeners, defaultRtcListener, DEFAULT_RTC, DEFAULT_RTC_NOTE, LEGACY_RTC, type IceServer, type Listener, type ListenerConfig, type RtcListener } from "./listeners.js";
|
|
4
|
+
import { recordHoldMs } from "./record.js";
|
|
4
5
|
|
|
5
6
|
import { file } from "bun";
|
|
6
7
|
import yargs from "yargs";
|
|
7
8
|
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
8
9
|
import { redact } from "./redact.js";
|
|
9
|
-
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
|
|
10
|
+
import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, READ_HEADERS_USAGE, parseReadHeadersArgs, readHeadersWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
|
|
10
11
|
import { createHash, randomBytes } from "crypto";
|
|
11
12
|
import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
12
13
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
@@ -17,7 +18,7 @@ import { createRequire } from "node:module";
|
|
|
17
18
|
import { spawn as cpSpawn } from "child_process";
|
|
18
19
|
import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
|
|
19
20
|
import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
|
|
20
|
-
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
|
|
21
|
+
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, REUSABLE_DEFAULT_TTL, REUSABLE_MAX_DEVICES, readShares, updateShares, type Share } from "./shares.js";
|
|
21
22
|
import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
|
|
22
23
|
import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
|
|
23
24
|
import { startSignalRelay } from "./signal-relay.js";
|
|
@@ -1961,7 +1962,8 @@ async function callServe(
|
|
|
1961
1962
|
// dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
|
|
1962
1963
|
// `secret` (fill-secret only) rides in its own field so it is never part of the logged args.
|
|
1963
1964
|
body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}), ...(secret ? { secret } : {}) }),
|
|
1964
|
-
|
|
1965
|
+
// A timed `record --seconds N` holds the request for N seconds before it answers.
|
|
1966
|
+
signal: AbortSignal.timeout(70_000 + recordHoldMs(args)),
|
|
1965
1967
|
}).catch(async (e) => {
|
|
1966
1968
|
if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
|
|
1967
1969
|
console.error(`[rech] ${e.message}`);
|
|
@@ -2059,7 +2061,7 @@ export function withDefaultScheme(target: string): string {
|
|
|
2059
2061
|
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
2060
2062
|
// missing value; accepts multiple occurrences (last one wins).
|
|
2061
2063
|
/**
|
|
2062
|
-
* `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
|
|
2064
|
+
* `rech [--profile X] [--isolate[=<name>]] pw <args>` forwards <args> to playwright-cli verbatim, so
|
|
2063
2065
|
* `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
|
|
2064
2066
|
* same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
|
|
2065
2067
|
* Bun consumes the `--` right after the script path. The separator counts only when
|
|
@@ -2071,12 +2073,32 @@ export function rechSeparatorIndex(args: string[]): number {
|
|
|
2071
2073
|
const a = args[i];
|
|
2072
2074
|
if (a === "--" || a === "pw" || a === "playwright") return i;
|
|
2073
2075
|
if (a === "--profile") { i++; continue; }
|
|
2074
|
-
if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
|
|
2076
|
+
if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a)) continue;
|
|
2075
2077
|
return -1;
|
|
2076
2078
|
}
|
|
2077
2079
|
return -1;
|
|
2078
2080
|
}
|
|
2079
2081
|
|
|
2082
|
+
/** Prefix of a named isolated session (`--isolate=<name>` -> `-s=iso_<name>`); `_` keeps it out of the iso-<hex> reaper. */
|
|
2083
|
+
export const NAMED_ISO_PREFIX = "iso_";
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* Turn rech's `--isolate` / `--isolate=<name>` into a session flag. Bare `--isolate` is a
|
|
2087
|
+
* throwaway `-s=iso-<random>` (reaped when idle); `--isolate=<name>` is the persistent
|
|
2088
|
+
* `-s=iso_<name>`, reused by later commands with the same name until `rech session close <name>`.
|
|
2089
|
+
*/
|
|
2090
|
+
export function applyIsolateArg(args: string[]): string[] {
|
|
2091
|
+
const idx = args.findIndex(a => a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a));
|
|
2092
|
+
if (idx === -1) return args;
|
|
2093
|
+
const flag = args[idx];
|
|
2094
|
+
const rest = args.filter((_, i) => i !== idx);
|
|
2095
|
+
if (!flag.includes("=")) return [...rest, `-s=iso-${randomBytes(8).toString("hex")}`];
|
|
2096
|
+
const name = flag.slice(flag.indexOf("=") + 1);
|
|
2097
|
+
if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error(`--isolate=<name> takes 1-60 letters, digits, '-' or '_' (got "${name}")`);
|
|
2098
|
+
if (rest.some(a => a.startsWith("-s="))) throw new Error("Pass --isolate=<name> or -s=<session>, not both.");
|
|
2099
|
+
return [...rest, `-s=${NAMED_ISO_PREFIX}${name}`];
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2080
2102
|
/** rechrome's own version, from the package.json shipped next to this file. */
|
|
2081
2103
|
export function rechromeVersion(): string {
|
|
2082
2104
|
try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
|
|
@@ -2241,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2241
2263
|
console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
|
|
2242
2264
|
process.exit(0);
|
|
2243
2265
|
}
|
|
2266
|
+
if (args[cmdIdx] === "read-headers") {
|
|
2267
|
+
// read-headers writes a JSON evidence file (no bodies, credential header values redacted), so it
|
|
2268
|
+
// goes through the normal file-download path below, not the secretOut pipe. Validate and
|
|
2269
|
+
// normalize the args here for an early error and help text, then fall through.
|
|
2270
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_HEADERS_USAGE); process.exit(0); }
|
|
2271
|
+
try {
|
|
2272
|
+
const hwire = readHeadersWireArgs(parseReadHeadersArgs(args.slice(cmdIdx)));
|
|
2273
|
+
args = [...args.filter(a => a.startsWith("-s=")), ...hwire];
|
|
2274
|
+
} catch (err) {
|
|
2275
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_HEADERS_USAGE}`);
|
|
2276
|
+
process.exit(2);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2244
2279
|
if (args[cmdIdx] === "fill-secret") {
|
|
2245
2280
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2246
2281
|
try {
|
|
@@ -2313,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2313
2348
|
}
|
|
2314
2349
|
process.stderr.write(stderr);
|
|
2315
2350
|
}
|
|
2316
|
-
|
|
2351
|
+
// Wait for the write: process.exit() below would otherwise cut a pipe off at its buffer
|
|
2352
|
+
// (128 KiB), e.g. a large run-code result read through `| jq`. A reader that stops early
|
|
2353
|
+
// (`| head`) closes the pipe: that is EPIPE, not an error.
|
|
2354
|
+
if (stdout) await new Promise<void>((done, fail) => process.stdout.write(stdout, e => {
|
|
2355
|
+
if (!e || (e as NodeJS.ErrnoException).code === "EPIPE") done(); else fail(e);
|
|
2356
|
+
}));
|
|
2317
2357
|
|
|
2318
2358
|
if (files?.length) {
|
|
2319
2359
|
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
@@ -3026,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
|
|
|
3026
3066
|
}
|
|
3027
3067
|
}
|
|
3028
3068
|
|
|
3069
|
+
// The Chromium revision pinned by the playwright-core that the relay (playwright-cli) runs, read
|
|
3070
|
+
// from its browsers.json. Provisioning must seed with that same build: the relay later reopens the
|
|
3071
|
+
// profile with it, and a profile written by a newer Chromium (e.g. 156, whose localStorage moved to
|
|
3072
|
+
// SQLite) loses the seeded token, so the extension mints a new one and the bridge never connects.
|
|
3073
|
+
export function relayChromiumRevision(cli: string = resolvePlaywrightCli()): string | null {
|
|
3074
|
+
try {
|
|
3075
|
+
const entry = cli.split(" ").pop()!;
|
|
3076
|
+
let dir = dirname(createRequire(entry).resolve("playwright-core/lib/tools/cli-client/program"));
|
|
3077
|
+
for (let i = 0; i < 8 && dir !== dirname(dir); i++, dir = dirname(dir)) {
|
|
3078
|
+
const browsers = join(dir, "browsers.json");
|
|
3079
|
+
if (!existsSync(browsers)) continue;
|
|
3080
|
+
const chromium = JSON.parse(readFileSync(browsers, "utf8")).browsers?.find((b: { name?: string }) => b.name === "chromium");
|
|
3081
|
+
return chromium?.revision ? String(chromium.revision) : null;
|
|
3082
|
+
}
|
|
3083
|
+
} catch {}
|
|
3084
|
+
return null;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3029
3087
|
// Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
|
|
3030
3088
|
// Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
|
|
3031
3089
|
// --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
|
|
3032
|
-
function findChromiumForTesting(): string | null {
|
|
3090
|
+
function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
|
|
3033
3091
|
// Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
|
|
3034
3092
|
// `playwright install` doesn't always write to the env path, so check both.
|
|
3035
3093
|
const bases = [
|
|
@@ -3042,6 +3100,8 @@ function findChromiumForTesting(): string | null {
|
|
|
3042
3100
|
let revs: string[];
|
|
3043
3101
|
try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
|
|
3044
3102
|
catch { continue; }
|
|
3103
|
+
// The relay's own build first; the newest installed one only if it is missing.
|
|
3104
|
+
if (preferredRevision) revs = revs.filter(r => r === `chromium-${preferredRevision}`).concat(revs.filter(r => r !== `chromium-${preferredRevision}`));
|
|
3045
3105
|
for (const rev of revs) {
|
|
3046
3106
|
const root = join(base, rev);
|
|
3047
3107
|
const candidates = process.platform === "darwin"
|
|
@@ -3489,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3489
3549
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3490
3550
|
}
|
|
3491
3551
|
|
|
3492
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
|
|
3552
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; readHeaders?: boolean; keepDownloads?: boolean }): Promise<void> {
|
|
3493
3553
|
const config = await requireListeners();
|
|
3494
3554
|
let listener = config.listeners.find(l => l.name === name);
|
|
3495
3555
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3515,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3515
3575
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3516
3576
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3517
3577
|
}
|
|
3578
|
+
if (opts.readHeaders !== undefined) {
|
|
3579
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-headers is always available there.`);
|
|
3580
|
+
if (opts.readHeaders) listener.readHeaders = true; else delete listener.readHeaders;
|
|
3581
|
+
}
|
|
3518
3582
|
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3519
3583
|
await writeListeners(config);
|
|
3520
3584
|
if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
|
|
3521
3585
|
if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
|
|
3522
3586
|
if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
|
|
3587
|
+
if (opts.readHeaders !== undefined) console.log(opts.readHeaders ? `${name}: read-headers on. Anyone with this link can capture response status + headers for any site its profiles reach; credential header values (cookie, authorization, tokens) and URL query values are redacted. Each capture is logged without those values` : `${name}: read-headers off`);
|
|
3523
3588
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3524
3589
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3525
3590
|
const dirs = (listener.uploads || {}).hostDirs;
|
|
@@ -3694,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
|
|
|
3694
3759
|
}
|
|
3695
3760
|
}
|
|
3696
3761
|
|
|
3697
|
-
type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean };
|
|
3762
|
+
type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean; sessions?: { name: string; device: string }[] };
|
|
3698
3763
|
|
|
3699
3764
|
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
|
|
3700
3765
|
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
3701
3766
|
// A bare rech1_ token means the default signal server.
|
|
3702
3767
|
if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
|
|
3703
3768
|
const share = shareLinkParts(url);
|
|
3704
|
-
if (share && !shared)
|
|
3769
|
+
if (share && !shared) {
|
|
3770
|
+
// node-datachannel's native threads can keep the process alive after every connection is
|
|
3771
|
+
// closed, so the command leaves explicitly: done, it must not hang on.
|
|
3772
|
+
try { await connectShare(url, share, opts); }
|
|
3773
|
+
catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }
|
|
3774
|
+
process.exit(0);
|
|
3775
|
+
}
|
|
3705
3776
|
if (share && shared) return saveConnection(url, opts, shared);
|
|
3706
3777
|
const parsed = parseUrl(url);
|
|
3707
3778
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
@@ -3762,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
|
|
|
3762
3833
|
for (const p of shared.profiles) console.log(` ${p}`);
|
|
3763
3834
|
console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
|
|
3764
3835
|
} else if (shared.profiles.length === 1) console.log(`Profile: ${shared.profiles[0]}. Try: rech open https://example.com`);
|
|
3836
|
+
if (shared.sessions?.length) {
|
|
3837
|
+
console.log(`\nOpen sessions on this share:`);
|
|
3838
|
+
for (const x of shared.sessions) console.log(` ${x.name} (device ${x.device})`);
|
|
3839
|
+
}
|
|
3765
3840
|
}
|
|
3766
3841
|
|
|
3767
3842
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
@@ -4309,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
|
|
|
4309
4384
|
function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
|
|
4310
4385
|
const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
|
|
4311
4386
|
console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
|
|
4312
|
-
|
|
4387
|
+
if (share.reusable) {
|
|
4388
|
+
const until = share.inviteExpiresAt ? `until ${fmtDate(share.inviteExpiresAt)}` : "with no expiry";
|
|
4389
|
+
console.log(`Reusable invite: up to ${share.maxDevices} devices may redeem it ${until}, each getting its own credential (secret: anyone with it can join; kill it with rech share rotate ${share.label}):\n`);
|
|
4390
|
+
} else console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
|
|
4313
4391
|
const width = Math.max(...links.map(l => l.via.length));
|
|
4314
4392
|
for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
|
|
4315
4393
|
if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
|
|
@@ -4321,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
|
|
|
4321
4399
|
console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
|
|
4322
4400
|
}
|
|
4323
4401
|
|
|
4324
|
-
export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
|
|
4402
|
+
export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean; reusable?: boolean };
|
|
4325
4403
|
|
|
4326
4404
|
/**
|
|
4327
4405
|
* The transports named on the command line, in order: --via entries (comma-separated), then
|
|
@@ -4377,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
|
|
|
4377
4455
|
const share = await updateShares(config => createShare(config, {
|
|
4378
4456
|
label, profiles, via,
|
|
4379
4457
|
expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
|
|
4380
|
-
inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
|
|
4381
|
-
maxDevices: opts.devices ?? 1, approve: opts.approve,
|
|
4458
|
+
inviteTtlMs: parseDuration(opts.inviteTtl ?? (opts.reusable ? REUSABLE_DEFAULT_TTL : "24h")),
|
|
4459
|
+
maxDevices: opts.devices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1), approve: opts.approve, reusable: opts.reusable,
|
|
4382
4460
|
}));
|
|
4383
4461
|
// The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
|
|
4384
4462
|
if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
|
|
@@ -4389,12 +4467,15 @@ async function shareList(): Promise<void> {
|
|
|
4389
4467
|
const { shares } = await readShares();
|
|
4390
4468
|
if (shares.length) {
|
|
4391
4469
|
const now = Date.now();
|
|
4392
|
-
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
|
|
4470
|
+
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
|
|
4393
4471
|
for (const s of shares) {
|
|
4394
4472
|
const pending = s.devices.filter(d => !d.approved).length;
|
|
4395
4473
|
const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
|
|
4396
4474
|
: inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
|
|
4397
|
-
|
|
4475
|
+
const invite = s.reusable
|
|
4476
|
+
? `reusable, ${s.redeemed ?? 0} redeemed, ${s.inviteExpiresAt && now >= s.inviteExpiresAt ? "expired" : `until ${s.inviteExpiresAt ? fmtDate(s.inviteExpiresAt) : "never"}`}`
|
|
4477
|
+
: "single-use";
|
|
4478
|
+
rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), invite, status]);
|
|
4398
4479
|
}
|
|
4399
4480
|
printTable(rows);
|
|
4400
4481
|
console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
|
|
@@ -4441,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
|
|
|
4441
4522
|
const s = findShare(config, name);
|
|
4442
4523
|
s.invite = newToken();
|
|
4443
4524
|
s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
|
|
4444
|
-
if (s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
|
|
4525
|
+
if (!s.reusable && s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
|
|
4526
|
+
if (s.reusable) s.redeemed = 0;
|
|
4445
4527
|
return s;
|
|
4446
4528
|
});
|
|
4447
4529
|
console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
|
|
@@ -4530,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
|
|
|
4530
4612
|
const saved = new URL(parts.origin);
|
|
4531
4613
|
if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
|
|
4532
4614
|
saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
|
|
4533
|
-
|
|
4615
|
+
const sessions = Array.isArray(ping?.sessions) ? ping.sessions as { name: string; device: string }[] : [];
|
|
4616
|
+
await connect(saved.toString(), opts, { share, device, profiles, webrtc, sessions });
|
|
4534
4617
|
}
|
|
4535
4618
|
|
|
4536
4619
|
// ---- WebRTC client bridge -------------------------------------------------------------------
|
|
@@ -4585,22 +4668,40 @@ async function bridgeCommand(): Promise<void> {
|
|
|
4585
4668
|
|
|
4586
4669
|
// ---- sessions (client view) -----------------------------------------------------------------
|
|
4587
4670
|
|
|
4671
|
+
type SessionRow = { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean };
|
|
4672
|
+
|
|
4673
|
+
async function fetchSessions(url: string): Promise<{ res: Response; body: { sessions?: SessionRow[]; stderr?: string } | null }> {
|
|
4674
|
+
const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
|
|
4675
|
+
return { res, body: await res.json().catch(() => null) as { sessions?: SessionRow[]; stderr?: string } | null };
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4678
|
+
/** `name` as given, unless only the named isolated session `iso_<name>` exists. */
|
|
4679
|
+
export function pickSessionName(name: string, known: string[]): string {
|
|
4680
|
+
if (name.startsWith(NAMED_ISO_PREFIX) || known.includes(name)) return name;
|
|
4681
|
+
return known.includes(NAMED_ISO_PREFIX + name) ? NAMED_ISO_PREFIX + name : name;
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
async function resolveSessionName(url: string, name: string): Promise<string> {
|
|
4685
|
+
const known = await fetchSessions(url).then(({ body }) => (body?.sessions ?? []).map(s => s.session)).catch(() => [] as string[]);
|
|
4686
|
+
return pickSessionName(name, known);
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4588
4689
|
async function sessionCommand(action: string, name?: string): Promise<void> {
|
|
4589
4690
|
const raw = process.env[ENV_KEY];
|
|
4590
4691
|
if (!raw) throw new Error(notConnectedMessage());
|
|
4591
4692
|
const url = await transportUrl(raw);
|
|
4592
4693
|
if (action === "close") {
|
|
4593
4694
|
if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
|
|
4594
|
-
|
|
4695
|
+
// A named isolated session closes by its --isolate name too (`login` for iso_login).
|
|
4696
|
+
return run(url, ["close", `-s=${await resolveSessionName(url, name)}`]);
|
|
4595
4697
|
}
|
|
4596
|
-
const res
|
|
4597
|
-
const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
|
|
4698
|
+
const { res, body } = await fetchSessions(url);
|
|
4598
4699
|
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.`);
|
|
4599
4700
|
if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
|
|
4600
4701
|
const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
|
|
4601
4702
|
for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
|
|
4602
4703
|
printTable(rows);
|
|
4603
|
-
console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
|
|
4704
|
+
console.log(`\nUse one: rech -s=<session> open https://example.com (an ${NAMED_ISO_PREFIX}<name> session: rech --isolate=<name> …) · close it: rech session close <session>`);
|
|
4604
4705
|
}
|
|
4605
4706
|
|
|
4606
4707
|
async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
|
|
@@ -4731,18 +4832,30 @@ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from s
|
|
|
4731
4832
|
Usage: rech <command> [options] · rech <browser-command> [args]`;
|
|
4732
4833
|
|
|
4733
4834
|
const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
4734
|
-
rech [--profile <p>] [--isolate] <browser-command> [args]
|
|
4835
|
+
rech [--profile <p>] [--isolate[=<name>]] <browser-command> [args]
|
|
4735
4836
|
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
4736
4837
|
--profile <p> as another registered profile (email, name or folder); put it first
|
|
4737
|
-
--isolate in a throwaway session
|
|
4838
|
+
--isolate in a throwaway session (a new one per command)
|
|
4839
|
+
--isolate=<n> in the named isolated session <n>: made on first use, reused by later
|
|
4840
|
+
commands (same tabs, same page) until \`rech session close <n>\`
|
|
4738
4841
|
rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
|
|
4739
4842
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4740
4843
|
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
4741
4844
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4742
4845
|
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4743
4846
|
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4744
|
-
rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
|
|
4847
|
+
rech read-storage <local|session> <key> [json-path] --allow-domain <host> [--goto <url>] | <command>
|
|
4745
4848
|
copy a localStorage/sessionStorage value into a pipe, never shown or logged
|
|
4849
|
+
rech read-headers --allow-domain <host> [--seconds N] [--goto <url>]
|
|
4850
|
+
capture response status + headers (no bodies) to a JSON evidence file;
|
|
4851
|
+
credential header values and URL query values are redacted
|
|
4852
|
+
rech record start [--fps <n>] [--quality <1-100>] [--format mp4|webm|mjpeg] [--max-seconds <n>]
|
|
4853
|
+
rech record stop [--filename <name>]
|
|
4854
|
+
record the active tab, streamed into ffmpeg on the host, until stop (or
|
|
4855
|
+
--max-seconds, default 600); stop hands you the video. No click, no new
|
|
4856
|
+
permission; mjpeg if the host has no ffmpeg
|
|
4857
|
+
rech record [--seconds <n>] [...]
|
|
4858
|
+
the same in one call: record n seconds (max 60) and return the file
|
|
4746
4859
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4747
4860
|
rech --version rechrome's version
|
|
4748
4861
|
|
|
@@ -4808,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4808
4921
|
.option("webrtc", { type: "string", describe: `Share over WebRTC: bare = ${DEFAULT_RTC} (${DEFAULT_SIGNAL}, best effort); --webrtc=<signal-url> = that server's listener (made if needed); repeatable` })
|
|
4809
4922
|
.option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
|
|
4810
4923
|
.option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
|
|
4811
|
-
.option("invite-ttl", { type: "string", requiresArg: true, describe: "With new:
|
|
4924
|
+
.option("invite-ttl", { type: "string", requiresArg: true, alias: "ttl", describe: "With new: the invite stops working after this (default 24h; 7d with --reusable)" })
|
|
4925
|
+
.option("reusable", { type: "boolean", describe: "With new: many devices may redeem the same invite link (each gets its own credential) until --ttl or rech share rotate" })
|
|
4812
4926
|
.option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
|
|
4813
4927
|
.option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
|
|
4814
4928
|
.option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
|
|
@@ -4821,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4821
4935
|
const [action, target, ...rest] = profiles;
|
|
4822
4936
|
const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
|
|
4823
4937
|
const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
|
|
4824
|
-
const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve };
|
|
4938
|
+
const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve, reusable: a.reusable };
|
|
4825
4939
|
if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
|
|
4826
4940
|
// Transport flags make a share (an invite link) for the named profiles.
|
|
4827
4941
|
if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
|
|
@@ -4883,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4883
4997
|
.option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
|
|
4884
4998
|
.option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
|
|
4885
4999
|
.option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
|
|
5000
|
+
.option("read-headers", { type: "boolean", describe: "Off by default. Let this listener's links capture response status + headers (no bodies) for an allowed host (rech read-headers); credential header values and URL query values are redacted. --no-read-headers turns it off" })
|
|
4886
5001
|
.option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
|
|
4887
5002
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4888
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
|
|
4889
|
-
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
|
|
5003
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.readHeaders !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies/--read-headers, or --keep-downloads"),
|
|
5004
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.readHeaders !== undefined ? { readHeaders: a.readHeaders } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
|
|
4890
5005
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4891
5006
|
a => handlers.listenerPort(a.name))
|
|
4892
5007
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
@@ -4894,7 +5009,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4894
5009
|
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
4895
5010
|
a => handlers.removeListener(a.name))
|
|
4896
5011
|
.demandCommand(1).strict())
|
|
4897
|
-
.command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
|
|
5012
|
+
.command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name> (also an --isolate=<name> session)", y => y
|
|
4898
5013
|
.positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
|
|
4899
5014
|
.positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
|
|
4900
5015
|
a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
|
|
@@ -5041,10 +5156,13 @@ if (import.meta.main) {
|
|
|
5041
5156
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
5042
5157
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
|
|
5043
5158
|
// OAuth drive can't leak an orphaned browser context.
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5159
|
+
// --isolate=<name>: a named isolated session (-s=iso_<name>), kept across commands until
|
|
5160
|
+
// `rech session close <name>`, for multi-step flows (email, password, TOTP).
|
|
5161
|
+
try { args = applyIsolateArg(args); }
|
|
5162
|
+
catch (err) {
|
|
5163
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
5164
|
+
envWatcher?.close();
|
|
5165
|
+
process.exit(1);
|
|
5048
5166
|
}
|
|
5049
5167
|
args = [...forwarded, ...args];
|
|
5050
5168
|
await run(url, args, overrideEnv, { verbatim: separator !== -1 });
|