rechrome 1.37.1 → 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 +5 -2
- package/rechrome.js +172 -74
- package/rechrome.ts +172 -74
- package/serve.js +102 -10
- package/serve.ts +102 -10
- package/skills/rechrome-setup/SKILL.md +2 -0
- package/skills/windows-desktop-ui/SKILL.md +64 -0
package/rechrome.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePr
|
|
|
5
5
|
import { file } from "bun";
|
|
6
6
|
import yargs from "yargs";
|
|
7
7
|
import { readExtensionTokenFromProfile } from "./extension-token.ts";
|
|
8
|
+
import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
|
|
8
9
|
import { createHash, randomBytes } from "crypto";
|
|
9
10
|
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
10
11
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
@@ -1869,6 +1870,7 @@ async function callServe(
|
|
|
1869
1870
|
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
1870
1871
|
throwOnFailure = false,
|
|
1871
1872
|
upload?: UploadPayload,
|
|
1873
|
+
secret?: string,
|
|
1872
1874
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
1873
1875
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
1874
1876
|
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
@@ -1887,7 +1889,8 @@ async function callServe(
|
|
|
1887
1889
|
method: "POST",
|
|
1888
1890
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
1889
1891
|
// dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
|
|
1890
|
-
|
|
1892
|
+
// `secret` (fill-secret only) rides in its own field so it is never part of the logged args.
|
|
1893
|
+
body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env, ...(upload ? { upload } : {}), ...(secret ? { secret } : {}) }),
|
|
1891
1894
|
signal: AbortSignal.timeout(70_000),
|
|
1892
1895
|
}).catch(async (e) => {
|
|
1893
1896
|
if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
|
|
@@ -1933,6 +1936,23 @@ async function callServe(
|
|
|
1933
1936
|
}
|
|
1934
1937
|
}
|
|
1935
1938
|
|
|
1939
|
+
/** Probe only the temporary setup session; always release it, including on transport errors. */
|
|
1940
|
+
export async function probeSetupBridge(
|
|
1941
|
+
url: string,
|
|
1942
|
+
session: string,
|
|
1943
|
+
env: Record<string, string>,
|
|
1944
|
+
identity: { key: string; label: string; profile?: string },
|
|
1945
|
+
run: typeof callServe = callServe,
|
|
1946
|
+
): Promise<Awaited<ReturnType<typeof callServe>>> {
|
|
1947
|
+
try {
|
|
1948
|
+
// The bundled CLI has no --wait option. about:blank needs no page-load override.
|
|
1949
|
+
return await run(url, [`-s=${session}`, "open", "about:blank"], env, identity, true);
|
|
1950
|
+
} finally {
|
|
1951
|
+
// Never claim or close an existing worktree session, or mask the probe error with cleanup errors.
|
|
1952
|
+
await run(url, [`-s=${session}`, "close"], env, identity, true).catch(() => {});
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1936
1956
|
const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
|
|
1937
1957
|
|
|
1938
1958
|
export function normalizeCommandArgs(args: string[]): string[] {
|
|
@@ -2082,12 +2102,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2082
2102
|
|
|
2083
2103
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension });
|
|
2084
2104
|
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
2105
|
+
let secret: string | undefined;
|
|
2085
2106
|
let upload: UploadPayload | undefined;
|
|
2086
|
-
|
|
2107
|
+
const cmdIdx = args.findIndex(a => !a.startsWith("-s="));
|
|
2108
|
+
if (args[cmdIdx] === "fill-secret") {
|
|
2109
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2110
|
+
try {
|
|
2111
|
+
({ args, secret } = await prepareFillSecret(args));
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${FILL_SECRET_USAGE}`);
|
|
2114
|
+
process.exit(2);
|
|
2115
|
+
}
|
|
2116
|
+
} else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
|
|
2087
2117
|
if (process.stdin.isTTY) throw new Error("upload reads stdin; redirect a PNG/SVG file with < icon.png");
|
|
2088
2118
|
({ args, upload } = await prepareStdinUpload(args, process.stdin));
|
|
2089
2119
|
}
|
|
2090
|
-
const
|
|
2120
|
+
const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
|
|
2121
|
+
// The daemon already masks the value; this is a second line of defence for an older daemon.
|
|
2122
|
+
const scrub = (s: string) => secret && s ? s.replaceAll(secret, SECRET_MASK) : s;
|
|
2123
|
+
const { files, existingSession } = served;
|
|
2124
|
+
const status = served.status, stdout = scrub(served.stdout), stderr = scrub(served.stderr);
|
|
2091
2125
|
if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
|
|
2092
2126
|
// Close the failed session so the retry gets a fresh cliDaemon and relay. callServe re-reads
|
|
2093
2127
|
// the extension's live token from the profile, so a re-minted token heals here too.
|
|
@@ -2154,6 +2188,22 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2154
2188
|
process.exit(status);
|
|
2155
2189
|
}
|
|
2156
2190
|
|
|
2191
|
+
/** Swap the client-side fill-secret args for the wire form and read the value (or TOTP code). */
|
|
2192
|
+
async function prepareFillSecret(args: string[]): Promise<{ args: string[]; secret: string }> {
|
|
2193
|
+
const sessionArgs = args.filter(a => a.startsWith("-s="));
|
|
2194
|
+
const req = parseFillSecretArgs(args.filter(a => !a.startsWith("-s=")).slice(1));
|
|
2195
|
+
const raw = await readSecretSource(req.source);
|
|
2196
|
+
let secret = raw;
|
|
2197
|
+
if (req.totp) {
|
|
2198
|
+
const wait = totpWaitMs();
|
|
2199
|
+
if (wait) { console.error(`[rech] TOTP code expires in under 5s; waiting ${Math.ceil(wait / 1000)}s for the next one`); await Bun.sleep(wait); }
|
|
2200
|
+
secret = totpCode(raw); // only the code leaves this machine, never the seed
|
|
2201
|
+
}
|
|
2202
|
+
if (!req.allowDomains.length) console.error("[rech] fill-secret: no --allow-domain given; the value will be typed into whatever page is active");
|
|
2203
|
+
const wire = fillSecretWireArgs(req);
|
|
2204
|
+
return { args: [...wire, ...sessionArgs], secret };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2157
2207
|
/** The daemon's Playwright CLI predates `open --wait` (rechrome <= 1.35.1 bundled one). */
|
|
2158
2208
|
export function cliRejectsWaitOption(output: string): boolean {
|
|
2159
2209
|
return /Unknown options?:?[^\n]*?['"`]?--wait\b/i.test(output);
|
|
@@ -2866,45 +2916,90 @@ function findChromiumForTesting(): string | null {
|
|
|
2866
2916
|
// Minimal Chrome DevTools Protocol client over a WebSocket — just enough to create a
|
|
2867
2917
|
// target, attach to it, and evaluate JS. Used to seed the auth token into a managed
|
|
2868
2918
|
// profile's extension localStorage without pulling in the full Playwright dependency.
|
|
2869
|
-
class CDPClient {
|
|
2919
|
+
export class CDPClient {
|
|
2870
2920
|
private ws: WebSocket;
|
|
2871
2921
|
private nextId = 0;
|
|
2872
|
-
private pending = new Map<number, { resolve: (v: any) => void; reject: (e:
|
|
2922
|
+
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
2873
2923
|
private opened: Promise<void>;
|
|
2874
|
-
constructor(url: string) {
|
|
2924
|
+
constructor(url: string, private timeoutMs = 30_000) {
|
|
2875
2925
|
this.ws = new WebSocket(url);
|
|
2876
2926
|
this.opened = new Promise<void>((resolve, reject) => {
|
|
2877
|
-
|
|
2878
|
-
this.ws.addEventListener("
|
|
2927
|
+
const timer = setTimeout(() => { reject(new Error("CDP WebSocket connection timed out")); this.close(); }, timeoutMs);
|
|
2928
|
+
this.ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
2929
|
+
const fail = () => {
|
|
2930
|
+
clearTimeout(timer);
|
|
2931
|
+
const error = new Error("CDP WebSocket closed or failed");
|
|
2932
|
+
reject(error);
|
|
2933
|
+
for (const p of this.pending.values()) p.reject(error);
|
|
2934
|
+
this.pending.clear();
|
|
2935
|
+
};
|
|
2936
|
+
this.ws.addEventListener("error", fail);
|
|
2937
|
+
this.ws.addEventListener("close", fail);
|
|
2879
2938
|
});
|
|
2880
2939
|
this.ws.addEventListener("message", (ev: MessageEvent) => {
|
|
2881
2940
|
let msg: any;
|
|
2882
2941
|
try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : ""); } catch { return; }
|
|
2883
2942
|
const p = msg.id != null ? this.pending.get(msg.id) : undefined;
|
|
2884
2943
|
if (!p) return;
|
|
2885
|
-
this.pending.delete(msg.id);
|
|
2886
2944
|
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
2887
2945
|
else p.resolve(msg.result);
|
|
2888
2946
|
});
|
|
2889
2947
|
}
|
|
2890
2948
|
async open(): Promise<void> { await this.opened; }
|
|
2891
2949
|
send(method: string, params: Record<string, any> = {}, sessionId?: string): Promise<any> {
|
|
2950
|
+
if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new Error("CDP WebSocket is not open"));
|
|
2892
2951
|
const id = ++this.nextId;
|
|
2893
|
-
const payload: any = { id, method, params };
|
|
2894
|
-
if (sessionId) payload.sessionId = sessionId;
|
|
2895
2952
|
return new Promise((resolve, reject) => {
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2953
|
+
const finish = (callback: (value: any) => void, value: any) => {
|
|
2954
|
+
clearTimeout(timer);
|
|
2955
|
+
this.pending.delete(id);
|
|
2956
|
+
callback(value);
|
|
2957
|
+
};
|
|
2958
|
+
const timer = setTimeout(() => finish(reject, new Error(`CDP ${method} timed out`)), this.timeoutMs);
|
|
2959
|
+
this.pending.set(id, { resolve: v => finish(resolve, v), reject: e => finish(reject, e) });
|
|
2960
|
+
try { this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
|
|
2961
|
+
catch (error) { finish(reject, error); }
|
|
2899
2962
|
});
|
|
2900
2963
|
}
|
|
2901
|
-
close(): void {
|
|
2964
|
+
close(): void {
|
|
2965
|
+
for (const p of this.pending.values()) p.reject(new Error("CDP client closed"));
|
|
2966
|
+
this.pending.clear();
|
|
2967
|
+
try { this.ws.close(); } catch {}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
async function waitForExit(exited: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
|
2972
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
2973
|
+
try {
|
|
2974
|
+
return await Promise.race([exited.then(() => true), new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); })]);
|
|
2975
|
+
} finally { clearTimeout(timer!); }
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
export async function seedExtensionToken(cdp: Pick<CDPClient, "send">, token: string): Promise<void> {
|
|
2979
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
|
|
2980
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2981
|
+
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2982
|
+
let ok = false;
|
|
2983
|
+
const expr = `(()=>{try{if(location.origin!==${JSON.stringify(`chrome-extension://${EXTENSION_ID}`)})return 'ERR:Extension page not loaded';localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
|
|
2984
|
+
let lastError = "Extension page is not ready";
|
|
2985
|
+
const deadline = Date.now() + 30_000;
|
|
2986
|
+
while (Date.now() < deadline) {
|
|
2987
|
+
const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId);
|
|
2988
|
+
lastError = r?.exceptionDetails?.text || String(r?.result?.value ?? "No evaluation result");
|
|
2989
|
+
if (r?.result?.value === token) { ok = true; break; }
|
|
2990
|
+
// A target opened before the extension finishes loading can remain on Chrome's
|
|
2991
|
+
// error document forever. Evaluating it again cannot finish the navigation.
|
|
2992
|
+
const navigation = await cdp.send("Page.navigate", { url: `chrome-extension://${EXTENSION_ID}/status.html` }, sessionId);
|
|
2993
|
+
if (navigation.errorText) lastError = navigation.errorText;
|
|
2994
|
+
await Bun.sleep(1_000);
|
|
2995
|
+
}
|
|
2996
|
+
if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/: ${lastError}`);
|
|
2902
2997
|
}
|
|
2903
2998
|
|
|
2904
2999
|
// Launch a throwaway Chrome against a dedicated user-data-dir with the unpacked extension
|
|
2905
3000
|
// loaded, then seed `token` into the extension's localStorage (the value `connect.html` checks
|
|
2906
3001
|
// for token-bypass). Headless by default; never touches the user's real Chrome/profiles.
|
|
2907
|
-
async function provisionExtensionToken(opts: {
|
|
3002
|
+
export async function provisionExtensionToken(opts: {
|
|
2908
3003
|
userDataDir: string; profileDir: string; dist: string; token: string; headed?: boolean;
|
|
2909
3004
|
}): Promise<void> {
|
|
2910
3005
|
// Branded Google Chrome 149+ rejects --load-extension ("not allowed in Google Chrome"), so a
|
|
@@ -2927,75 +3022,82 @@ async function provisionExtensionToken(opts: {
|
|
|
2927
3022
|
"--no-first-run",
|
|
2928
3023
|
"--no-default-browser-check",
|
|
2929
3024
|
"--disable-background-timer-throttling",
|
|
3025
|
+
"--disable-background-networking",
|
|
3026
|
+
"--disable-component-update",
|
|
3027
|
+
"--disable-search-engine-choice-screen",
|
|
3028
|
+
// Match Playwright's unattended macOS launch: no interactive keychain prompt.
|
|
3029
|
+
"--password-store=basic",
|
|
3030
|
+
"--use-mock-keychain",
|
|
2930
3031
|
];
|
|
2931
3032
|
if (!opts.headed) args.push("--headless=new");
|
|
2932
3033
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
2933
|
-
// Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
|
|
2934
|
-
if (process.platform === "darwin") args.push("--use-mock-keychain");
|
|
2935
3034
|
args.push("about:blank");
|
|
2936
|
-
|
|
3035
|
+
// Own a separate process group so escalation cannot touch the user's Chrome.
|
|
3036
|
+
const proc = cpSpawn(chromeBin, args, { detached: process.platform !== "win32", stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
3037
|
+
let stderr = "";
|
|
3038
|
+
proc.stderr?.on("data", data => { stderr = (stderr + data.toString()).slice(-8_192); });
|
|
3039
|
+
let launchError: Error | undefined;
|
|
3040
|
+
const exited = new Promise<void>(resolve => {
|
|
3041
|
+
proc.once("exit", () => resolve());
|
|
3042
|
+
proc.once("error", error => { launchError = error; resolve(); });
|
|
3043
|
+
});
|
|
3044
|
+
const signal = (value: NodeJS.Signals) => {
|
|
3045
|
+
if (!proc.pid) return;
|
|
3046
|
+
try {
|
|
3047
|
+
if (process.platform === "win32") proc.kill(value);
|
|
3048
|
+
else process.kill(-proc.pid, value);
|
|
3049
|
+
} catch {}
|
|
3050
|
+
};
|
|
3051
|
+
const interrupted = (value: NodeJS.Signals) => {
|
|
3052
|
+
signal("SIGKILL");
|
|
3053
|
+
console.error(`Profile provisioning interrupted (${value})`);
|
|
3054
|
+
process.exit(value === "SIGINT" ? 130 : 143);
|
|
3055
|
+
};
|
|
3056
|
+
const onInt = () => interrupted("SIGINT");
|
|
3057
|
+
const onTerm = () => interrupted("SIGTERM");
|
|
3058
|
+
process.once("SIGINT", onInt);
|
|
3059
|
+
process.once("SIGTERM", onTerm);
|
|
2937
3060
|
let cdp: CDPClient | null = null;
|
|
2938
|
-
|
|
2939
|
-
const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
|
|
2940
|
-
Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
|
|
3061
|
+
let phase = "Chrome startup";
|
|
2941
3062
|
try {
|
|
2942
3063
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
2943
3064
|
let port: number | null = null;
|
|
2944
|
-
for (let i = 0; i <
|
|
3065
|
+
for (let i = 0; i < 600; i++) {
|
|
2945
3066
|
await Bun.sleep(100);
|
|
2946
3067
|
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
2947
3068
|
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
2948
|
-
if (
|
|
3069
|
+
if (launchError) throw launchError;
|
|
3070
|
+
if (proc.exitCode !== null || proc.signalCode !== null) throw new Error(`Chrome exited before opening the DevTools port (${proc.exitCode ?? proc.signalCode})`);
|
|
2949
3071
|
}
|
|
2950
|
-
if (!port) throw new Error("Chrome
|
|
2951
|
-
|
|
3072
|
+
if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
|
|
3073
|
+
phase = "Chrome DevTools discovery";
|
|
3074
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(30_000) })).json();
|
|
3075
|
+
phase = "Chrome DevTools connection";
|
|
2952
3076
|
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
2953
|
-
await
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
let loaded = false;
|
|
2957
|
-
for (let i = 0; i < 50 && !loaded; i++) {
|
|
2958
|
-
const { targetInfos } = await cdp.send("Target.getTargets");
|
|
2959
|
-
loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
|
|
2960
|
-
if (!loaded) await Bun.sleep(200);
|
|
2961
|
-
}
|
|
2962
|
-
if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
|
|
2963
|
-
const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
|
|
2964
|
-
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2965
|
-
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2966
|
-
let ok = false, last = "";
|
|
2967
|
-
const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
|
|
2968
|
-
const deadline = Date.now() + 10_000;
|
|
2969
|
-
while (Date.now() < deadline) {
|
|
2970
|
-
const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
|
|
2971
|
-
const value = (r as any)?.result?.value;
|
|
2972
|
-
if (value === token) { ok = true; break; }
|
|
2973
|
-
last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
|
|
2974
|
-
await Bun.sleep(200);
|
|
2975
|
-
}
|
|
2976
|
-
if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
|
|
3077
|
+
await cdp.open();
|
|
3078
|
+
phase = "Extension token seeding";
|
|
3079
|
+
await seedExtensionToken(cdp, token);
|
|
2977
3080
|
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
2978
|
-
|
|
2979
|
-
await
|
|
3081
|
+
phase = "Chrome shutdown";
|
|
3082
|
+
await cdp.send("Browser.close").catch(() => {});
|
|
3083
|
+
if (!await waitForExit(exited, 30_000)) throw new Error("Chrome did not exit after flushing the auth token");
|
|
3084
|
+
if (readExtensionTokenFromProfile(userDataDir, profileDir, EXTENSION_ID) !== token)
|
|
3085
|
+
throw new Error("Chrome exited without persisting the extension auth token");
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3088
|
+
throw new Error(`${phase}: ${message}${stderr.trim() ? `\nChrome stderr:\n${stderr.trim()}` : ""}`.replaceAll(token, "[redacted]"));
|
|
2980
3089
|
} finally {
|
|
2981
3090
|
cdp?.close();
|
|
2982
|
-
|
|
3091
|
+
signal("SIGTERM");
|
|
3092
|
+
if (!await waitForExit(exited, 2_000)) signal("SIGKILL");
|
|
3093
|
+
// Also reap helpers that survived the browser parent exiting.
|
|
3094
|
+
signal("SIGKILL");
|
|
3095
|
+
await waitForExit(exited, 2_000);
|
|
3096
|
+
process.removeListener("SIGINT", onInt);
|
|
3097
|
+
process.removeListener("SIGTERM", onTerm);
|
|
2983
3098
|
}
|
|
2984
3099
|
}
|
|
2985
3100
|
|
|
2986
|
-
/**
|
|
2987
|
-
* Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
|
|
2988
|
-
* still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
|
|
2989
|
-
*/
|
|
2990
|
-
async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
|
|
2991
|
-
if (proc.exitCode === null) {
|
|
2992
|
-
try { proc.kill(); } catch {}
|
|
2993
|
-
const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
|
|
2994
|
-
if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
|
|
2995
|
-
}
|
|
2996
|
-
if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
|
|
2997
|
-
}
|
|
2998
|
-
|
|
2999
3101
|
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
3000
3102
|
// The name doubles as the on-disk profile directory and the registry/URL key, so keep it a
|
|
3001
3103
|
// simple token and disallow the reserved real-Chrome names to avoid any cross-talk.
|
|
@@ -3012,7 +3114,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
3012
3114
|
console.log(` extension: ${dist}`);
|
|
3013
3115
|
console.log(` Launching ${opts.headed ? "headed" : "headless"} Chrome to seed the auth token...`);
|
|
3014
3116
|
await provisionExtensionToken({ userDataDir, profileDir: name, dist, token, headed: opts.headed });
|
|
3015
|
-
console.log(
|
|
3117
|
+
console.log(" Token seeded");
|
|
3016
3118
|
|
|
3017
3119
|
// [2/3] Daemon URL — reuse the running daemon's key; warn (don't fail) if it isn't up yet.
|
|
3018
3120
|
console.log(`\n[2/3] Building RECHROME_URL`);
|
|
@@ -3585,10 +3687,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3585
3687
|
guide?.takeRetry();
|
|
3586
3688
|
guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
|
|
3587
3689
|
try {
|
|
3588
|
-
const probe = await
|
|
3589
|
-
args => callServe(rechUrl.toString(), [`-s=${probeSession}`, ...args], probeEnv, probeIdentity, true),
|
|
3590
|
-
note => console.log(` ${note}`),
|
|
3591
|
-
);
|
|
3690
|
+
const probe = await probeSetupBridge(rechUrl.toString(), probeSession, probeEnv, probeIdentity);
|
|
3592
3691
|
bridgeVerified = probe.status === 0;
|
|
3593
3692
|
if (bridgeVerified) {
|
|
3594
3693
|
console.log("✓ [4/5] Extension bridge connected successfully");
|
|
@@ -3600,9 +3699,6 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3600
3699
|
} catch (error) {
|
|
3601
3700
|
bridgeError = error instanceof Error ? error.message : String(error);
|
|
3602
3701
|
console.error(` Extension bridge verification failed: ${bridgeError}`);
|
|
3603
|
-
} finally {
|
|
3604
|
-
// The isolated probe must never claim or close an existing worktree session.
|
|
3605
|
-
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity, true).catch(() => {});
|
|
3606
3702
|
}
|
|
3607
3703
|
|
|
3608
3704
|
if (bridgeVerified) break;
|
|
@@ -4129,6 +4225,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4129
4225
|
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
4130
4226
|
--profile <p> as another registered profile (email, name or folder); put it first
|
|
4131
4227
|
--isolate in a throwaway session, e.g. for a login flow
|
|
4228
|
+
rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
|
|
4229
|
+
type a password / TOTP code without it reaching argv, logs or output
|
|
4132
4230
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4133
4231
|
rech --version rechrome's version
|
|
4134
4232
|
|
package/serve.js
CHANGED
|
@@ -5,8 +5,8 @@ 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, 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.js";
|
|
27
|
+
import { SecretMasker, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
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.js.
|
|
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.js.
|
|
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
|