@melaya/runner 1.1.42 → 1.1.45

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,393 @@
1
+ // File-source resolution for the runner bridge's `upload_file` act.
2
+ //
3
+ // The runner machine is the EXECUTING side, so it is the one that turns a file
4
+ // source into real bytes on disk before CDP's DOM.setFileInputFiles can attach
5
+ // them to a page's <input type=file>. Three sources:
6
+ //
7
+ // handle "fh_<hex>_<ext>" — minted by the server's staging flow
8
+ // (services/browser/browserUploadMedia.ts);
9
+ // fetched from GET <serverBase>/browser-media/
10
+ // <hex>.<ext> (public capability URL, short TTL).
11
+ // url (http/https) — downloaded directly by the runner. The URL was
12
+ // chosen by a model, and this code runs on the
13
+ // USER'S machine, so private/loopback/link-local
14
+ // addresses are blocked (a mirror of the server's
15
+ // safeFetch guard, self-contained here): the
16
+ // user's LAN must not be reachable through it.
17
+ // path (local file) — LOCAL-RUNNER-ONLY convenience for pipeline
18
+ // artifacts already on this machine. Never
19
+ // reachable from the cloud action schema (it has
20
+ // no path field); the loose local-bridge contract
21
+ // is the only door, and it is image-gated below.
22
+ //
23
+ // v1 is IMAGES ONLY (png/jpg/webp/gif), 10MB cap, magic-byte verified — the
24
+ // same allowlist the server's stager enforces, re-checked here because this
25
+ // path can bypass the server entirely (direct url / local path).
26
+ import { lookup as dnsLookup } from "node:dns";
27
+ import * as http from "node:http";
28
+ import * as https from "node:https";
29
+ import { randomBytes } from "node:crypto";
30
+ import { writeFile, readFile, stat } from "node:fs/promises";
31
+ import { join } from "node:path";
32
+ import { tmpdir } from "node:os";
33
+ export const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
34
+ const CT_BY_EXT = {
35
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
36
+ gif: "image/gif",
37
+ };
38
+ export class UploadSourceError extends Error {
39
+ }
40
+ function extForContentType(ct) {
41
+ const base = (ct || "").split(";")[0].trim().toLowerCase();
42
+ switch (base) {
43
+ case "image/png": return "png";
44
+ case "image/jpeg":
45
+ case "image/jpg": return "jpg";
46
+ case "image/webp": return "webp";
47
+ case "image/gif": return "gif";
48
+ default: return null;
49
+ }
50
+ }
51
+ function extOfName(name) {
52
+ const m = /\.([a-z0-9]{2,4})(?:[?#]|$)/i.exec(name || "");
53
+ const ext = m ? m[1].toLowerCase() : "";
54
+ return ext in CT_BY_EXT ? ext : null;
55
+ }
56
+ function imageSniffOk(buf, ext) {
57
+ if (buf.length < 12)
58
+ return false;
59
+ if (ext === "png")
60
+ return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
61
+ if (ext === "jpg" || ext === "jpeg")
62
+ return buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
63
+ if (ext === "webp")
64
+ return buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP";
65
+ if (ext === "gif")
66
+ return buf.toString("ascii", 0, 3) === "GIF";
67
+ return false;
68
+ }
69
+ // ── Private-address guard (mirror of server safeFetch's block ranges) ────────
70
+ function ipv4ToInt(ip) {
71
+ const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
72
+ if (!m)
73
+ return null;
74
+ const o = m.slice(1).map(Number);
75
+ if (o.some((x) => x > 255))
76
+ return null;
77
+ return (((o[0] << 24) >>> 0) + (o[1] << 16) + (o[2] << 8) + o[3]) >>> 0;
78
+ }
79
+ function isBlockedV4(ip) {
80
+ const n = ipv4ToInt(ip);
81
+ if (n === null)
82
+ return true; // unparseable -> block (fail closed)
83
+ const inRange = (base, bits) => {
84
+ const b = ipv4ToInt(base);
85
+ if (b === null)
86
+ return false;
87
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
88
+ return (n & mask) === (b & mask);
89
+ };
90
+ return inRange("0.0.0.0", 8) || inRange("10.0.0.0", 8) || inRange("100.64.0.0", 10) ||
91
+ inRange("127.0.0.0", 8) || inRange("169.254.0.0", 16) || inRange("172.16.0.0", 12) ||
92
+ inRange("192.0.0.0", 24) || inRange("192.168.0.0", 16) || inRange("198.18.0.0", 15) ||
93
+ inRange("224.0.0.0", 4) || inRange("240.0.0.0", 4);
94
+ }
95
+ function parseV6(addr) {
96
+ let a = addr.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/%.*$/, "");
97
+ if (!a.includes(":"))
98
+ return null;
99
+ const dotted = a.match(/(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
100
+ if (dotted) {
101
+ const v4 = ipv4ToInt(dotted[2]);
102
+ if (v4 === null)
103
+ return null;
104
+ a = dotted[1] + (((v4 >>> 16) & 0xffff).toString(16)) + ":" + ((v4 & 0xffff).toString(16));
105
+ }
106
+ const halves = a.split("::");
107
+ if (halves.length > 2)
108
+ return null;
109
+ const head = halves[0] ? halves[0].split(":") : [];
110
+ const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null;
111
+ const toWords = (parts) => {
112
+ const out = [];
113
+ for (const p of parts) {
114
+ if (!/^[0-9a-f]{1,4}$/.test(p))
115
+ return null;
116
+ out.push(parseInt(p, 16));
117
+ }
118
+ return out;
119
+ };
120
+ const h = toWords(head);
121
+ if (h === null)
122
+ return null;
123
+ if (tail === null)
124
+ return h.length === 8 ? h : null;
125
+ const t = toWords(tail);
126
+ if (t === null)
127
+ return null;
128
+ const fill = 8 - h.length - t.length;
129
+ if (fill < 0)
130
+ return null;
131
+ return [...h, ...Array(fill).fill(0), ...t];
132
+ }
133
+ function isBlockedIp(addr) {
134
+ const a = addr.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/%.*$/, "");
135
+ if (!a.includes(":"))
136
+ return isBlockedV4(a);
137
+ const w = parseV6(a);
138
+ if (w === null)
139
+ return true;
140
+ if (w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0 && w[5] === 0xffff) {
141
+ const v4 = `${(w[6] >> 8) & 0xff}.${w[6] & 0xff}.${(w[7] >> 8) & 0xff}.${w[7] & 0xff}`;
142
+ return isBlockedV4(v4);
143
+ }
144
+ return (w[0] & 0xe000) !== 0x2000; // only global unicast 2000::/3
145
+ }
146
+ /** Validating + pinning lookup: the socket connects to exactly the vetted
147
+ * address, so there is no DNS-rebinding TOCTOU window. */
148
+ function validatingLookup(hostname, options, callback) {
149
+ const all = !!(options && typeof options === "object" && options.all);
150
+ dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
151
+ if (err || !addresses || (Array.isArray(addresses) && addresses.length === 0)) {
152
+ callback(Object.assign(new Error("upload_url_unresolvable"), { code: "ENOTFOUND" }), "");
153
+ return;
154
+ }
155
+ const recs = addresses;
156
+ for (const r of recs) {
157
+ if (isBlockedIp(r.address)) {
158
+ callback(Object.assign(new Error("upload_url_blocked_ip"), { code: "EACCES" }), "");
159
+ return;
160
+ }
161
+ }
162
+ if (all)
163
+ callback(null, recs);
164
+ else
165
+ callback(null, recs[0].address, recs[0].family);
166
+ });
167
+ }
168
+ /** One guarded GET, following up to 5 redirects (each hop re-validated). */
169
+ async function guardedGet(urlStr, opts) {
170
+ let current = urlStr;
171
+ for (let hop = 0; hop <= 5; hop++) {
172
+ let u;
173
+ try {
174
+ u = new URL(current);
175
+ }
176
+ catch {
177
+ throw new UploadSourceError(`invalid URL: ${current}`);
178
+ }
179
+ if (u.protocol !== "http:" && u.protocol !== "https:")
180
+ throw new UploadSourceError(`unsupported scheme '${u.protocol}' — only http(s) URLs can be uploaded`);
181
+ if (opts.guardPrivate && u.port && u.port !== "80" && u.port !== "443")
182
+ throw new UploadSourceError("non-standard ports are blocked for upload URLs");
183
+ if (opts.guardPrivate) {
184
+ const bare = u.hostname.replace(/^\[|\]$/g, "");
185
+ if ((ipv4ToInt(bare) !== null || bare.includes(":")) && isBlockedIp(bare)) {
186
+ throw new UploadSourceError("that URL points at a private/loopback address, which is blocked");
187
+ }
188
+ }
189
+ const r = await new Promise((resolve, reject) => {
190
+ const mod = u.protocol === "https:" ? https : http;
191
+ const req = mod.request(u, {
192
+ method: "GET",
193
+ ...(opts.guardPrivate ? { lookup: validatingLookup } : {}),
194
+ headers: { Accept: "image/*,*/*;q=0.5", "User-Agent": "MelayaRunner/1.0 (browser_upload)" },
195
+ }, (res) => {
196
+ const status = res.statusCode || 0;
197
+ const contentType = String(res.headers["content-type"] || "");
198
+ if (status >= 300 && status < 400 && res.headers.location) {
199
+ res.resume();
200
+ resolve({ status, contentType, location: String(res.headers.location), body: Buffer.alloc(0) });
201
+ return;
202
+ }
203
+ const chunks = [];
204
+ let received = 0;
205
+ res.on("data", (c) => {
206
+ received += c.length;
207
+ if (received > opts.maxBytes) {
208
+ req.destroy();
209
+ reject(new UploadSourceError(`the file exceeds the ${Math.round(opts.maxBytes / 1024 / 1024)}MB upload cap`));
210
+ return;
211
+ }
212
+ chunks.push(c);
213
+ });
214
+ res.on("end", () => resolve({ status, contentType, body: Buffer.concat(chunks) }));
215
+ res.on("error", (e) => reject(e));
216
+ });
217
+ req.on("error", (e) => reject(e));
218
+ req.setTimeout(opts.timeoutMs, () => { req.destroy(); reject(new UploadSourceError("timed out downloading the file")); });
219
+ req.end();
220
+ });
221
+ if (r.status >= 300 && r.status < 400 && r.location) {
222
+ current = new URL(r.location, current).toString();
223
+ continue;
224
+ }
225
+ if (r.status < 200 || r.status >= 300)
226
+ throw new UploadSourceError(`download failed with HTTP ${r.status}`);
227
+ return { body: r.body, contentType: r.contentType, finalUrl: current };
228
+ }
229
+ throw new UploadSourceError("too many redirects");
230
+ }
231
+ function validateImage(buf, extHint, label) {
232
+ if (!buf.length)
233
+ throw new UploadSourceError(`${label} is empty`);
234
+ if (buf.length > UPLOAD_MAX_BYTES)
235
+ throw new UploadSourceError(`${label} exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
236
+ // Sniff first, trust headers/extensions second: the bytes are the authority.
237
+ for (const ext of extHint ? [extHint] : []) {
238
+ if (imageSniffOk(buf, ext))
239
+ return ext;
240
+ }
241
+ for (const ext of Object.keys(CT_BY_EXT)) {
242
+ if (imageSniffOk(buf, ext))
243
+ return ext;
244
+ }
245
+ throw new UploadSourceError(`${label} is not a supported image — v1 uploads accept png, jpg, webp or gif only`);
246
+ }
247
+ async function toTemp(buf, ext) {
248
+ const name = `melaya-upload-${randomBytes(6).toString("hex")}.${ext}`;
249
+ const p = join(tmpdir(), name);
250
+ await writeFile(p, buf, { mode: 0o600 });
251
+ return { path: p, name: `upload.${ext}`, bytes: buf.length, temp: true };
252
+ }
253
+ /** Loose act args (flat width/height/fit/format from browser.py, or a strict
254
+ * `resize` object) → a normalized UploadResize, or null when none asked. */
255
+ export function parseUploadResize(args) {
256
+ const src = (args.resize && typeof args.resize === "object" ? args.resize : args);
257
+ const w = Math.trunc(Number(src.width));
258
+ const h = Math.trunc(Number(src.height));
259
+ const fit = String(src.fit ?? "").trim().toLowerCase();
260
+ const format = String(src.format ?? "").trim().toLowerCase().replace(/^jpg$/, "jpeg");
261
+ const out = {
262
+ ...(Number.isFinite(w) && w >= 1 && w <= 10_000 ? { width: w } : {}),
263
+ ...(Number.isFinite(h) && h >= 1 && h <= 10_000 ? { height: h } : {}),
264
+ ...(fit === "contain" || fit === "cover" ? { fit: fit } : {}),
265
+ ...(format === "png" || format === "jpeg" || format === "webp" ? { format: format } : {}),
266
+ };
267
+ return out.width || out.height || out.format ? out : null;
268
+ }
269
+ const MIME_BY_EXT = {
270
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
271
+ };
272
+ /** Apply a resize/convert to a resolved file by running a canvas job through
273
+ * the supplied JS evaluator (an async-IIFE expression whose awaited value is
274
+ * the transformed image as base64). Returns a NEW temp file; throws
275
+ * UploadSourceError with an actionable message when the page cannot do it. */
276
+ export async function applyUploadResize(file, resize, evaluate) {
277
+ const srcExt = (file.path.split(".").pop() || "png").toLowerCase();
278
+ const srcMime = MIME_BY_EXT[srcExt] || "image/png";
279
+ const outExt = resize.format === "jpeg" ? "jpg" : (resize.format || (srcExt === "gif" ? "png" : srcExt));
280
+ const outMime = MIME_BY_EXT[outExt] || srcMime;
281
+ const raw = await readFile(file.path);
282
+ const w = resize.width || 0;
283
+ const h = resize.height || 0;
284
+ const fit = resize.fit === "cover" ? "cover" : "contain";
285
+ // Everything interpolated below is numeric or JSON-quoted — nothing from a
286
+ // page, nothing free-form; the base64 alphabet cannot break the string out.
287
+ const expression = `(async function(){
288
+ var bin = atob(${JSON.stringify(raw.toString("base64"))});
289
+ var bytes = new Uint8Array(bin.length);
290
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
291
+ var bmp = await createImageBitmap(new Blob([bytes], { type: ${JSON.stringify(srcMime)} }));
292
+ var sw = bmp.width, sh = bmp.height;
293
+ var w = ${w}, h = ${h};
294
+ var W, H, dx, dy, dw, dh;
295
+ if (w && h) {
296
+ W = w; H = h;
297
+ var s = ${JSON.stringify(fit)} === "cover" ? Math.max(w / sw, h / sh) : Math.min(w / sw, h / sh);
298
+ dw = sw * s; dh = sh * s; dx = (W - dw) / 2; dy = (H - dh) / 2;
299
+ } else if (w) { W = w; H = Math.max(1, Math.round(sh * w / sw)); dx = 0; dy = 0; dw = W; dh = H; }
300
+ else if (h) { H = h; W = Math.max(1, Math.round(sw * h / sh)); dx = 0; dy = 0; dw = W; dh = H; }
301
+ else { W = sw; H = sh; dx = 0; dy = 0; dw = sw; dh = sh; }
302
+ var canvas = new OffscreenCanvas(W, H);
303
+ var g = canvas.getContext("2d");
304
+ if (${JSON.stringify(outMime)} === "image/jpeg") { g.fillStyle = "#fff"; g.fillRect(0, 0, W, H); }
305
+ g.imageSmoothingQuality = "high";
306
+ g.drawImage(bmp, dx, dy, dw, dh);
307
+ var blob = await canvas.convertToBlob({ type: ${JSON.stringify(outMime)}, quality: 0.92 });
308
+ var out = new Uint8Array(await blob.arrayBuffer());
309
+ var s2 = ""; var CH = 32768;
310
+ for (var j = 0; j < out.length; j += CH) s2 += String.fromCharCode.apply(null, Array.prototype.slice.call(out.subarray(j, j + CH)));
311
+ return btoa(s2);
312
+ })()`;
313
+ let outB64;
314
+ try {
315
+ outB64 = await evaluate(expression);
316
+ }
317
+ catch (e) {
318
+ throw new UploadSourceError(`could not convert the image in the browser (${e.message}). ` +
319
+ "Retry without width/height/format, or convert the file before uploading.");
320
+ }
321
+ if (typeof outB64 !== "string" || !outB64) {
322
+ throw new UploadSourceError("the in-browser image conversion returned nothing — retry without width/height/format, or convert the file before uploading.");
323
+ }
324
+ const buf = Buffer.from(outB64, "base64");
325
+ if (!buf.length)
326
+ throw new UploadSourceError("the in-browser image conversion produced an empty file.");
327
+ if (buf.length > UPLOAD_MAX_BYTES)
328
+ throw new UploadSourceError(`the converted image exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap.`);
329
+ return toTemp(buf, outExt);
330
+ }
331
+ /** Staged-handle source: fetch <serverBase>/browser-media/<hex>.<ext>. */
332
+ export async function resolveHandleSource(serverBase, handle) {
333
+ const m = /^fh_([a-f0-9]{32})_([a-z0-9]{2,4})$/.exec((handle || "").trim());
334
+ if (!m || !(m[2].toLowerCase() in CT_BY_EXT)) {
335
+ throw new UploadSourceError(`'${String(handle).slice(0, 60)}' is not a staged file handle (expected "fh_<hex>_<ext>"). ` +
336
+ "Use browser_upload with a public image `url` — it stages the file and mints the handle for you.");
337
+ }
338
+ const base = (serverBase || "").replace(/\/+$/, "");
339
+ if (!base)
340
+ throw new UploadSourceError("this run has no server base configured, so a staged handle cannot be resolved — pass a public image `url` instead");
341
+ const { body } = await guardedGet(`${base}/browser-media/${m[1]}.${m[2].toLowerCase()}`, {
342
+ maxBytes: UPLOAD_MAX_BYTES, timeoutMs: 30_000, guardPrivate: false, // the user's own server base may be private in dev
343
+ });
344
+ const ext = validateImage(body, m[2].toLowerCase(), "the staged file");
345
+ return toTemp(body, ext);
346
+ }
347
+ /** URL source: guarded direct download from the runner machine. */
348
+ export async function resolveUrlSource(url) {
349
+ const { body, contentType, finalUrl } = await guardedGet(url, {
350
+ maxBytes: UPLOAD_MAX_BYTES, timeoutMs: 30_000, guardPrivate: true,
351
+ });
352
+ const hint = extForContentType(contentType) ?? extOfName(new URL(finalUrl).pathname);
353
+ const ext = validateImage(body, hint, `the file at ${url}`);
354
+ return toTemp(body, ext);
355
+ }
356
+ /** Base64 data-URL source: decoded locally (no network). Same image-only
357
+ * allowlist, sniff and cap. NOTE the local bridge's request body cap bounds
358
+ * how much base64 can arrive this way; large files should come as a staged
359
+ * handle or a url. */
360
+ export async function resolveDataUrlSource(dataUrl) {
361
+ const m = /^data:([a-z0-9.+/-]+)(?:;[a-z0-9=.-]+)*;base64,([A-Za-z0-9+/=\s]+)$/i.exec((dataUrl || "").trim());
362
+ if (!m)
363
+ throw new UploadSourceError('expected a base64 data URL like "data:image/png;base64,..."');
364
+ const b64 = m[2].replace(/\s+/g, "");
365
+ if (b64.length > Math.ceil((UPLOAD_MAX_BYTES * 4) / 3) + 8) {
366
+ throw new UploadSourceError(`the file exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
367
+ }
368
+ const buf = Buffer.from(b64, "base64");
369
+ const ext = validateImage(buf, extForContentType(m[1]), "the data URL payload");
370
+ return toTemp(buf, ext);
371
+ }
372
+ /** Local-path source (LOCAL RUNNER ONLY): an image file already on this
373
+ * machine. Image-gated by magic bytes, so this cannot be used to lift an
374
+ * arbitrary local file (keys, configs, code) onto a website. */
375
+ export async function resolvePathSource(path) {
376
+ const p = (path || "").trim();
377
+ if (!p)
378
+ throw new UploadSourceError("empty path");
379
+ let size;
380
+ try {
381
+ size = (await stat(p)).size;
382
+ }
383
+ catch {
384
+ throw new UploadSourceError(`no file at '${p}' on the runner machine`);
385
+ }
386
+ if (size > UPLOAD_MAX_BYTES)
387
+ throw new UploadSourceError(`the file exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
388
+ const buf = await readFile(p);
389
+ const ext = validateImage(buf, extOfName(p), `the file at ${p}`);
390
+ // The file already exists locally with the right bytes — attach it in place
391
+ // (no temp copy) so nothing else has to be cleaned up.
392
+ return { path: p, name: `upload.${ext}`, bytes: buf.length, temp: false };
393
+ }