rechrome 1.47.0 → 1.48.1
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 +4 -0
- 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 +96 -21
- package/rechrome.ts +96 -21
- package/record.js +288 -0
- package/record.ts +258 -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 +140 -13
- package/serve.ts +140 -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}`);
|
|
@@ -2261,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2261
2263
|
console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
|
|
2262
2264
|
process.exit(0);
|
|
2263
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
|
+
}
|
|
2264
2279
|
if (args[cmdIdx] === "fill-secret") {
|
|
2265
2280
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2266
2281
|
try {
|
|
@@ -2333,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2333
2348
|
}
|
|
2334
2349
|
process.stderr.write(stderr);
|
|
2335
2350
|
}
|
|
2336
|
-
|
|
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
|
+
}));
|
|
2337
2357
|
|
|
2338
2358
|
if (files?.length) {
|
|
2339
2359
|
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
@@ -3046,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
|
|
|
3046
3066
|
}
|
|
3047
3067
|
}
|
|
3048
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
|
+
|
|
3049
3087
|
// Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
|
|
3050
3088
|
// Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
|
|
3051
3089
|
// --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
|
|
3052
|
-
function findChromiumForTesting(): string | null {
|
|
3090
|
+
function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
|
|
3053
3091
|
// Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
|
|
3054
3092
|
// `playwright install` doesn't always write to the env path, so check both.
|
|
3055
3093
|
const bases = [
|
|
@@ -3062,6 +3100,8 @@ function findChromiumForTesting(): string | null {
|
|
|
3062
3100
|
let revs: string[];
|
|
3063
3101
|
try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
|
|
3064
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}`));
|
|
3065
3105
|
for (const rev of revs) {
|
|
3066
3106
|
const root = join(base, rev);
|
|
3067
3107
|
const candidates = process.platform === "darwin"
|
|
@@ -3509,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3509
3549
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3510
3550
|
}
|
|
3511
3551
|
|
|
3512
|
-
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> {
|
|
3513
3553
|
const config = await requireListeners();
|
|
3514
3554
|
let listener = config.listeners.find(l => l.name === name);
|
|
3515
3555
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3535,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3535
3575
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3536
3576
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3537
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
|
+
}
|
|
3538
3582
|
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3539
3583
|
await writeListeners(config);
|
|
3540
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`);
|
|
3541
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`);
|
|
3542
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`);
|
|
3543
3588
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3544
3589
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3545
3590
|
const dirs = (listener.uploads || {}).hostDirs;
|
|
@@ -3714,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
|
|
|
3714
3759
|
}
|
|
3715
3760
|
}
|
|
3716
3761
|
|
|
3717
|
-
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 }[] };
|
|
3718
3763
|
|
|
3719
3764
|
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
|
|
3720
3765
|
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
3721
3766
|
// A bare rech1_ token means the default signal server.
|
|
3722
3767
|
if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
|
|
3723
3768
|
const share = shareLinkParts(url);
|
|
3724
|
-
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
|
+
}
|
|
3725
3776
|
if (share && shared) return saveConnection(url, opts, shared);
|
|
3726
3777
|
const parsed = parseUrl(url);
|
|
3727
3778
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
@@ -3782,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
|
|
|
3782
3833
|
for (const p of shared.profiles) console.log(` ${p}`);
|
|
3783
3834
|
console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
|
|
3784
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
|
+
}
|
|
3785
3840
|
}
|
|
3786
3841
|
|
|
3787
3842
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
@@ -4329,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
|
|
|
4329
4384
|
function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
|
|
4330
4385
|
const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
|
|
4331
4386
|
console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
|
|
4332
|
-
|
|
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`);
|
|
4333
4391
|
const width = Math.max(...links.map(l => l.via.length));
|
|
4334
4392
|
for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
|
|
4335
4393
|
if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
|
|
@@ -4341,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
|
|
|
4341
4399
|
console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
|
|
4342
4400
|
}
|
|
4343
4401
|
|
|
4344
|
-
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 };
|
|
4345
4403
|
|
|
4346
4404
|
/**
|
|
4347
4405
|
* The transports named on the command line, in order: --via entries (comma-separated), then
|
|
@@ -4397,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
|
|
|
4397
4455
|
const share = await updateShares(config => createShare(config, {
|
|
4398
4456
|
label, profiles, via,
|
|
4399
4457
|
expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
|
|
4400
|
-
inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
|
|
4401
|
-
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,
|
|
4402
4460
|
}));
|
|
4403
4461
|
// The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
|
|
4404
4462
|
if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
|
|
@@ -4409,12 +4467,15 @@ async function shareList(): Promise<void> {
|
|
|
4409
4467
|
const { shares } = await readShares();
|
|
4410
4468
|
if (shares.length) {
|
|
4411
4469
|
const now = Date.now();
|
|
4412
|
-
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
|
|
4470
|
+
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
|
|
4413
4471
|
for (const s of shares) {
|
|
4414
4472
|
const pending = s.devices.filter(d => !d.approved).length;
|
|
4415
4473
|
const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
|
|
4416
4474
|
: inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
|
|
4417
|
-
|
|
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]);
|
|
4418
4479
|
}
|
|
4419
4480
|
printTable(rows);
|
|
4420
4481
|
console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
|
|
@@ -4461,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
|
|
|
4461
4522
|
const s = findShare(config, name);
|
|
4462
4523
|
s.invite = newToken();
|
|
4463
4524
|
s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
|
|
4464
|
-
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;
|
|
4465
4527
|
return s;
|
|
4466
4528
|
});
|
|
4467
4529
|
console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
|
|
@@ -4550,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
|
|
|
4550
4612
|
const saved = new URL(parts.origin);
|
|
4551
4613
|
if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
|
|
4552
4614
|
saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
|
|
4553
|
-
|
|
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 });
|
|
4554
4617
|
}
|
|
4555
4618
|
|
|
4556
4619
|
// ---- WebRTC client bridge -------------------------------------------------------------------
|
|
@@ -4781,8 +4844,18 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4781
4844
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4782
4845
|
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4783
4846
|
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4784
|
-
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>
|
|
4785
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
|
|
4786
4859
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4787
4860
|
rech --version rechrome's version
|
|
4788
4861
|
|
|
@@ -4848,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4848
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` })
|
|
4849
4922
|
.option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
|
|
4850
4923
|
.option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
|
|
4851
|
-
.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" })
|
|
4852
4926
|
.option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
|
|
4853
4927
|
.option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
|
|
4854
4928
|
.option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
|
|
@@ -4861,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4861
4935
|
const [action, target, ...rest] = profiles;
|
|
4862
4936
|
const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
|
|
4863
4937
|
const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
|
|
4864
|
-
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 };
|
|
4865
4939
|
if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
|
|
4866
4940
|
// Transport flags make a share (an invite link) for the named profiles.
|
|
4867
4941
|
if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
|
|
@@ -4923,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4923
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" })
|
|
4924
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" })
|
|
4925
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" })
|
|
4926
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" })
|
|
4927
5002
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4928
|
-
.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"),
|
|
4929
|
-
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 } : {}) }))
|
|
4930
5005
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4931
5006
|
a => handlers.listenerPort(a.name))
|
|
4932
5007
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/rechrome.ts
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.ts";
|
|
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.ts";
|
|
4
|
+
import { recordHoldMs } from "./record.ts";
|
|
4
5
|
|
|
5
6
|
import { file } from "bun";
|
|
6
7
|
import yargs from "yargs";
|
|
7
8
|
import { readExtensionTokenFromProfile } from "./extension-token.ts";
|
|
8
9
|
import { redact } from "./redact.ts";
|
|
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.ts";
|
|
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.ts";
|
|
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.ts";
|
|
20
|
-
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.ts";
|
|
21
|
+
import { DEFAULT_SIGNAL, WEBRTC, createShare, normalizeSignal, findDevice, findShare, inviteLink, inviteProblem, parseDuration, REUSABLE_DEFAULT_TTL, REUSABLE_MAX_DEVICES, readShares, updateShares, type Share } from "./shares.ts";
|
|
21
22
|
import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.ts";
|
|
22
23
|
import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.ts";
|
|
23
24
|
import { startSignalRelay } from "./signal-relay.ts";
|
|
@@ -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}`);
|
|
@@ -2261,6 +2263,19 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2261
2263
|
console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
|
|
2262
2264
|
process.exit(0);
|
|
2263
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
|
+
}
|
|
2264
2279
|
if (args[cmdIdx] === "fill-secret") {
|
|
2265
2280
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2266
2281
|
try {
|
|
@@ -2333,7 +2348,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2333
2348
|
}
|
|
2334
2349
|
process.stderr.write(stderr);
|
|
2335
2350
|
}
|
|
2336
|
-
|
|
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
|
+
}));
|
|
2337
2357
|
|
|
2338
2358
|
if (files?.length) {
|
|
2339
2359
|
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
@@ -3046,10 +3066,28 @@ async function trayCommand(sub?: string): Promise<void> {
|
|
|
3046
3066
|
}
|
|
3047
3067
|
}
|
|
3048
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
|
+
|
|
3049
3087
|
// Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
|
|
3050
3088
|
// Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
|
|
3051
3089
|
// --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
|
|
3052
|
-
function findChromiumForTesting(): string | null {
|
|
3090
|
+
function findChromiumForTesting(preferredRevision: string | null = relayChromiumRevision()): string | null {
|
|
3053
3091
|
// Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
|
|
3054
3092
|
// `playwright install` doesn't always write to the env path, so check both.
|
|
3055
3093
|
const bases = [
|
|
@@ -3062,6 +3100,8 @@ function findChromiumForTesting(): string | null {
|
|
|
3062
3100
|
let revs: string[];
|
|
3063
3101
|
try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
|
|
3064
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}`));
|
|
3065
3105
|
for (const rev of revs) {
|
|
3066
3106
|
const root = join(base, rev);
|
|
3067
3107
|
const candidates = process.platform === "darwin"
|
|
@@ -3509,7 +3549,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3509
3549
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3510
3550
|
}
|
|
3511
3551
|
|
|
3512
|
-
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> {
|
|
3513
3553
|
const config = await requireListeners();
|
|
3514
3554
|
let listener = config.listeners.find(l => l.name === name);
|
|
3515
3555
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3535,11 +3575,16 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3535
3575
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3536
3576
|
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3537
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
|
+
}
|
|
3538
3582
|
if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
|
|
3539
3583
|
await writeListeners(config);
|
|
3540
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`);
|
|
3541
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`);
|
|
3542
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`);
|
|
3543
3588
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3544
3589
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3545
3590
|
const dirs = (listener.uploads || {}).hostDirs;
|
|
@@ -3714,14 +3759,20 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
|
|
|
3714
3759
|
}
|
|
3715
3760
|
}
|
|
3716
3761
|
|
|
3717
|
-
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 }[] };
|
|
3718
3763
|
|
|
3719
3764
|
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
|
|
3720
3765
|
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
3721
3766
|
// A bare rech1_ token means the default signal server.
|
|
3722
3767
|
if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
|
|
3723
3768
|
const share = shareLinkParts(url);
|
|
3724
|
-
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
|
+
}
|
|
3725
3776
|
if (share && shared) return saveConnection(url, opts, shared);
|
|
3726
3777
|
const parsed = parseUrl(url);
|
|
3727
3778
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
@@ -3782,6 +3833,10 @@ async function saveConnection(url: string, opts: { global?: boolean; project?: b
|
|
|
3782
3833
|
for (const p of shared.profiles) console.log(` ${p}`);
|
|
3783
3834
|
console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
|
|
3784
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
|
+
}
|
|
3785
3840
|
}
|
|
3786
3841
|
|
|
3787
3842
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
@@ -4329,7 +4384,10 @@ async function shareLinks(share: Share): Promise<{ via: string; link: string; si
|
|
|
4329
4384
|
function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
|
|
4330
4385
|
const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
|
|
4331
4386
|
console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
|
|
4332
|
-
|
|
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`);
|
|
4333
4391
|
const width = Math.max(...links.map(l => l.via.length));
|
|
4334
4392
|
for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
|
|
4335
4393
|
if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
|
|
@@ -4341,7 +4399,7 @@ function printInvite(share: Share, links: { via: string; link: string; signal?:
|
|
|
4341
4399
|
console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
|
|
4342
4400
|
}
|
|
4343
4401
|
|
|
4344
|
-
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 };
|
|
4345
4403
|
|
|
4346
4404
|
/**
|
|
4347
4405
|
* The transports named on the command line, in order: --via entries (comma-separated), then
|
|
@@ -4397,8 +4455,8 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
|
|
|
4397
4455
|
const share = await updateShares(config => createShare(config, {
|
|
4398
4456
|
label, profiles, via,
|
|
4399
4457
|
expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
|
|
4400
|
-
inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
|
|
4401
|
-
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,
|
|
4402
4460
|
}));
|
|
4403
4461
|
// The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
|
|
4404
4462
|
if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
|
|
@@ -4409,12 +4467,15 @@ async function shareList(): Promise<void> {
|
|
|
4409
4467
|
const { shares } = await readShares();
|
|
4410
4468
|
if (shares.length) {
|
|
4411
4469
|
const now = Date.now();
|
|
4412
|
-
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
|
|
4470
|
+
const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "INVITE", "STATUS"]];
|
|
4413
4471
|
for (const s of shares) {
|
|
4414
4472
|
const pending = s.devices.filter(d => !d.approved).length;
|
|
4415
4473
|
const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
|
|
4416
4474
|
: inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
|
|
4417
|
-
|
|
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]);
|
|
4418
4479
|
}
|
|
4419
4480
|
printTable(rows);
|
|
4420
4481
|
console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
|
|
@@ -4461,7 +4522,8 @@ async function shareEdit(action: string, name: string, args: string[], opts: { e
|
|
|
4461
4522
|
const s = findShare(config, name);
|
|
4462
4523
|
s.invite = newToken();
|
|
4463
4524
|
s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
|
|
4464
|
-
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;
|
|
4465
4527
|
return s;
|
|
4466
4528
|
});
|
|
4467
4529
|
console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
|
|
@@ -4550,7 +4612,8 @@ async function connectShare(url: string, parts: { token: string; origin: string
|
|
|
4550
4612
|
const saved = new URL(parts.origin);
|
|
4551
4613
|
if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
|
|
4552
4614
|
saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
|
|
4553
|
-
|
|
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 });
|
|
4554
4617
|
}
|
|
4555
4618
|
|
|
4556
4619
|
// ---- WebRTC client bridge -------------------------------------------------------------------
|
|
@@ -4781,8 +4844,18 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4781
4844
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4782
4845
|
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4783
4846
|
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4784
|
-
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>
|
|
4785
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
|
|
4786
4859
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4787
4860
|
rech --version rechrome's version
|
|
4788
4861
|
|
|
@@ -4848,7 +4921,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4848
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` })
|
|
4849
4922
|
.option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
|
|
4850
4923
|
.option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
|
|
4851
|
-
.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" })
|
|
4852
4926
|
.option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
|
|
4853
4927
|
.option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
|
|
4854
4928
|
.option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
|
|
@@ -4861,7 +4935,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4861
4935
|
const [action, target, ...rest] = profiles;
|
|
4862
4936
|
const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
|
|
4863
4937
|
const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
|
|
4864
|
-
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 };
|
|
4865
4939
|
if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
|
|
4866
4940
|
// Transport flags make a share (an invite link) for the named profiles.
|
|
4867
4941
|
if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
|
|
@@ -4923,10 +4997,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4923
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" })
|
|
4924
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" })
|
|
4925
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" })
|
|
4926
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" })
|
|
4927
5002
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4928
|
-
.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"),
|
|
4929
|
-
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 } : {}) }))
|
|
4930
5005
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4931
5006
|
a => handlers.listenerPort(a.name))
|
|
4932
5007
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|