@melaya/runner 1.0.118 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ // packages/runner/src/browserProvisioner.ts
2
+ //
3
+ // Melaya Browser, Phase 1 (plan Section 7): discovery + provisioning of
4
+ // controllable browser engines on the operator's box. Brave is a
5
+ // FIRST-CLASS, user-selectable engine alongside Chrome and Edge (user
6
+ // decision, plan Section 7). The runner ships NO browser binaries; this
7
+ // module only discovers what is installed, reports versions, and (only
8
+ // behind an explicit flag) provisions the Playwright-bundled Chromium.
9
+ // It never silently downloads anything.
10
+ //
11
+ // Brave quirks the capability report surfaces (plan Section 7): Shields
12
+ // can block or rewrite page requests, which may alter snapshots and
13
+ // trip origin policy in ways Chrome would not; Brave Rewards/Wallet
14
+ // surfaces add browser-internal pages; Brave uses a distinct
15
+ // user-data-dir layout. All three engines are Chromium-based, so CDP
16
+ // launch + connectOverCDP work identically.
17
+ import { existsSync, readdirSync } from "node:fs";
18
+ import { execFile } from "node:child_process";
19
+ import { join } from "node:path";
20
+ import os from "node:os";
21
+ const BRAVE_QUIRKS = [
22
+ "brave_shields_may_alter_requests_and_snapshots",
23
+ "brave_internal_surfaces_rewards_wallet",
24
+ "brave_distinct_user_data_dir",
25
+ ];
26
+ // ---------------------------------------------------------------------
27
+ // Per-OS well-known install paths
28
+ // ---------------------------------------------------------------------
29
+ function windowsCandidates() {
30
+ const pf = process.env["ProgramFiles"] || "C:\\Program Files";
31
+ const pf86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
32
+ const local = process.env["LOCALAPPDATA"] || join(os.homedir(), "AppData", "Local");
33
+ return [
34
+ { engine: "chrome", path: join(pf, "Google", "Chrome", "Application", "chrome.exe"), channel: "chrome", quirks: [] },
35
+ { engine: "chrome", path: join(pf86, "Google", "Chrome", "Application", "chrome.exe"), channel: "chrome", quirks: [] },
36
+ { engine: "chrome", path: join(local, "Google", "Chrome", "Application", "chrome.exe"), channel: "chrome", quirks: [] },
37
+ { engine: "edge", path: join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"), channel: "msedge", quirks: [] },
38
+ { engine: "edge", path: join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), channel: "msedge", quirks: [] },
39
+ { engine: "brave", path: join(pf, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), channel: null, quirks: BRAVE_QUIRKS },
40
+ { engine: "brave", path: join(pf86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), channel: null, quirks: BRAVE_QUIRKS },
41
+ { engine: "brave", path: join(local, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), channel: null, quirks: BRAVE_QUIRKS },
42
+ ];
43
+ }
44
+ function macCandidates() {
45
+ const userApps = join(os.homedir(), "Applications");
46
+ return [
47
+ // System-wide installs (/Applications) then per-user installs (~/Applications)
48
+ // for every engine, not just Brave: a user can install any of the three
49
+ // into their own Applications folder without admin rights.
50
+ { engine: "chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", channel: "chrome", quirks: [] },
51
+ { engine: "chrome", path: join(userApps, "Google Chrome.app", "Contents", "MacOS", "Google Chrome"), channel: "chrome", quirks: [] },
52
+ { engine: "edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", channel: "msedge", quirks: [] },
53
+ { engine: "edge", path: join(userApps, "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge"), channel: "msedge", quirks: [] },
54
+ { engine: "brave", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", channel: null, quirks: BRAVE_QUIRKS },
55
+ { engine: "brave", path: join(userApps, "Brave Browser.app", "Contents", "MacOS", "Brave Browser"), channel: null, quirks: BRAVE_QUIRKS },
56
+ ];
57
+ }
58
+ function linuxCandidates() {
59
+ const bins = [
60
+ ["chrome", "google-chrome", "chrome", []],
61
+ ["chrome", "google-chrome-stable", "chrome", []],
62
+ ["edge", "microsoft-edge", "msedge", []],
63
+ ["edge", "microsoft-edge-stable", "msedge", []],
64
+ ["brave", "brave", null, BRAVE_QUIRKS],
65
+ ["brave", "brave-browser", null, BRAVE_QUIRKS],
66
+ ];
67
+ const dirs = ["/usr/bin", "/usr/local/bin", "/opt/brave.com/brave", "/snap/bin"];
68
+ const out = [];
69
+ for (const [engine, bin, channel, quirks] of bins) {
70
+ for (const d of dirs)
71
+ out.push({ engine, path: join(d, bin), channel, quirks });
72
+ }
73
+ return out;
74
+ }
75
+ // ---------------------------------------------------------------------
76
+ // Version detection
77
+ // ---------------------------------------------------------------------
78
+ /** Windows: read the version-named subdirectory next to the executable
79
+ * (e.g. Application/139.0.7258.67/).
80
+ * macOS: try reading CFBundleShortVersionString from the bundle's
81
+ * Info.plist (reliable, no subprocess), then fall back to
82
+ * running the binary with --version if the plist is absent or
83
+ * malformed. Both paths avoid spawning a headed browser window.
84
+ * Linux: `<exe> --version` prints "Google Chrome 139.0...".
85
+ *
86
+ * On all platforms a best-effort empty string is returned rather than
87
+ * throwing, because a missing version must not block engine discovery. */
88
+ async function detectVersion(exePath) {
89
+ if (process.platform === "win32") {
90
+ try {
91
+ const appDir = exePath.replace(/[\\/][^\\/]+$/, "");
92
+ const versionDirs = readdirSync(appDir)
93
+ .filter((n) => /^\d+\.\d+\.\d+\.\d+$/.test(n))
94
+ .sort((a, b) => compareVersions(b, a));
95
+ return versionDirs[0] || "";
96
+ }
97
+ catch {
98
+ return "";
99
+ }
100
+ }
101
+ if (process.platform === "darwin") {
102
+ // Primary: read Info.plist inside the .app bundle. The executable is at
103
+ // <Bundle>.app/Contents/MacOS/<Binary>; the plist is at
104
+ // <Bundle>.app/Contents/Info.plist. No subprocess needed.
105
+ try {
106
+ // Walk up from the binary to the .app Contents/ directory.
107
+ // exePath = /Applications/Brave Browser.app/Contents/MacOS/Brave Browser
108
+ // plistPath = /Applications/Brave Browser.app/Contents/Info.plist
109
+ const contentsDir = join(exePath, "..", ".."); // MacOS/../.. = Contents/
110
+ const plistPath = join(contentsDir, "Info.plist");
111
+ const { readFileSync: _readFileSync } = await import("node:fs");
112
+ const plist = _readFileSync(plistPath, "utf-8");
113
+ // Scan for the key/string pair in the XML plist without a full parser.
114
+ const keyIdx = plist.indexOf("<key>CFBundleShortVersionString</key>");
115
+ if (keyIdx !== -1) {
116
+ const after = plist.slice(keyIdx + "<key>CFBundleShortVersionString</key>".length);
117
+ const m = after.match(/<string>([^<]+)<\/string>/);
118
+ if (m) {
119
+ const v = m[1].trim();
120
+ if (/^\d+/.test(v))
121
+ return v;
122
+ }
123
+ }
124
+ }
125
+ catch {
126
+ // Fall through to --version subprocess below.
127
+ }
128
+ // Fallback: run the binary with --version. Some Chromium builds print
129
+ // "Chromium 139.0.7258.67" or "Google Chrome 139.0.7258.67" to stdout.
130
+ return new Promise((resolve) => {
131
+ execFile(exePath, ["--version"], { timeout: 5000 }, (err, stdout) => {
132
+ if (err)
133
+ return resolve("");
134
+ const m = String(stdout || "").match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
135
+ resolve(m ? m[1] : "");
136
+ });
137
+ });
138
+ }
139
+ // Linux (and any other POSIX platform).
140
+ return new Promise((resolve) => {
141
+ execFile(exePath, ["--version"], { timeout: 5000 }, (err, stdout) => {
142
+ if (err)
143
+ return resolve("");
144
+ const m = String(stdout || "").match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
145
+ resolve(m ? m[1] : "");
146
+ });
147
+ });
148
+ }
149
+ function compareVersions(a, b) {
150
+ const pa = a.split(".").map(Number);
151
+ const pb = b.split(".").map(Number);
152
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
153
+ const d = (pa[i] || 0) - (pb[i] || 0);
154
+ if (d !== 0)
155
+ return d;
156
+ }
157
+ return 0;
158
+ }
159
+ // ---------------------------------------------------------------------
160
+ // Discovery (cached; a runner's install set changes rarely)
161
+ // ---------------------------------------------------------------------
162
+ let _discoveryCache = null;
163
+ export function discoverEngines(forceRefresh = false) {
164
+ if (_discoveryCache && !forceRefresh)
165
+ return _discoveryCache;
166
+ _discoveryCache = (async () => {
167
+ const candidates = process.platform === "win32" ? windowsCandidates()
168
+ : process.platform === "darwin" ? macCandidates()
169
+ : linuxCandidates();
170
+ const found = new Map();
171
+ for (const c of candidates) {
172
+ if (found.has(c.engine))
173
+ continue; // first hit per engine wins
174
+ if (!existsSync(c.path))
175
+ continue;
176
+ const version = await detectVersion(c.path);
177
+ found.set(c.engine, {
178
+ engine: c.engine,
179
+ executablePath: c.path,
180
+ version,
181
+ channel: c.channel,
182
+ quirks: c.quirks,
183
+ });
184
+ }
185
+ // Playwright-bundled Chromium, when already provisioned on disk.
186
+ try {
187
+ const playwright = await import("playwright");
188
+ const p = playwright.chromium.executablePath();
189
+ if (p && existsSync(p)) {
190
+ found.set("chromium", {
191
+ engine: "chromium",
192
+ executablePath: p,
193
+ version: "",
194
+ channel: null,
195
+ quirks: [],
196
+ });
197
+ }
198
+ }
199
+ catch { /* playwright unavailable -> no bundled engine */ }
200
+ return [...found.values()];
201
+ })();
202
+ return _discoveryCache;
203
+ }
204
+ export async function capabilityReport() {
205
+ return {
206
+ engines: await discoverEngines(),
207
+ cdp: true,
208
+ provisioner: "v1",
209
+ platform: process.platform,
210
+ };
211
+ }
212
+ // ---------------------------------------------------------------------
213
+ // Provisioning
214
+ // ---------------------------------------------------------------------
215
+ export class ProvisionError extends Error {
216
+ code;
217
+ instructions;
218
+ constructor(code, message, instructions) {
219
+ super(message);
220
+ this.name = "ProvisionError";
221
+ this.code = code;
222
+ this.instructions = instructions;
223
+ }
224
+ }
225
+ /** Resolve an engine to a launchable executable. NEVER downloads unless
226
+ * allowDownload is explicitly true, and even then only the
227
+ * Playwright-bundled Chromium is downloadable (Chrome/Edge/Brave are
228
+ * user-installed products we will not fetch on the user's behalf). */
229
+ export async function ensureEngine(engine, opts = {}) {
230
+ const engines = await discoverEngines();
231
+ const hit = engines.find((e) => e.engine === engine);
232
+ if (hit)
233
+ return hit;
234
+ if (engine !== "chromium") {
235
+ const urls = {
236
+ chrome: "https://www.google.com/chrome/",
237
+ edge: "https://www.microsoft.com/edge",
238
+ brave: "https://brave.com/download/",
239
+ };
240
+ throw new ProvisionError("engine_not_installed", `${engine} is not installed on this machine`, `Install ${engine} from ${urls[engine]} and reconnect the runner.`);
241
+ }
242
+ if (!opts.allowDownload) {
243
+ throw new ProvisionError("download_not_authorized", "bundled Chromium is not on disk and download was not authorized", "Re-run with the download flag enabled, or run: npx playwright install chromium");
244
+ }
245
+ // Explicitly authorized: install the bundled Chromium via Playwright's
246
+ // own installer (respects HTTPS_PROXY, resumable, disk-space checked
247
+ // by the installer itself).
248
+ opts.log?.("Downloading Playwright Chromium (explicitly authorized)...");
249
+ await new Promise((resolve, reject) => {
250
+ const child = execFile(process.execPath, [join("node_modules", "playwright", "cli.js"), "install", "chromium"], { cwd: packageRootGuess(), timeout: 10 * 60 * 1000 }, (err) => (err ? reject(err) : resolve()));
251
+ child.stdout?.on("data", (d) => opts.log?.(String(d).trim()));
252
+ }).catch((e) => {
253
+ throw new ProvisionError("download_failed", `Chromium download failed: ${e?.message || e}`);
254
+ });
255
+ const refreshed = await discoverEngines(true);
256
+ const post = refreshed.find((e) => e.engine === "chromium");
257
+ if (!post)
258
+ throw new ProvisionError("download_failed", "Chromium installed but executable not found");
259
+ return post;
260
+ }
261
+ function packageRootGuess() {
262
+ // dist/ layout: this file compiles to dist/browserProvisioner.js; the
263
+ // package root (with node_modules/playwright) is one level up. In the
264
+ // dev tree it is two up from src/, which the fallback covers.
265
+ return join(new URL(".", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"), "..");
266
+ }
@@ -0,0 +1,49 @@
1
+ /** Facade the BRIDGE provides: executes one governed op. The bridge
2
+ * performs authz + tracing; the worker only ever sees op names. */
3
+ export type GovernedFacade = (op: string, args: Record<string, unknown>) => Promise<unknown>;
4
+ export interface CodeWorkerLimits {
5
+ wallClockMs: number;
6
+ maxOps: number;
7
+ maxArgBytes: number;
8
+ maxConsoleLines: number;
9
+ maxOutputBytes: number;
10
+ maxScreenshots: number;
11
+ maxNavigations: number;
12
+ /** Heap cap (MB) passed as --max-old-space-size. Also controls the
13
+ * OS-level rlimit on Linux (bwrap --rlimit) and macOS (ulimit -v).
14
+ * Default 256 MB; hard cap 512 MB to prevent OOM on the host. */
15
+ maxMemoryMb: number;
16
+ /** CPU time limit in seconds for the macOS ulimit -t wrapper.
17
+ * Ignored on other platforms (wall-clock timer is the backstop).
18
+ * Default 60 (matches wallClockMs default). */
19
+ maxCpuSeconds: number;
20
+ }
21
+ export declare const DEFAULT_LIMITS: CodeWorkerLimits;
22
+ export interface SubActionTrace {
23
+ seq: number;
24
+ op: string;
25
+ ok: boolean;
26
+ ms: number;
27
+ error?: string;
28
+ }
29
+ export interface CodeRunResult {
30
+ ok: boolean;
31
+ result?: unknown;
32
+ console: string[];
33
+ traces: SubActionTrace[];
34
+ error?: {
35
+ code: string;
36
+ message: string;
37
+ };
38
+ /** Which OS sandbox actually wrapped the child. */
39
+ sandbox: "bubblewrap" | "seatbelt" | "node-permission-only" | "none";
40
+ killVerified: boolean;
41
+ }
42
+ export declare function runSandboxedScript(opts: {
43
+ script: string;
44
+ scratchDir: string;
45
+ facade: GovernedFacade;
46
+ limits?: Partial<CodeWorkerLimits>;
47
+ onTrace?: (t: SubActionTrace) => void;
48
+ log?: (m: string) => void;
49
+ }): Promise<CodeRunResult>;