rechrome 1.46.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, 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,34 @@ 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
+ const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
127
+ if (out !== s.out) renameSync(s.out, out);
128
+ 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] } : {}) })}`);
129
+ if (!entry.keep) {
130
+ const real = realpathSync(out);
131
+ clearTimeout(pendingDownloads.get(real));
132
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
133
+ }
134
+ const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
135
+ 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("\\", "/")] };
136
+ }
108
137
  // playwright-cli's line for a finished download (tools/backend/response.js).
109
138
  const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
139
  // Where the CLI saves downloads: its output dir under the session's cwd (context.js outputDir).
@@ -701,6 +730,16 @@ export async function serve() {
701
730
  }).catch(error => log(`could not record device use: ${error}`));
702
731
  };
703
732
 
733
+ const profileInfo = async (profiles: string[] | "*") => {
734
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
735
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
736
+ const keys = profiles === "*" ? Object.keys(registry) : profiles;
737
+ return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
738
+ };
739
+ const liveSessions = () => {
740
+ try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
741
+ };
742
+
704
743
  const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
705
744
  let listener = initialListener;
706
745
  const reqUrl = new URL(req.url);
@@ -735,7 +774,8 @@ export async function serve() {
735
774
  const { name } = await req.json().catch(() => ({})) as { name?: string };
736
775
  try {
737
776
  const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
738
- shares = await readShares();
777
+ // Open the device's room now, not on the next 1 s reload: the client uses it right away.
778
+ await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
739
779
  log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
740
780
  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
781
  } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
@@ -744,17 +784,13 @@ export async function serve() {
744
784
  if (reqUrl.pathname === "/profiles") {
745
785
  const denied = authCheck(req, key);
746
786
  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] })) });
787
+ return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
751
788
  }
752
789
 
753
790
  if (reqUrl.pathname === "/sessions") {
754
791
  const denied = authCheck(req, key);
755
792
  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 {}
793
+ const live = liveSessions();
758
794
  const all = listener.profiles === "*" && !cred;
759
795
  const rows = [...sessionOwners.entries()]
760
796
  .filter(([, o]) => all || o.owner === owner)
@@ -812,7 +848,10 @@ export async function serve() {
812
848
  if (scoped || !reqUrl.searchParams.get("deep"))
813
849
  return Response.json({
814
850
  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 } : {}),
851
+ ...(cred ? {
852
+ 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,
853
+ profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
854
+ } : {}),
816
855
  });
817
856
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
818
857
  // most-recently-used session — never a fresh one, which could spawn a browser window.
@@ -1003,8 +1042,8 @@ export async function serve() {
1003
1042
  // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
1004
1043
  if (filteredArgs[0] === "read-storage") {
1005
1044
  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 });
1045
+ const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
1046
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
1008
1047
  if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
1009
1048
  secretMasker.add(value, Infinity);
1010
1049
  log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
@@ -1032,6 +1071,87 @@ export async function serve() {
1032
1071
  }
1033
1072
  }
1034
1073
 
1074
+ // read-headers: response status + headers (no bodies) for an --allow-domain host over a
1075
+ // window, credential header values and URL query values redacted. Written as a JSON evidence file in
1076
+ // the output dir and returned in `files`; header values never reach stdout or a log line.
1077
+ if (filteredArgs[0] === "read-headers") {
1078
+ try {
1079
+ const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
1080
+ const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
1081
+ const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1082
+ await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
1083
+ log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
1084
+ return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
1085
+ } catch (error) {
1086
+ const message = error instanceof Error ? error.message : String(error);
1087
+ log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
1088
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1089
+ }
1090
+ }
1091
+
1092
+ // record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
1093
+ // streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
1094
+ // open-ended recording; plain `record --seconds N` does both in one call. See record.js.
1095
+ if (filteredArgs[0] === "record") {
1096
+ const fail = (message: string) => {
1097
+ log(`exit: 1 | ${message.split("\n")[0]}`);
1098
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1099
+ };
1100
+ try {
1101
+ const a = parseRecordArgs(filteredArgs);
1102
+ const existing = recordings.get(namespacedSession);
1103
+ if (a.mode === "stop") {
1104
+ if (!existing) return fail("record: no recording in progress; run `rech record start` first");
1105
+ recordings.delete(namespacedSession);
1106
+ clearTimeout(existing.expire);
1107
+ const s = existing.done ?? await existing.rec.stop();
1108
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
1109
+ }
1110
+ 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\``);
1111
+ const ffmpegBin = resolveFfmpeg();
1112
+ const format = resolveFormat(a.format, ffmpegBin !== null);
1113
+ const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1114
+ const code = buildCaptureChunkCode(a);
1115
+ const capture = async (): Promise<CaptureChunk> => {
1116
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
+ if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1118
+ // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1119
+ let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
1120
+ for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
1121
+ const chunk = parsed as CaptureChunk | undefined;
1122
+ if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
1123
+ return chunk;
1124
+ };
1125
+ const entry: RecordingEntry = {
1126
+ dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
1127
+ rec: new Recording({
1128
+ fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
1129
+ capture, sink: recordSink(format, ffmpegBin, a.fps, out),
1130
+ onAutoStop: s => {
1131
+ entry.done = s;
1132
+ // Nobody came for it: drop it like an uncollected download.
1133
+ entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
1134
+ log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
1135
+ },
1136
+ }),
1137
+ };
1138
+ recordings.set(namespacedSession, entry);
1139
+ if (a.mode === "start") {
1140
+ const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
1141
+ log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
1142
+ 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: [] });
1143
+ }
1144
+ // Answer early if the capture ends on its own (the tab went away).
1145
+ await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
1146
+ recordings.delete(namespacedSession);
1147
+ clearTimeout(entry.expire);
1148
+ const s = await entry.rec.stop();
1149
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
1150
+ } catch (error) {
1151
+ return fail(error instanceof Error ? error.message : String(error));
1152
+ }
1153
+ }
1154
+
1035
1155
  // For open commands, default to about:blank to avoid leaving connect.html visible
1036
1156
  const isOpenCmd = filteredArgs[0] === "open";
1037
1157
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
@@ -1377,6 +1497,10 @@ export async function serve() {
1377
1497
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1378
1498
  },
1379
1499
  async fetch(req, server) {
1500
+ // Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
1501
+ // rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
1502
+ // than the client waits (70 s + a recording's length).
1503
+ if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
1380
1504
  const listener = policies.get(listenerAddress(initial));
1381
1505
  if (!listener) return new Response("Listener removed", { status: 403 });
1382
1506
  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, 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,34 @@ 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
+ const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
127
+ if (out !== s.out) renameSync(s.out, out);
128
+ 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] } : {}) })}`);
129
+ if (!entry.keep) {
130
+ const real = realpathSync(out);
131
+ clearTimeout(pendingDownloads.get(real));
132
+ pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
133
+ }
134
+ const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
135
+ 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("\\", "/")] };
136
+ }
108
137
  // playwright-cli's line for a finished download (tools/backend/response.ts).
109
138
  const DOWNLOADED_LINE = /^- Downloaded file (.+?) to "(.+)"$/gm;
110
139
  // Where the CLI saves downloads: its output dir under the session's cwd (context.ts outputDir).
@@ -701,6 +730,16 @@ export async function serve() {
701
730
  }).catch(error => log(`could not record device use: ${error}`));
702
731
  };
703
732
 
733
+ const profileInfo = async (profiles: string[] | "*") => {
734
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
735
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
736
+ const keys = profiles === "*" ? Object.keys(registry) : profiles;
737
+ return keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] }));
738
+ };
739
+ const liveSessions = () => {
740
+ try { return new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch { return new Set<string>(); }
741
+ };
742
+
704
743
  const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
705
744
  let listener = initialListener;
706
745
  const reqUrl = new URL(req.url);
@@ -735,7 +774,8 @@ export async function serve() {
735
774
  const { name } = await req.json().catch(() => ({})) as { name?: string };
736
775
  try {
737
776
  const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
738
- shares = await readShares();
777
+ // Open the device's room now, not on the next 1 s reload: the client uses it right away.
778
+ await reloadShares().catch(error => log(`shares.json reload rejected; previous shares retained: ${error}`));
739
779
  log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
740
780
  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
781
  } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
@@ -744,17 +784,13 @@ export async function serve() {
744
784
  if (reqUrl.pathname === "/profiles") {
745
785
  const denied = authCheck(req, key);
746
786
  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] })) });
787
+ return Response.json({ share: cred?.share.label, profiles: await profileInfo(listener.profiles) });
751
788
  }
752
789
 
753
790
  if (reqUrl.pathname === "/sessions") {
754
791
  const denied = authCheck(req, key);
755
792
  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 {}
793
+ const live = liveSessions();
758
794
  const all = listener.profiles === "*" && !cred;
759
795
  const rows = [...sessionOwners.entries()]
760
796
  .filter(([, o]) => all || o.owner === owner)
@@ -812,7 +848,10 @@ export async function serve() {
812
848
  if (scoped || !reqUrl.searchParams.get("deep"))
813
849
  return Response.json({
814
850
  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 } : {}),
851
+ ...(cred ? {
852
+ 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,
853
+ profileInfo: await profileInfo(cred.share.profiles), sessions: shareSessions(cred.share, sessionOwners, liveSessions()),
854
+ } : {}),
816
855
  });
817
856
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
818
857
  // most-recently-used session — never a fresh one, which could spawn a browser window.
@@ -1003,8 +1042,8 @@ export async function serve() {
1003
1042
  // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
1004
1043
  if (filteredArgs[0] === "read-storage") {
1005
1044
  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 });
1045
+ const { area, key, path, allowDomains, gotoUrl } = parseReadStorageWire(filteredArgs);
1046
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains, gotoUrl });
1008
1047
  if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
1009
1048
  secretMasker.add(value, Infinity);
1010
1049
  log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
@@ -1032,6 +1071,87 @@ export async function serve() {
1032
1071
  }
1033
1072
  }
1034
1073
 
1074
+ // read-headers: response status + headers (no bodies) for an --allow-domain host over a
1075
+ // window, credential header values and URL query values redacted. Written as a JSON evidence file in
1076
+ // the output dir and returned in `files`; header values never reach stdout or a log line.
1077
+ if (filteredArgs[0] === "read-headers") {
1078
+ try {
1079
+ const { allowDomains, seconds, gotoUrl } = parseReadHeadersWire(filteredArgs);
1080
+ const { host, captured } = await readHeadersOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, allowDomains, seconds, gotoUrl });
1081
+ const name = `read-headers-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1082
+ await Bun.write(join(runWorkDir, name), readHeadersEvidence(captured, t => secretMasker.mask(t)));
1083
+ log(`exit: 0 | read-headers ${allowDomains.join(",")} (${captured.length} responses, ${seconds}s window, header values redacted)`);
1084
+ return Response.json({ status: 0, stdout: `[rech] read-headers: captured ${captured.length} responses on ${host} to ${name}\n`, stderr: "", files: [(outputPrefix + name).replaceAll("\\", "/")] });
1085
+ } catch (error) {
1086
+ const message = error instanceof Error ? error.message : String(error);
1087
+ log(`exit: 1 | read-headers: ${message.split("\n")[0]}`);
1088
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1089
+ }
1090
+ }
1091
+
1092
+ // record: serve runs the capture loop (short run-code screenshot chunks, back to back) and
1093
+ // streams every frame into ffmpeg as it arrives. `record start` / `record stop` bracket an
1094
+ // open-ended recording; plain `record --seconds N` does both in one call. See record.ts.
1095
+ if (filteredArgs[0] === "record") {
1096
+ const fail = (message: string) => {
1097
+ log(`exit: 1 | ${message.split("\n")[0]}`);
1098
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
1099
+ };
1100
+ try {
1101
+ const a = parseRecordArgs(filteredArgs);
1102
+ const existing = recordings.get(namespacedSession);
1103
+ if (a.mode === "stop") {
1104
+ if (!existing) return fail("record: no recording in progress; run `rech record start` first");
1105
+ recordings.delete(namespacedSession);
1106
+ clearTimeout(existing.expire);
1107
+ const s = existing.done ?? await existing.rec.stop();
1108
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(existing, s, a.filename) });
1109
+ }
1110
+ 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\``);
1111
+ const ffmpegBin = resolveFfmpeg();
1112
+ const format = resolveFormat(a.format, ffmpegBin !== null);
1113
+ const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1114
+ const code = buildCaptureChunkCode(a);
1115
+ const capture = async (): Promise<CaptureChunk> => {
1116
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
+ if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1118
+ // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1119
+ let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
1120
+ for (let i = 0; i < 2 && typeof parsed === "string"; i++) { try { parsed = JSON.parse(parsed); } catch { parsed = undefined; } }
1121
+ const chunk = parsed as CaptureChunk | undefined;
1122
+ if (!Array.isArray(chunk?.frames) || !Array.isArray(chunk?.times)) throw new Error("record: the browser returned no frames");
1123
+ return chunk;
1124
+ };
1125
+ const entry: RecordingEntry = {
1126
+ dir: runWorkDir, outputPrefix, listener: listener.name, keep: listener.keepDownloads === true,
1127
+ rec: new Recording({
1128
+ fps: a.fps, format, out, maxMs: (a.mode === "timed" ? a.seconds : a.maxSeconds) * 1000,
1129
+ capture, sink: recordSink(format, ffmpegBin, a.fps, out),
1130
+ onAutoStop: s => {
1131
+ entry.done = s;
1132
+ // Nobody came for it: drop it like an uncollected download.
1133
+ entry.expire = setTimeout(() => { if (recordings.get(namespacedSession) === entry) recordings.delete(namespacedSession); try { unlinkSync(s.out); } catch {} }, DOWNLOAD_PICKUP_MS);
1134
+ log(`record: ${namespacedSession} stopped by itself after ${s.seconds.toFixed(1)} s${s.error ? ` (${s.error.split("\n")[0]})` : ""}`);
1135
+ },
1136
+ }),
1137
+ };
1138
+ recordings.set(namespacedSession, entry);
1139
+ if (a.mode === "start") {
1140
+ const note = format !== a.format ? ` (no ffmpeg on the host, so ${format})` : "";
1141
+ log(`exit: 0 | record start (${a.fps} fps, ${format}, max ${a.maxSeconds} s)`);
1142
+ 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: [] });
1143
+ }
1144
+ // Answer early if the capture ends on its own (the tab went away).
1145
+ await Promise.race([Bun.sleep(a.seconds * 1000), entry.rec.ended]);
1146
+ recordings.delete(namespacedSession);
1147
+ clearTimeout(entry.expire);
1148
+ const s = await entry.rec.stop();
1149
+ return Response.json({ status: 0, stderr: "", ...deliverRecording(entry, s, a.filename) });
1150
+ } catch (error) {
1151
+ return fail(error instanceof Error ? error.message : String(error));
1152
+ }
1153
+ }
1154
+
1035
1155
  // For open commands, default to about:blank to avoid leaving connect.html visible
1036
1156
  const isOpenCmd = filteredArgs[0] === "open";
1037
1157
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
@@ -1377,6 +1497,10 @@ export async function serve() {
1377
1497
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1378
1498
  },
1379
1499
  async fetch(req, server) {
1500
+ // Bun's default idle limit is 10 s. Bun 1.4 does not apply it to a POST with a body, but don't
1501
+ // rely on that: a /run can take longer (`record --seconds 60`, a slow page). Allow it longer
1502
+ // than the client waits (70 s + a recording's length).
1503
+ if (req.method === "POST") server.timeout(req, RUN_IDLE_SECONDS);
1380
1504
  const listener = policies.get(listenerAddress(initial));
1381
1505
  if (!listener) return new Response("Listener removed", { status: 403 });
1382
1506
  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,