rechrome 1.37.2 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/fill-secret.js +405 -0
- package/fill-secret.ts +399 -0
- package/listeners.js +2 -2
- package/listeners.ts +2 -2
- package/package.json +4 -2
- package/rechrome.js +154 -67
- package/rechrome.ts +154 -67
- package/serve.js +102 -10
- package/serve.ts +102 -10
package/serve.ts
CHANGED
|
@@ -5,8 +5,8 @@ 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, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
9
|
-
import { join, resolve, relative, isAbsolute } from "path";
|
|
8
|
+
import { mkdirSync, lstatSync, statSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, existsSync, readFileSync, constants as fsConstants } from "fs";
|
|
9
|
+
import { join, resolve, relative, isAbsolute, sep } from "path";
|
|
10
10
|
import { tmpdir } from "os";
|
|
11
11
|
import {
|
|
12
12
|
log,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.ts";
|
|
27
|
+
import { SecretMasker, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
27
28
|
|
|
28
29
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
29
30
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
@@ -87,6 +88,18 @@ const sessionTimeouts = new Map<string, number>(); // per-session consecutive-ti
|
|
|
87
88
|
// Most-recently-used non-iso sessions, so the deep health probe can target something real
|
|
88
89
|
// instead of spawning a fresh session (which could open a browser window).
|
|
89
90
|
const recentSessions = new Map<string, number>();
|
|
91
|
+
// Values typed by `fill-secret`, masked in every output, log line and served text file for a
|
|
92
|
+
// while afterwards: snapshots echo input values (passwords included). See fill-secret.ts.
|
|
93
|
+
const secretMasker = new SecretMasker();
|
|
94
|
+
const TEXT_OUTPUT = /\.(?:ya?ml|json|md|txt|log)$/i;
|
|
95
|
+
// /run commands in flight, with the (real) output dir each writes to once known. A snapshot file
|
|
96
|
+
// exists on disk before its command has checked it for password values, so a text download waits
|
|
97
|
+
// until no command writing to its directory is in flight.
|
|
98
|
+
const runsInFlight = new Map<Promise<void>, string | undefined>();
|
|
99
|
+
// Only the CLI's own artifacts are ever scrubbed or deleted, never a file a page merely names.
|
|
100
|
+
const CLI_TEXT_ARTIFACT = /(^|\/)\.playwright-cli(-multi-tab)?\/[^/]+\.(?:ya?ml|json|md|txt|log)$/;
|
|
101
|
+
// Artifacts that could not be checked AND could not be deleted: never served.
|
|
102
|
+
const deniedFiles = new Set<string>();
|
|
90
103
|
function noteSession(sess: string, now: number): void {
|
|
91
104
|
recentSessions.set(sess, now);
|
|
92
105
|
if (recentSessions.size > 64) {
|
|
@@ -738,6 +751,15 @@ export async function serve() {
|
|
|
738
751
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
739
752
|
const real = realpathSync(resolved);
|
|
740
753
|
if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
|
|
754
|
+
if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
|
|
755
|
+
if (TEXT_OUTPUT.test(real)) {
|
|
756
|
+
const busy = () => [...runsInFlight].filter(([, dir]) => !dir || real.startsWith(dir + sep)).map(([p]) => p);
|
|
757
|
+
for (let pending = busy(); pending.length; pending = busy()) await Promise.all(pending); // re-check: new runs may start
|
|
758
|
+
// Synchronous from the last check on: no await, so no new run can start and write here.
|
|
759
|
+
if (deniedFiles.has(real)) return new Response("Forbidden", { status: 403 });
|
|
760
|
+
if (!existsSync(real)) return new Response("Not found", { status: 404 }); // withheld
|
|
761
|
+
return new Response(maskPasswordLines(secretMasker.mask(readFileSync(real, "utf8"))));
|
|
762
|
+
}
|
|
741
763
|
return new Response(f);
|
|
742
764
|
}
|
|
743
765
|
|
|
@@ -782,6 +804,10 @@ export async function serve() {
|
|
|
782
804
|
const denied = authCheck(req, key);
|
|
783
805
|
if (denied) return denied;
|
|
784
806
|
markActivity(); // a real command: this serve is not idle
|
|
807
|
+
let runDone = () => {};
|
|
808
|
+
const running = new Promise<void>((r) => { runDone = r; });
|
|
809
|
+
runsInFlight.set(running, undefined); // dir not known yet: holds every text download
|
|
810
|
+
try { return await (async () => {
|
|
785
811
|
|
|
786
812
|
let body: any;
|
|
787
813
|
try { body = await readRequestJson(req); }
|
|
@@ -827,6 +853,7 @@ export async function serve() {
|
|
|
827
853
|
const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) + (cred?.kind === "device" ? `d-${cred.device.id}/` : "") : "";
|
|
828
854
|
const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
|
|
829
855
|
mkdirSync(runWorkDir, { recursive: true });
|
|
856
|
+
runsInFlight.set(running, realpathSync(runWorkDir));
|
|
830
857
|
let args: string[];
|
|
831
858
|
let sessionId: string;
|
|
832
859
|
let clientName = "";
|
|
@@ -894,6 +921,27 @@ export async function serve() {
|
|
|
894
921
|
|
|
895
922
|
log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
|
|
896
923
|
|
|
924
|
+
// fill-secret: the value arrives in body.secret, never in args; it is handed to the live
|
|
925
|
+
// session over its socket (no child argv) and never logged. See fill-secret.ts.
|
|
926
|
+
if (filteredArgs[0] === "fill-secret") {
|
|
927
|
+
const secret = !Array.isArray(body) && typeof body.secret === "string" ? body.secret : "";
|
|
928
|
+
try {
|
|
929
|
+
const { ref, allowDomains, submit, totp } = parseFillSecretWire(filteredArgs);
|
|
930
|
+
if (!secret) throw new Error("fill-secret: the request carried no secret (this client is older than the daemon?)");
|
|
931
|
+
secretMasker.add(secret, totp ? undefined : Infinity);
|
|
932
|
+
const reply = await fillSecretOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, ref, value: secret, allowDomains, submit });
|
|
933
|
+
// Drop the echoed run-code body (it embeds the value, masked or not, and is noise).
|
|
934
|
+
const text = secretMasker.mask(reply.text).replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n?/, "").trim();
|
|
935
|
+
const status = reply.isError ? 1 : 0;
|
|
936
|
+
log(`exit: ${status} | fill-secret ${ref}`);
|
|
937
|
+
return Response.json(reply.isError ? { status, stdout: "", stderr: `${text}\n`, files: [] } : { status, stdout: `${text}\n`, stderr: "", files: [] });
|
|
938
|
+
} catch (error) {
|
|
939
|
+
const message = secretMasker.mask(secret ? (error instanceof Error ? error.message : String(error)).replaceAll(secret, "***") : (error instanceof Error ? error.message : String(error)));
|
|
940
|
+
log(`exit: 1 | fill-secret: ${message.split("\n")[0]}`);
|
|
941
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
897
945
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
898
946
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
899
947
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
@@ -1041,7 +1089,7 @@ export async function serve() {
|
|
|
1041
1089
|
reject(new Error("timeout"));
|
|
1042
1090
|
}, TIMEOUT);
|
|
1043
1091
|
});
|
|
1044
|
-
|
|
1092
|
+
let [status, stdout, rawStderr] = await Promise.race([
|
|
1045
1093
|
Promise.all([
|
|
1046
1094
|
proc.exited,
|
|
1047
1095
|
new Response(proc.stdout).text(),
|
|
@@ -1058,7 +1106,7 @@ export async function serve() {
|
|
|
1058
1106
|
? configuredHandshakeTimeout
|
|
1059
1107
|
: 30_000;
|
|
1060
1108
|
const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
|
|
1061
|
-
|
|
1109
|
+
let stderr = (tccNote ?? "") + inferSilentExtensionFailure({
|
|
1062
1110
|
status,
|
|
1063
1111
|
stdout,
|
|
1064
1112
|
stderr: rawStderr,
|
|
@@ -1068,6 +1116,24 @@ export async function serve() {
|
|
|
1068
1116
|
handshakeTimeoutMs,
|
|
1069
1117
|
});
|
|
1070
1118
|
|
|
1119
|
+
// Snapshots print every input's value, password fields included (e.g. ones Chrome
|
|
1120
|
+
// autofilled). Read the live password values and mask them before anything is logged,
|
|
1121
|
+
// returned or left on disk; the label-based pass catches what the scan misses.
|
|
1122
|
+
// Fails closed: a snapshot that can't be fully checked is withheld, not passed through.
|
|
1123
|
+
const rawOutput = `${stdout}\n${stderr}`; // artifact paths come from the unmasked text
|
|
1124
|
+
const snapshotOut = hasSnapshot(rawOutput);
|
|
1125
|
+
let withheld = "";
|
|
1126
|
+
if (snapshotOut) {
|
|
1127
|
+
try {
|
|
1128
|
+
for (const v of await passwordValuesOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir })) secretMasker.add(v, Infinity);
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
withheld = "could not check it for password values";
|
|
1131
|
+
log(`snapshot withheld: ${secretMasker.mask(error instanceof Error ? error.message : String(error)).split("\n")[0]}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
stdout = maskPasswordLines(secretMasker.mask(stdout));
|
|
1135
|
+
stderr = maskPasswordLines(secretMasker.mask(stderr));
|
|
1136
|
+
if (withheld) { stdout = withholdSnapshots(stdout, withheld); stderr = withholdSnapshots(stderr, withheld); }
|
|
1071
1137
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
1072
1138
|
|
|
1073
1139
|
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS). A command that RETURNS usually
|
|
@@ -1105,12 +1171,8 @@ export async function serve() {
|
|
|
1105
1171
|
}
|
|
1106
1172
|
|
|
1107
1173
|
// Detect files mentioned in output
|
|
1108
|
-
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|
|
|
1109
|
-
const mentionedFiles = [
|
|
1110
|
-
...new Set(
|
|
1111
|
-
[...stdout.matchAll(filePattern), ...stderr.matchAll(filePattern)].map((m) => m[0]),
|
|
1112
|
-
),
|
|
1113
|
-
];
|
|
1174
|
+
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|ya?ml)\b/gi;
|
|
1175
|
+
const mentionedFiles = [...new Set([...rawOutput.matchAll(filePattern)].map((m) => m[0]))];
|
|
1114
1176
|
const outputFiles: string[] = [];
|
|
1115
1177
|
for (const f of mentionedFiles) {
|
|
1116
1178
|
if (!isUnderDir(runWorkDir, f)) continue;
|
|
@@ -1130,6 +1192,35 @@ export async function serve() {
|
|
|
1130
1192
|
}
|
|
1131
1193
|
}
|
|
1132
1194
|
|
|
1195
|
+
// A file the command was told to write (`snapshot --filename=x.yml`) is its own artifact too.
|
|
1196
|
+
const ownFilenames = filteredArgs.flatMap((a, i) => a.startsWith("--filename=") ? [a.slice(11)] : a === "--filename" && filteredArgs[i + 1] ? [filteredArgs[i + 1]!] : []);
|
|
1197
|
+
// Snapshot files echo input values: scrub them on disk too, not only on download. Only
|
|
1198
|
+
// files this command wrote (a page can merely mention an existing file's name). One that
|
|
1199
|
+
// can't be checked or scrubbed is deleted, or, failing that, never served.
|
|
1200
|
+
// The command's own --filename targets count even when the output doesn't name them.
|
|
1201
|
+
const ownTargets = ownFilenames.filter(n => isUnderDir(runWorkDir, n) && existsSync(join(runWorkDir, n)));
|
|
1202
|
+
if (snapshotOut || secretMasker.active) for (const f of [...new Set([...outputFiles, ...ownTargets])]) {
|
|
1203
|
+
const ownFile = ownFilenames.some(n => resolve(runWorkDir, n) === resolve(runWorkDir, f));
|
|
1204
|
+
if (!ownFile && !CLI_TEXT_ARTIFACT.test(f.replaceAll("\\", "/"))) continue;
|
|
1205
|
+
const path = join(runWorkDir, f);
|
|
1206
|
+
let mtimeMs = 0;
|
|
1207
|
+
try { mtimeMs = statSync(path).mtimeMs; } catch { continue; }
|
|
1208
|
+
if (mtimeMs < commandStartedAt - 1000) continue;
|
|
1209
|
+
try {
|
|
1210
|
+
// Unchecked: withhold every snapshot file, and anything the command was told to write.
|
|
1211
|
+
if (withheld && (ownFile || /\.ya?ml$/i.test(f))) throw new Error("withheld");
|
|
1212
|
+
// Text vs binary by content, not name: `snapshot --filename=x.png` writes text.
|
|
1213
|
+
const bytes = new Uint8Array(await file(path).arrayBuffer());
|
|
1214
|
+
if (bytes.subarray(0, 8192).includes(0)) continue; // binary (e.g. a screenshot)
|
|
1215
|
+
const text = new TextDecoder().decode(bytes);
|
|
1216
|
+
const masked = maskPasswordLines(secretMasker.mask(text));
|
|
1217
|
+
if (masked !== text) await Bun.write(path, masked);
|
|
1218
|
+
} catch {
|
|
1219
|
+
try { unlinkSync(path); } catch { try { deniedFiles.add(realpathSync(path)); } catch {} }
|
|
1220
|
+
if (outputFiles.includes(f)) outputFiles.splice(outputFiles.indexOf(f), 1);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1133
1224
|
const rebrand = (s: string) => s.replaceAll("npx playwright-cli", "rech");
|
|
1134
1225
|
return Response.json({
|
|
1135
1226
|
status,
|
|
@@ -1139,6 +1230,7 @@ export async function serve() {
|
|
|
1139
1230
|
// a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
|
|
1140
1231
|
files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
|
|
1141
1232
|
});
|
|
1233
|
+
})(); } finally { runsInFlight.delete(running); runDone(); }
|
|
1142
1234
|
};
|
|
1143
1235
|
|
|
1144
1236
|
// WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
|