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/serve.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.js";
2
2
  import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.js";
3
- import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
3
+ import { WEBRTC, authorizeCredential, credentialForBearer, inviteState, readShares, redeemInvite, shareProblem, shareSessions, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
4
4
  import { bearerOf, safeEqual } from "./secret.js";
5
5
  import { WebrtcHost } from "./webrtc.js";
6
6
  import { file } from "bun";
7
7
  import { createHash, randomBytes, X509Certificate } from "crypto";
8
- import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
8
+ import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, renameSync, constants as fsConstants } from "fs";
9
9
  import { join, resolve, relative, isAbsolute, sep } from "path";
10
10
  import { tmpdir } from "os";
11
11
  import {
@@ -24,7 +24,8 @@ import {
24
24
  checkTailscaleServe,
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.js";
27
- import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
27
+ import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
28
+ import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, withFormatExt, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.js";
28
29
 
29
30
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
30
31
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -105,6 +106,37 @@ const deniedFiles = new Set<string>();
105
106
  // keeps them on the host instead (opt-in).
106
107
  const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
107
108
  const DOWNLOAD_PICKUP_MS = 10 * 60_000;
109
+ // Per-request idle limit for POSTs (Bun's default is 10 s): past the client's 70 s plus the longest timed recording.
110
+ export const RUN_IDLE_SECONDS = 70 + RECORD_MAX_SECONDS + 20;
111
+ // rech record start/stop: the recording in progress (or finished by --max-seconds and not yet
112
+ // collected) per namespaced session. serve owns the capture loop; see record.js.
113
+ type RecordingEntry = { rec: Recording; dir: string; outputPrefix: string; listener: string; keep: boolean; done?: RecordingSummary; expire?: ReturnType<typeof setTimeout> };
114
+ const recordings = new Map<string, RecordingEntry>();
115
+
116
+ /** Rename a finished recording to its final name and hand it to the client like a download. */
117
+ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename: string | undefined): { stdout: string; files: string[] } {
118
+ // Nothing captured is the real story (no tab, session gone); ffmpeg's "no stream" only follows from it.
119
+ if (!s.frames || !existsSync(s.out)) {
120
+ try { unlinkSync(s.out); } catch {}
121
+ const why = s.error?.split("\n")[0];
122
+ throw new Error(`record: nothing was captured${why ? ` (${why})` : ""}${why?.includes("rech open") ? "" : "; is a tab open? run \`rech open <url>\` first"}`);
123
+ }
124
+ if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
125
+ // Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
126
+ // The name says what the file is: no ffmpeg on the host means mjpeg, whatever --filename asked for.
127
+ const wanted = filename ? withFormatExt(filename, s.format) : undefined;
128
+ const out = wanted ? join(entry.dir, freeName(wanted, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
129
+ if (out !== s.out) renameSync(s.out, out);
130
+ log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
131
+ if (!entry.keep) {
132
+ const real = realpathSync(out);
133
+ clearTimeout(pendingDownloads.get(real));
134
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
135
+ }
136
+ const note = (filename && wanted !== filename ? ` — saved as ${s.format}, not ${filename}${s.format === "mjpeg" ? " (no ffmpeg on the host)" : ""}` : "")
137
+ + (s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "");
138
+ return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
139
+ }
108
140
  // playwright-cli's line for a finished download (tools/backend/response.js).
109
141
  const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
142
  // Where the CLI saves downloads: its output dir under the session's cwd (context.js outputDir).
@@ -701,6 +733,16 @@ export async function serve() {
701
733
  }).catch(error => log(`could not record device use: ${error}`));
702
734
  };
703
735
 
736
+ const profileInfo = async (profiles: string[] | "*") => {
737
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
738
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
739
+ const keys = profiles === "*" ? Object.keys(registry) : profiles;
740
+ return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
741
+ };
742
+ const liveSessions = () => {
743
+ try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
744
+ };
745
+
704
746
  const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
705
747
  let listener = initialListener;
706
748
  const reqUrl = new URL(req.url);
@@ -735,7 +777,8 @@ export async function serve() {
735
777
  const { name } = await req.json().catch(() => ({})) as { name?: string };
736
778
  try {
737
779
  const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
738
- shares = await readShares();
780
+ // Open the device's room now, not on the next 1 s reload: the client uses it right away.
781
+ await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
739
782
  log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
740
783
  return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
741
784
  } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
@@ -744,17 +787,13 @@ export async function serve() {
744
787
  if (reqUrl.pathname === "/profiles") {
745
788
  const denied = authCheck(req, key);
746
789
  if (denied) return denied;
747
- const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
748
- const cache = await readChromeProfileCache().catch(() => null) ?? {};
749
- const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
750
- return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
790
+ return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
751
791
  }
752
792
 
753
793
  if (reqUrl.pathname === "/sessions") {
754
794
  const denied = authCheck(req, key);
755
795
  if (denied) return denied;
756
- let live = new Set<string>();
757
- try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
796
+ const live = liveSessions();
758
797
  const all = listener.profiles === "*" && !cred;
759
798
  const rows = [...sessionOwners.entries()]
760
799
  .filter(([, o]) => all || o.owner === owner)
@@ -812,7 +851,10 @@ export async function serve() {
812
851
  if (scoped || !reqUrl.searchParams.get("deep"))
813
852
  return Response.json({
814
853
  ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
815
- ...(cred ? { share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : {}), expiresAt: cred.share.expiresAt } : {}),
854
+ ...(cred ? {
855
+ share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : { invite: inviteState(cred.share) }), expiresAt: cred.share.expiresAt,
856
+ profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
857
+ } : {}),
816
858
  });
817
859
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
818
860
  // most-recently-used session — never a fresh one, which could spawn a browser window.
@@ -1003,8 +1045,8 @@ export async function serve() {
1003
1045
  // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
1004
1046
  if (filteredArgs[0] === "read-storage") {
1005
1047
  try {
1006
- const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
1007
- const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
1048
+ const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
1049
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
1008
1050
  if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
1009
1051
  secretMasker.add(value, Infinity);
1010
1052
  log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
@@ -1032,6 +1074,87 @@ export async function serve() {
1032
1074
  }
1033
1075
  }
1034
1076
 
1077
+ // read-headers: response status + headers (no bodies) for an --allow-domain host over a
1078
+ // window, credential header values and URL query values redacted. Written as a JSON evidence file in
1079
+ // the output dir and returned in `files`; header values never reach stdout or a log line.
1080
+ if (filteredArgs[0] === "read-headers") {
1081
+ try {
1082
+ const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
1083
+ const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
1084
+ const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1085
+ await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
1086
+ log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
1087
+ return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
1088
+ } catch (error) {
1089
+ const message = error instanceof Error ? error.message : String(error);
1090
+ log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
1091
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1092
+ }
1093
+ }
1094
+
1095
+ // record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
1096
+ // streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
1097
+ // open-ended recording; plain `record --seconds N` does both in one call. See record.js.
1098
+ if (filteredArgs[0] === "record") {
1099
+ const fail = (message: string) => {
1100
+ log(`exit: 1 | ${message.split("\n")[0]}`);
1101
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1102
+ };
1103
+ try {
1104
+ const a = parseRecordArgs(filteredArgs);
1105
+ const existing = recordings.get(namespacedSession);
1106
+ if (a.mode === "stop") {
1107
+ if (!existing) return fail("record: no recording in progress; run `rech record start` first");
1108
+ recordings.delete(namespacedSession);
1109
+ clearTimeout(existing.expire);
1110
+ const s = existing.done ?? await existing.rec.stop();
1111
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
1112
+ }
1113
+ if (existing) return fail(existing.done ? "record: a finished recording is waiting; run `rech record stop` to collect it first" : `record: already recording (since ${new Date(existing.rec.startAt).toISOString()}); run \`rech record stop\``);
1114
+ const ffmpegBin = resolveFfmpeg();
1115
+ const format = resolveFormat(a.format, ffmpegBin !== null);
1116
+ const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1117
+ const code = buildCaptureChunkCode(a);
1118
+ const capture = async (): Promise<CaptureChunk> => {
1119
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1120
+ if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1121
+ // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1122
+ let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
1123
+ for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
1124
+ const chunk = parsed as CaptureChunk | undefined;
1125
+ if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
1126
+ return chunk;
1127
+ };
1128
+ const entry: RecordingEntry = {
1129
+ dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
1130
+ rec: new Recording({
1131
+ fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
1132
+ capture, sink: recordSink(format, ffmpegBin, a.fps, out),
1133
+ onAutoStop: s => {
1134
+ entry.done = s;
1135
+ // Nobody came for it: drop it like an uncollected download.
1136
+ entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
1137
+ log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
1138
+ },
1139
+ }),
1140
+ };
1141
+ recordings.set(namespacedSession, entry);
1142
+ if (a.mode === "start") {
1143
+ const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
1144
+ log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
1145
+ return Response.json({ status: 0, stdout: `record: recording at ${a.fps} fps to ${format}${note}; run \`rech record stop\` to save (stops by itself after ${a.maxSeconds} s)\n`, stderr: "", files: [] });
1146
+ }
1147
+ // Answer early if the capture ends on its own (the tab went away).
1148
+ await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
1149
+ recordings.delete(namespacedSession);
1150
+ clearTimeout(entry.expire);
1151
+ const s = await entry.rec.stop();
1152
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
1153
+ } catch (error) {
1154
+ return fail(error instanceof Error ? error.message : String(error));
1155
+ }
1156
+ }
1157
+
1035
1158
  // For open commands, default to about:blank to avoid leaving connect.html visible
1036
1159
  const isOpenCmd = filteredArgs[0] === "open";
1037
1160
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
@@ -1377,6 +1500,10 @@ export async function serve() {
1377
1500
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1378
1501
  },
1379
1502
  async fetch(req, server) {
1503
+ // Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
1504
+ // rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
1505
+ // than the client waits (70 s + a recording's length).
1506
+ if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
1380
1507
  const listener = policies.get(listenerAddress(initial));
1381
1508
  if (!listener) return new Response("Listener removed", { status: 403 });
1382
1509
  return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
package/serve.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.ts";
2
2
  import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, shareRtcListeners, rtcListeners, type Listener } from "./listeners.ts";
3
- import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
3
+ import { WEBRTC, authorizeCredential, credentialForBearer, inviteState, readShares, redeemInvite, shareProblem, shareSessions, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.ts";
4
4
  import { bearerOf, safeEqual } from "./secret.ts";
5
5
  import { WebrtcHost } from "./webrtc.ts";
6
6
  import { file } from "bun";
7
7
  import { createHash, randomBytes, X509Certificate } from "crypto";
8
- import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
8
+ import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, renameSync, constants as fsConstants } from "fs";
9
9
  import { join, resolve, relative, isAbsolute, sep } from "path";
10
10
  import { tmpdir } from "os";
11
11
  import {
@@ -24,7 +24,8 @@ import {
24
24
  checkTailscaleServe,
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.ts";
27
- import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
27
+ import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
28
+ import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, withFormatExt, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.ts";
28
29
 
29
30
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
30
31
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -105,6 +106,37 @@ const deniedFiles = new Set<string>();
105
106
  // keeps them on the host instead (opt-in).
106
107
  const pendingDownloads = new Map<string, ReturnType<typeof setTimeout>>();
107
108
  const DOWNLOAD_PICKUP_MS = 10 * 60_000;
109
+ // Per-request idle limit for POSTs (Bun's default is 10 s): past the client's 70 s plus the longest timed recording.
110
+ export const RUN_IDLE_SECONDS = 70 + RECORD_MAX_SECONDS + 20;
111
+ // rech record start/stop: the recording in progress (or finished by --max-seconds and not yet
112
+ // collected) per namespaced session. serve owns the capture loop; see record.ts.
113
+ type RecordingEntry = { rec: Recording; dir: string; outputPrefix: string; listener: string; keep: boolean; done?: RecordingSummary; expire?: ReturnType<typeof setTimeout> };
114
+ const recordings = new Map<string, RecordingEntry>();
115
+
116
+ /** Rename a finished recording to its final name and hand it to the client like a download. */
117
+ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename: string | undefined): { stdout: string; files: string[] } {
118
+ // Nothing captured is the real story (no tab, session gone); ffmpeg's "no stream" only follows from it.
119
+ if (!s.frames || !existsSync(s.out)) {
120
+ try { unlinkSync(s.out); } catch {}
121
+ const why = s.error?.split("\n")[0];
122
+ throw new Error(`record: nothing was captured${why ? ` (${why})` : ""}${why?.includes("rech open") ? "" : "; is a tab open? run \`rech open <url>\` first"}`);
123
+ }
124
+ if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
125
+ // Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
126
+ // The name says what the file is: no ffmpeg on the host means mjpeg, whatever --filename asked for.
127
+ const wanted = filename ? withFormatExt(filename, s.format) : undefined;
128
+ const out = wanted ? join(entry.dir, freeName(wanted, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
129
+ if (out !== s.out) renameSync(s.out, out);
130
+ log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
131
+ if (!entry.keep) {
132
+ const real = realpathSync(out);
133
+ clearTimeout(pendingDownloads.get(real));
134
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
135
+ }
136
+ const note = (filename && wanted !== filename ? ` — saved as ${s.format}, not ${filename}${s.format === "mjpeg" ? " (no ffmpeg on the host)" : ""}` : "")
137
+ + (s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "");
138
+ return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
139
+ }
108
140
  // playwright-cli's line for a finished download (tools/backend/response.ts).
109
141
  const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
142
  // Where the CLI saves downloads: its output dir under the session's cwd (context.ts outputDir).
@@ -701,6 +733,16 @@ export async function serve() {
701
733
  }).catch(error => log(`could not record device use: ${error}`));
702
734
  };
703
735
 
736
+ const profileInfo = async (profiles: string[] | "*") => {
737
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
738
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
739
+ const keys = profiles === "*" ? Object.keys(registry) : profiles;
740
+ return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
741
+ };
742
+ const liveSessions = () => {
743
+ try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
744
+ };
745
+
704
746
  const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
705
747
  let listener = initialListener;
706
748
  const reqUrl = new URL(req.url);
@@ -735,7 +777,8 @@ export async function serve() {
735
777
  const { name } = await req.json().catch(() => ({})) as { name?: string };
736
778
  try {
737
779
  const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
738
- shares = await readShares();
780
+ // Open the device's room now, not on the next 1 s reload: the client uses it right away.
781
+ await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
739
782
  log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
740
783
  return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
741
784
  } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
@@ -744,17 +787,13 @@ export async function serve() {
744
787
  if (reqUrl.pathname === "/profiles") {
745
788
  const denied = authCheck(req, key);
746
789
  if (denied) return denied;
747
- const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
748
- const cache = await readChromeProfileCache().catch(() => null) ?? {};
749
- const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
750
- return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
790
+ return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
751
791
  }
752
792
 
753
793
  if (reqUrl.pathname === "/sessions") {
754
794
  const denied = authCheck(req, key);
755
795
  if (denied) return denied;
756
- let live = new Set<string>();
757
- try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
796
+ const live = liveSessions();
758
797
  const all = listener.profiles === "*" && !cred;
759
798
  const rows = [...sessionOwners.entries()]
760
799
  .filter(([, o]) => all || o.owner === owner)
@@ -812,7 +851,10 @@ export async function serve() {
812
851
  if (scoped || !reqUrl.searchParams.get("deep"))
813
852
  return Response.json({
814
853
  ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
815
- ...(cred ? { share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : {}), expiresAt: cred.share.expiresAt } : {}),
854
+ ...(cred ? {
855
+ share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : { invite: inviteState(cred.share) }), expiresAt: cred.share.expiresAt,
856
+ profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
857
+ } : {}),
816
858
  });
817
859
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
818
860
  // most-recently-used session — never a fresh one, which could spawn a browser window.
@@ -1003,8 +1045,8 @@ export async function serve() {
1003
1045
  // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
1004
1046
  if (filteredArgs[0] === "read-storage") {
1005
1047
  try {
1006
- const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
1007
- const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
1048
+ const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
1049
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
1008
1050
  if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
1009
1051
  secretMasker.add(value, Infinity);
1010
1052
  log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
@@ -1032,6 +1074,87 @@ export async function serve() {
1032
1074
  }
1033
1075
  }
1034
1076
 
1077
+ // read-headers: response status + headers (no bodies) for an --allow-domain host over a
1078
+ // window, credential header values and URL query values redacted. Written as a JSON evidence file in
1079
+ // the output dir and returned in `files`; header values never reach stdout or a log line.
1080
+ if (filteredArgs[0] === "read-headers") {
1081
+ try {
1082
+ const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
1083
+ const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
1084
+ const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1085
+ await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
1086
+ log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
1087
+ return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
1088
+ } catch (error) {
1089
+ const message = error instanceof Error ? error.message : String(error);
1090
+ log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
1091
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1092
+ }
1093
+ }
1094
+
1095
+ // record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
1096
+ // streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
1097
+ // open-ended recording; plain `record --seconds N` does both in one call. See record.ts.
1098
+ if (filteredArgs[0] === "record") {
1099
+ const fail = (message: string) => {
1100
+ log(`exit: 1 | ${message.split("\n")[0]}`);
1101
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1102
+ };
1103
+ try {
1104
+ const a = parseRecordArgs(filteredArgs);
1105
+ const existing = recordings.get(namespacedSession);
1106
+ if (a.mode === "stop") {
1107
+ if (!existing) return fail("record: no recording in progress; run `rech record start` first");
1108
+ recordings.delete(namespacedSession);
1109
+ clearTimeout(existing.expire);
1110
+ const s = existing.done ?? await existing.rec.stop();
1111
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
1112
+ }
1113
+ if (existing) return fail(existing.done ? "record: a finished recording is waiting; run `rech record stop` to collect it first" : `record: already recording (since ${new Date(existing.rec.startAt).toISOString()}); run \`rech record stop\``);
1114
+ const ffmpegBin = resolveFfmpeg();
1115
+ const format = resolveFormat(a.format, ffmpegBin !== null);
1116
+ const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1117
+ const code = buildCaptureChunkCode(a);
1118
+ const capture = async (): Promise<CaptureChunk> => {
1119
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1120
+ if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1121
+ // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1122
+ let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
1123
+ for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
1124
+ const chunk = parsed as CaptureChunk | undefined;
1125
+ if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
1126
+ return chunk;
1127
+ };
1128
+ const entry: RecordingEntry = {
1129
+ dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
1130
+ rec: new Recording({
1131
+ fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
1132
+ capture, sink: recordSink(format, ffmpegBin, a.fps, out),
1133
+ onAutoStop: s => {
1134
+ entry.done = s;
1135
+ // Nobody came for it: drop it like an uncollected download.
1136
+ entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
1137
+ log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
1138
+ },
1139
+ }),
1140
+ };
1141
+ recordings.set(namespacedSession, entry);
1142
+ if (a.mode === "start") {
1143
+ const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
1144
+ log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
1145
+ return Response.json({ status: 0, stdout: `record: recording at ${a.fps} fps to ${format}${note}; run \`rech record stop\` to save (stops by itself after ${a.maxSeconds} s)\n`, stderr: "", files: [] });
1146
+ }
1147
+ // Answer early if the capture ends on its own (the tab went away).
1148
+ await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
1149
+ recordings.delete(namespacedSession);
1150
+ clearTimeout(entry.expire);
1151
+ const s = await entry.rec.stop();
1152
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
1153
+ } catch (error) {
1154
+ return fail(error instanceof Error ? error.message : String(error));
1155
+ }
1156
+ }
1157
+
1035
1158
  // For open commands, default to about:blank to avoid leaving connect.html visible
1036
1159
  const isOpenCmd = filteredArgs[0] === "open";
1037
1160
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
@@ -1377,6 +1500,10 @@ export async function serve() {
1377
1500
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1378
1501
  },
1379
1502
  async fetch(req, server) {
1503
+ // Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
1504
+ // rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
1505
+ // than the client waits (70 s + a recording's length).
1506
+ if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
1380
1507
  const listener = policies.get(listenerAddress(initial));
1381
1508
  if (!listener) return new Response("Listener removed", { status: 403 });
1382
1509
  return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
package/shares.js CHANGED
@@ -7,6 +7,8 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
7
7
 
8
8
  // secret.ts
9
9
  import { hkdfSync, randomBytes, timingSafeEqual } from "crypto";
10
+
11
+ // token.ts
10
12
  var TOKEN_PREFIX = "rech1_";
11
13
  var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
12
14
  var B58 = "[1-9A-HJ-NP-Za-km-z]";
@@ -65,9 +67,6 @@ function formatToken(secret) {
65
67
  throw new Error(`a share secret is ${SECRET_BYTES} bytes`);
66
68
  return `${TOKEN_PREFIX}${base58(secret, BODY_LEN)}${base58(u32(crc32(secret)), SUM_LEN)}`;
67
69
  }
68
- function newToken() {
69
- return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
70
- }
71
70
  function parseToken(token) {
72
71
  const m = EXACT.exec(token.trim());
73
72
  if (!m)
@@ -78,15 +77,20 @@ function parseToken(token) {
78
77
  const expected = u32(crc32(secret));
79
78
  return sum.every((b, i) => b === expected[i]) ? { ok: true, secret } : { ok: false, reason: "checksum" };
80
79
  }
81
- function secretOf(token) {
82
- const parsed = parseToken(token);
80
+
81
+ // secret.ts
82
+ function newToken() {
83
+ return formatToken(new Uint8Array(randomBytes(SECRET_BYTES)));
84
+ }
85
+ function secretOf(token2) {
86
+ const parsed = parseToken(token2);
83
87
  if (!parsed.ok)
84
88
  throw new Error(parsed.reason === "checksum" ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped." : "Not a rech1_ link.");
85
89
  return parsed.secret;
86
90
  }
87
- var derive = (token, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token), new Uint8Array(0), `rech/${label}`, bytes));
88
- var bearerOf = (token) => `rb_${base58(derive(token, "bearer", 24), 33)}`;
89
- var credentialIdOf = (token) => base58(derive(token, "id", 6), 9);
91
+ var derive = (token2, label, bytes) => new Uint8Array(hkdfSync("sha256", secretOf(token2), new Uint8Array(0), `rech/${label}`, bytes));
92
+ var bearerOf = (token2) => `rb_${base58(derive(token2, "bearer", 24), 33)}`;
93
+ var credentialIdOf = (token2) => base58(derive(token2, "id", 6), 9);
90
94
  function safeEqual(a, b) {
91
95
  const x = Buffer.from(a), y = Buffer.from(b);
92
96
  return x.length === y.length && timingSafeEqual(x, y);
@@ -180,6 +184,8 @@ function findShare(config, name) {
180
184
  throw new Error(`No share named "${name}". See: rech share ls`);
181
185
  return share;
182
186
  }
187
+ var REUSABLE_MAX_DEVICES = 100;
188
+ var REUSABLE_DEFAULT_TTL = "7d";
183
189
  function createShare(config, opts) {
184
190
  const now = opts.now ?? Date.now();
185
191
  if (!LABEL.test(opts.label))
@@ -201,10 +207,14 @@ function createShare(config, opts) {
201
207
  signal: opts.via.includes(WEBRTC) ? normalizeSignal(opts.signal ?? DEFAULT_SIGNAL) : undefined,
202
208
  createdAt: now,
203
209
  expiresAt: opts.expiresAt,
204
- maxDevices: opts.maxDevices ?? 1,
210
+ maxDevices: opts.maxDevices ?? (opts.reusable ? REUSABLE_MAX_DEVICES : 1),
205
211
  approve: !!opts.approve,
206
212
  devices: []
207
213
  };
214
+ if (opts.reusable) {
215
+ share.reusable = true;
216
+ share.redeemed = 0;
217
+ }
208
218
  config.shares.push(share);
209
219
  return share;
210
220
  }
@@ -256,11 +266,33 @@ function shareProblem(share, now = Date.now()) {
256
266
  }
257
267
  function inviteProblem(share, now = Date.now()) {
258
268
  if (share.inviteExpiresAt && now >= share.inviteExpiresAt)
259
- return `This invite expired unused. Ask the host for a fresh one: rech share rotate ${share.label}`;
269
+ return `This invite expired${share.reusable ? "" : " unused"}. Ask the host for a fresh one: rech share rotate ${share.label}`;
270
+ if (share.reusable) {
271
+ if (share.devices.length >= share.maxDevices)
272
+ return `This reusable invite reached its limit of ${share.maxDevices} devices. Ask the host to remove unused devices (rech share devices ${share.label} --rm <id>) or to rotate it.`;
273
+ return null;
274
+ }
260
275
  if (share.devices.length >= share.maxDevices)
261
276
  return `This invite was already used${share.maxDevices > 1 ? ` by ${share.maxDevices} devices` : ""}. Ask the host for a fresh one (rech share rotate ${share.label}); if you didn't use it, tell them, since someone else did.`;
262
277
  return null;
263
278
  }
279
+ function inviteState(share, now = Date.now()) {
280
+ const used = share.devices.length;
281
+ const state = used >= share.maxDevices ? "used" : share.inviteExpiresAt && now >= share.inviteExpiresAt ? "expired" : "open";
282
+ return { state, maxDevices: share.maxDevices, used, ...share.inviteExpiresAt ? { expiresAt: share.inviteExpiresAt } : {} };
283
+ }
284
+ function shareSessions(share, owners, live) {
285
+ const prefix = `share:${share.id}:`;
286
+ const out = [];
287
+ for (const [session, o] of owners) {
288
+ if (!o.owner.startsWith(prefix) || !live.has(session))
289
+ continue;
290
+ const device = share.devices.find((d) => d.id === o.owner.slice(prefix.length));
291
+ if (device)
292
+ out.push({ name: o.name, device: device.name });
293
+ }
294
+ return out;
295
+ }
264
296
  function authorizeCredential(cred, via, route, now = Date.now()) {
265
297
  const problem = shareProblem(cred.share, now);
266
298
  if (problem)
@@ -297,6 +329,8 @@ function redeemInvite(config, shareId, name, now = Date.now()) {
297
329
  approved: !share.approve
298
330
  };
299
331
  share.devices.push(device);
332
+ if (share.reusable)
333
+ share.redeemed = (share.redeemed ?? 0) + 1;
300
334
  return device;
301
335
  }
302
336
  function findDevice(share, id) {
@@ -321,8 +355,7 @@ function webrtcCredentials(config, now = Date.now(), rtcNames = [DEFAULT_RTC]) {
321
355
  for (const share of config.shares) {
322
356
  if (!share.via.some((v) => names.has(v)) || shareProblem(share, now))
323
357
  continue;
324
- if (!(share.inviteExpiresAt && now >= share.inviteExpiresAt))
325
- out.push({ share, kind: "invite", token: share.invite });
358
+ out.push({ share, kind: "invite", token: share.invite });
326
359
  for (const device of share.devices)
327
360
  out.push({ share, kind: "device", token: device.token, device });
328
361
  }
@@ -331,6 +364,8 @@ function webrtcCredentials(config, now = Date.now(), rtcNames = [DEFAULT_RTC]) {
331
364
  export {
332
365
  DEFAULT_RTC,
333
366
  DEFAULT_SIGNAL,
367
+ REUSABLE_DEFAULT_TTL,
368
+ REUSABLE_MAX_DEVICES,
334
369
  SHARES_FILE,
335
370
  WEBRTC,
336
371
  authorizeCredential,
@@ -340,11 +375,13 @@ export {
340
375
  findShare,
341
376
  inviteLink,
342
377
  inviteProblem,
378
+ inviteState,
343
379
  normalizeSignal,
344
380
  parseDuration,
345
381
  readShares,
346
382
  redeemInvite,
347
383
  shareProblem,
384
+ shareSessions,
348
385
  updateShares,
349
386
  validateShares,
350
387
  webrtcCredentials,