@gguf/claw
Version:
WhatsApp gateway CLI (Baileys web) with Pi RPC agent
1,568 lines (1,556 loc) • 78.9 kB
JavaScript
import { a as parseBooleanValue } from "./entry.js";
import { d as resolveConfigDir } from "./utils-DX85MiPR.js";
import { n as runExec } from "./exec-B8JKbXKW.js";
import { c as writeConfigFile, i as loadConfig } from "./config-CKLedg5Y.js";
import { c as allocateColor, d as isValidProfileName, f as deriveDefaultBrowserCdpPortRange, i as parseHttpUrl, l as getUsedColors, n as movePathToTrash, o as resolveProfile, r as getPwAiModule$1, s as allocateCdpPort, u as getUsedPorts } from "./server-context-yKyxyxOJ.js";
import { D as DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, E as DEFAULT_AI_SNAPSHOT_MAX_CHARS, T as DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, c as resolveOpenClawUserDataDir, h as captureScreenshot, u as resolveBrowserExecutableForPlatform, w as DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, y as snapshotAria } from "./errors-CZ9opC6L.js";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import fs$1 from "node:fs/promises";
import crypto from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import { lookup } from "node:dns";
import { lookup as lookup$1 } from "node:dns/promises";
import { Agent } from "undici";
import { pipeline } from "node:stream/promises";
//#region src/media/constants.ts
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
const MAX_AUDIO_BYTES = 16 * 1024 * 1024;
const MAX_VIDEO_BYTES = 16 * 1024 * 1024;
const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024;
function mediaKindFromMime(mime) {
if (!mime) return "unknown";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "document";
if (mime.startsWith("application/")) return "document";
return "unknown";
}
function maxBytesForKind(kind) {
switch (kind) {
case "image": return MAX_IMAGE_BYTES;
case "audio": return MAX_AUDIO_BYTES;
case "video": return MAX_VIDEO_BYTES;
case "document": return MAX_DOCUMENT_BYTES;
default: return MAX_DOCUMENT_BYTES;
}
}
//#endregion
//#region src/media/mime.ts
const EXT_BY_MIME = {
"image/heic": ".heic",
"image/heif": ".heif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
"audio/x-m4a": ".m4a",
"audio/mp4": ".m4a",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"application/pdf": ".pdf",
"application/json": ".json",
"application/zip": ".zip",
"application/gzip": ".gz",
"application/x-tar": ".tar",
"application/x-7z-compressed": ".7z",
"application/vnd.rar": ".rar",
"application/msword": ".doc",
"application/vnd.ms-excel": ".xls",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"text/csv": ".csv",
"text/plain": ".txt",
"text/markdown": ".md"
};
const MIME_BY_EXT = {
...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
".jpeg": "image/jpeg"
};
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".opus",
".wav"
]);
function normalizeHeaderMime(mime) {
if (!mime) return;
return mime.split(";")[0]?.trim().toLowerCase() || void 0;
}
async function sniffMime(buffer) {
if (!buffer) return;
try {
return (await fileTypeFromBuffer(buffer))?.mime ?? void 0;
} catch {
return;
}
}
function getFileExtension(filePath) {
if (!filePath) return;
try {
if (/^https?:\/\//i.test(filePath)) {
const url = new URL(filePath);
return path.extname(url.pathname).toLowerCase() || void 0;
}
} catch {}
return path.extname(filePath).toLowerCase() || void 0;
}
function isAudioFileName(fileName) {
const ext = getFileExtension(fileName);
if (!ext) return false;
return AUDIO_FILE_EXTENSIONS.has(ext);
}
function detectMime(opts) {
return detectMimeImpl(opts);
}
function isGenericMime(mime) {
if (!mime) return true;
const m = mime.toLowerCase();
return m === "application/octet-stream" || m === "application/zip";
}
async function detectMimeImpl(opts) {
const ext = getFileExtension(opts.filePath);
const extMime = ext ? MIME_BY_EXT[ext] : void 0;
const headerMime = normalizeHeaderMime(opts.headerMime);
const sniffed = await sniffMime(opts.buffer);
if (sniffed && (!isGenericMime(sniffed) || !extMime)) return sniffed;
if (extMime) return extMime;
if (headerMime && !isGenericMime(headerMime)) return headerMime;
if (sniffed) return sniffed;
if (headerMime) return headerMime;
}
function extensionForMime(mime) {
if (!mime) return;
return EXT_BY_MIME[mime.toLowerCase()];
}
function isGifMedia(opts) {
if (opts.contentType?.toLowerCase() === "image/gif") return true;
return getFileExtension(opts.fileName) === ".gif";
}
function imageMimeFromFormat(format) {
if (!format) return;
switch (format.toLowerCase()) {
case "jpg":
case "jpeg": return "image/jpeg";
case "heic": return "image/heic";
case "heif": return "image/heif";
case "png": return "image/png";
case "webp": return "image/webp";
case "gif": return "image/gif";
default: return;
}
}
function kindFromMime(mime) {
return mediaKindFromMime(mime);
}
//#endregion
//#region src/media/image-ops.ts
function isBun() {
return typeof process.versions.bun === "string";
}
function prefersSips() {
return process.env.OPENCLAW_IMAGE_BACKEND === "sips" || process.env.OPENCLAW_IMAGE_BACKEND !== "sharp" && isBun() && process.platform === "darwin";
}
async function loadSharp() {
const mod = await import("sharp");
const sharp = mod.default ?? mod;
return (buffer) => sharp(buffer, { failOnError: false });
}
/**
* Reads EXIF orientation from JPEG buffer.
* Returns orientation value 1-8, or null if not found/not JPEG.
*
* EXIF orientation values:
* 1 = Normal, 2 = Flip H, 3 = Rotate 180, 4 = Flip V,
* 5 = Rotate 270 CW + Flip H, 6 = Rotate 90 CW, 7 = Rotate 90 CW + Flip H, 8 = Rotate 270 CW
*/
function readJpegExifOrientation(buffer) {
if (buffer.length < 2 || buffer[0] !== 255 || buffer[1] !== 216) return null;
let offset = 2;
while (offset < buffer.length - 4) {
if (buffer[offset] !== 255) {
offset++;
continue;
}
const marker = buffer[offset + 1];
if (marker === 255) {
offset++;
continue;
}
if (marker === 225) {
const exifStart = offset + 4;
if (buffer.length > exifStart + 6 && buffer.toString("ascii", exifStart, exifStart + 4) === "Exif" && buffer[exifStart + 4] === 0 && buffer[exifStart + 5] === 0) {
const tiffStart = exifStart + 6;
if (buffer.length < tiffStart + 8) return null;
const isLittleEndian = buffer.toString("ascii", tiffStart, tiffStart + 2) === "II";
const readU16 = (pos) => isLittleEndian ? buffer.readUInt16LE(pos) : buffer.readUInt16BE(pos);
const readU32 = (pos) => isLittleEndian ? buffer.readUInt32LE(pos) : buffer.readUInt32BE(pos);
const ifd0Start = tiffStart + readU32(tiffStart + 4);
if (buffer.length < ifd0Start + 2) return null;
const numEntries = readU16(ifd0Start);
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifd0Start + 2 + i * 12;
if (buffer.length < entryOffset + 12) break;
if (readU16(entryOffset) === 274) {
const value = readU16(entryOffset + 8);
return value >= 1 && value <= 8 ? value : null;
}
}
}
return null;
}
if (marker >= 224 && marker <= 239) {
const segmentLength = buffer.readUInt16BE(offset + 2);
offset += 2 + segmentLength;
continue;
}
if (marker === 192 || marker === 218) break;
offset++;
}
return null;
}
async function withTempDir(fn) {
const dir = await fs$1.mkdtemp(path.join(os.tmpdir(), "openclaw-img-"));
try {
return await fn(dir);
} finally {
await fs$1.rm(dir, {
recursive: true,
force: true
}).catch(() => {});
}
}
async function sipsMetadataFromBuffer(buffer) {
return await withTempDir(async (dir) => {
const input = path.join(dir, "in.img");
await fs$1.writeFile(input, buffer);
const { stdout } = await runExec("/usr/bin/sips", [
"-g",
"pixelWidth",
"-g",
"pixelHeight",
input
], {
timeoutMs: 1e4,
maxBuffer: 512 * 1024
});
const w = stdout.match(/pixelWidth:\s*([0-9]+)/);
const h = stdout.match(/pixelHeight:\s*([0-9]+)/);
if (!w?.[1] || !h?.[1]) return null;
const width = Number.parseInt(w[1], 10);
const height = Number.parseInt(h[1], 10);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
if (width <= 0 || height <= 0) return null;
return {
width,
height
};
});
}
async function sipsResizeToJpeg(params) {
return await withTempDir(async (dir) => {
const input = path.join(dir, "in.img");
const output = path.join(dir, "out.jpg");
await fs$1.writeFile(input, params.buffer);
await runExec("/usr/bin/sips", [
"-Z",
String(Math.max(1, Math.round(params.maxSide))),
"-s",
"format",
"jpeg",
"-s",
"formatOptions",
String(Math.max(1, Math.min(100, Math.round(params.quality)))),
input,
"--out",
output
], {
timeoutMs: 2e4,
maxBuffer: 1024 * 1024
});
return await fs$1.readFile(output);
});
}
async function sipsConvertToJpeg(buffer) {
return await withTempDir(async (dir) => {
const input = path.join(dir, "in.heic");
const output = path.join(dir, "out.jpg");
await fs$1.writeFile(input, buffer);
await runExec("/usr/bin/sips", [
"-s",
"format",
"jpeg",
input,
"--out",
output
], {
timeoutMs: 2e4,
maxBuffer: 1024 * 1024
});
return await fs$1.readFile(output);
});
}
async function getImageMetadata(buffer) {
if (prefersSips()) return await sipsMetadataFromBuffer(buffer).catch(() => null);
try {
const meta = await (await loadSharp())(buffer).metadata();
const width = Number(meta.width ?? 0);
const height = Number(meta.height ?? 0);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
if (width <= 0 || height <= 0) return null;
return {
width,
height
};
} catch {
return null;
}
}
/**
* Applies rotation/flip to image buffer using sips based on EXIF orientation.
*/
async function sipsApplyOrientation(buffer, orientation) {
const ops = [];
switch (orientation) {
case 2:
ops.push("-f", "horizontal");
break;
case 3:
ops.push("-r", "180");
break;
case 4:
ops.push("-f", "vertical");
break;
case 5:
ops.push("-r", "270", "-f", "horizontal");
break;
case 6:
ops.push("-r", "90");
break;
case 7:
ops.push("-r", "90", "-f", "horizontal");
break;
case 8:
ops.push("-r", "270");
break;
default: return buffer;
}
return await withTempDir(async (dir) => {
const input = path.join(dir, "in.jpg");
const output = path.join(dir, "out.jpg");
await fs$1.writeFile(input, buffer);
await runExec("/usr/bin/sips", [
...ops,
input,
"--out",
output
], {
timeoutMs: 2e4,
maxBuffer: 1024 * 1024
});
return await fs$1.readFile(output);
});
}
async function resizeToJpeg(params) {
if (prefersSips()) {
const normalized = await normalizeExifOrientationSips(params.buffer);
if (params.withoutEnlargement !== false) {
const meta = await getImageMetadata(normalized);
if (meta) {
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > 0 && maxDim <= params.maxSide) return await sipsResizeToJpeg({
buffer: normalized,
maxSide: maxDim,
quality: params.quality
});
}
}
return await sipsResizeToJpeg({
buffer: normalized,
maxSide: params.maxSide,
quality: params.quality
});
}
return await (await loadSharp())(params.buffer).rotate().resize({
width: params.maxSide,
height: params.maxSide,
fit: "inside",
withoutEnlargement: params.withoutEnlargement !== false
}).jpeg({
quality: params.quality,
mozjpeg: true
}).toBuffer();
}
async function convertHeicToJpeg(buffer) {
if (prefersSips()) return await sipsConvertToJpeg(buffer);
return await (await loadSharp())(buffer).jpeg({
quality: 90,
mozjpeg: true
}).toBuffer();
}
/**
* Checks if an image has an alpha channel (transparency).
* Returns true if the image has alpha, false otherwise.
*/
async function hasAlphaChannel(buffer) {
try {
const meta = await (await loadSharp())(buffer).metadata();
return meta.hasAlpha || meta.channels === 4;
} catch {
return false;
}
}
/**
* Resizes an image to PNG format, preserving alpha channel (transparency).
* Falls back to sharp only (no sips fallback for PNG with alpha).
*/
async function resizeToPng(params) {
const sharp = await loadSharp();
const compressionLevel = params.compressionLevel ?? 6;
return await sharp(params.buffer).rotate().resize({
width: params.maxSide,
height: params.maxSide,
fit: "inside",
withoutEnlargement: params.withoutEnlargement !== false
}).png({ compressionLevel }).toBuffer();
}
async function optimizeImageToPng(buffer, maxBytes) {
const sides = [
2048,
1536,
1280,
1024,
800
];
const compressionLevels = [
6,
7,
8,
9
];
let smallest = null;
for (const side of sides) for (const compressionLevel of compressionLevels) try {
const out = await resizeToPng({
buffer,
maxSide: side,
compressionLevel,
withoutEnlargement: true
});
const size = out.length;
if (!smallest || size < smallest.size) smallest = {
buffer: out,
size,
resizeSide: side,
compressionLevel
};
if (size <= maxBytes) return {
buffer: out,
optimizedSize: size,
resizeSide: side,
compressionLevel
};
} catch {}
if (smallest) return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
compressionLevel: smallest.compressionLevel
};
throw new Error("Failed to optimize PNG image");
}
/**
* Internal sips-only EXIF normalization (no sharp fallback).
* Used by resizeToJpeg to normalize before sips resize.
*/
async function normalizeExifOrientationSips(buffer) {
try {
const orientation = readJpegExifOrientation(buffer);
if (!orientation || orientation === 1) return buffer;
return await sipsApplyOrientation(buffer, orientation);
} catch {
return buffer;
}
}
//#endregion
//#region src/infra/net/ssrf.ts
var SsrFBlockedError = class extends Error {
constructor(message) {
super(message);
this.name = "SsrFBlockedError";
}
};
const PRIVATE_IPV6_PREFIXES = [
"fe80:",
"fec0:",
"fc",
"fd"
];
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
function normalizeHostname(hostname) {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
if (normalized.startsWith("[") && normalized.endsWith("]")) return normalized.slice(1, -1);
return normalized;
}
function normalizeHostnameSet(values) {
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean));
}
function parseIpv4(address) {
const parts = address.split(".");
if (parts.length !== 4) return null;
const numbers = parts.map((part) => Number.parseInt(part, 10));
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) return null;
return numbers;
}
function parseIpv4FromMappedIpv6(mapped) {
if (mapped.includes(".")) return parseIpv4(mapped);
const parts = mapped.split(":").filter(Boolean);
if (parts.length === 1) {
const value = Number.parseInt(parts[0], 16);
if (Number.isNaN(value) || value < 0 || value > 4294967295) return null;
return [
value >>> 24 & 255,
value >>> 16 & 255,
value >>> 8 & 255,
value & 255
];
}
if (parts.length !== 2) return null;
const high = Number.parseInt(parts[0], 16);
const low = Number.parseInt(parts[1], 16);
if (Number.isNaN(high) || Number.isNaN(low) || high < 0 || low < 0 || high > 65535 || low > 65535) return null;
const value = (high << 16) + low;
return [
value >>> 24 & 255,
value >>> 16 & 255,
value >>> 8 & 255,
value & 255
];
}
function isPrivateIpv4(parts) {
const [octet1, octet2] = parts;
if (octet1 === 0) return true;
if (octet1 === 10) return true;
if (octet1 === 127) return true;
if (octet1 === 169 && octet2 === 254) return true;
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return true;
if (octet1 === 192 && octet2 === 168) return true;
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) return true;
return false;
}
function isPrivateIpAddress(address) {
let normalized = address.trim().toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
if (!normalized) return false;
if (normalized.startsWith("::ffff:")) {
const ipv4 = parseIpv4FromMappedIpv6(normalized.slice(7));
if (ipv4) return isPrivateIpv4(ipv4);
}
if (normalized.includes(":")) {
if (normalized === "::" || normalized === "::1") return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
const ipv4 = parseIpv4(normalized);
if (!ipv4) return false;
return isPrivateIpv4(ipv4);
}
function isBlockedHostname(hostname) {
const normalized = normalizeHostname(hostname);
if (!normalized) return false;
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
return normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal");
}
function createPinnedLookup(params) {
const normalizedHost = normalizeHostname(params.hostname);
const fallback = params.fallback ?? lookup;
const fallbackLookup = fallback;
const fallbackWithOptions = fallback;
const records = params.addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4
}));
let index = 0;
return ((host, options, callback) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return;
const normalized = normalizeHostname(host);
if (!normalized || normalized !== normalizedHost) {
if (typeof options === "function" || options === void 0) return fallbackLookup(host, cb);
return fallbackWithOptions(host, options, cb);
}
const opts = typeof options === "object" && options !== null ? options : {};
const requestedFamily = typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
const candidates = requestedFamily === 4 || requestedFamily === 6 ? records.filter((entry) => entry.family === requestedFamily) : records;
const usable = candidates.length > 0 ? candidates : records;
if (opts.all) {
cb(null, usable);
return;
}
const chosen = usable[index % usable.length];
index += 1;
cb(null, chosen.address, chosen.family);
});
}
async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
const normalized = normalizeHostname(hostname);
if (!normalized) throw new Error("Invalid hostname");
const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork);
const isExplicitAllowed = normalizeHostnameSet(params.policy?.allowedHostnames).has(normalized);
if (!allowPrivateNetwork && !isExplicitAllowed) {
if (isBlockedHostname(normalized)) throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
if (isPrivateIpAddress(normalized)) throw new SsrFBlockedError("Blocked: private/internal IP address");
}
const results = await (params.lookupFn ?? lookup$1)(normalized, { all: true });
if (results.length === 0) throw new Error(`Unable to resolve hostname: ${hostname}`);
if (!allowPrivateNetwork && !isExplicitAllowed) {
for (const entry of results) if (isPrivateIpAddress(entry.address)) throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
}
const addresses = Array.from(new Set(results.map((entry) => entry.address)));
if (addresses.length === 0) throw new Error(`Unable to resolve hostname: ${hostname}`);
return {
hostname: normalized,
addresses,
lookup: createPinnedLookup({
hostname: normalized,
addresses
})
};
}
async function resolvePinnedHostname(hostname, lookupFn = lookup$1) {
return await resolvePinnedHostnameWithPolicy(hostname, { lookupFn });
}
function createPinnedDispatcher(pinned) {
return new Agent({ connect: { lookup: pinned.lookup } });
}
async function closeDispatcher(dispatcher) {
if (!dispatcher) return;
const candidate = dispatcher;
try {
if (typeof candidate.close === "function") {
await candidate.close();
return;
}
if (typeof candidate.destroy === "function") candidate.destroy();
} catch {}
}
//#endregion
//#region src/browser/routes/agent.act.shared.ts
const ACT_KINDS = [
"click",
"close",
"drag",
"evaluate",
"fill",
"hover",
"scrollIntoView",
"press",
"resize",
"select",
"type",
"wait"
];
function isActKind(value) {
if (typeof value !== "string") return false;
return ACT_KINDS.includes(value);
}
const ALLOWED_CLICK_MODIFIERS = new Set([
"Alt",
"Control",
"ControlOrMeta",
"Meta",
"Shift"
]);
function parseClickButton(raw) {
if (raw === "left" || raw === "right" || raw === "middle") return raw;
}
function parseClickModifiers(raw) {
if (raw.filter((m) => !ALLOWED_CLICK_MODIFIERS.has(m)).length) return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" };
return { modifiers: raw.length ? raw : void 0 };
}
//#endregion
//#region src/browser/routes/utils.ts
/**
* Extract profile name from query string or body and get profile context.
* Query string takes precedence over body for consistency with GET routes.
*/
function getProfileContext(req, ctx) {
let profileName;
if (typeof req.query.profile === "string") profileName = req.query.profile.trim() || void 0;
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body;
if (typeof body.profile === "string") profileName = body.profile.trim() || void 0;
}
try {
return ctx.forProfile(profileName);
} catch (err) {
return {
error: String(err),
status: 404
};
}
}
function jsonError(res, status, message) {
res.status(status).json({ error: message });
}
function toStringOrEmpty(value) {
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value).trim();
return "";
}
function toNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : void 0;
}
}
function toBoolean(value) {
return parseBooleanValue(value, {
truthy: [
"true",
"1",
"yes"
],
falsy: [
"false",
"0",
"no"
]
});
}
function toStringArray(value) {
if (!Array.isArray(value)) return;
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
return strings.length ? strings : void 0;
}
//#endregion
//#region src/browser/routes/agent.shared.ts
const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
"",
"Example workflow:",
"1. snapshot action to get page state with refs",
"2. act with ref: \"e123\" to interact with element",
"",
"This is more reliable for modern SPAs."
].join("\n");
function readBody(req) {
const body = req.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body;
}
function handleRouteError(ctx, res, err) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));
}
function resolveProfileContext(req, res, ctx) {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
async function getPwAiModule() {
return await getPwAiModule$1({ mode: "soft" });
}
async function requirePwAi(res, feature) {
const mod = await getPwAiModule();
if (mod) return mod;
jsonError(res, 501, [
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
"Docs: /tools/browser#playwright-requirement"
].join("\n"));
return null;
}
//#endregion
//#region src/browser/routes/agent.act.ts
function registerBrowserAgentActRoutes(app, ctx) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) return jsonError(res, 400, "kind is required");
const kind = kindRaw;
const targetId = toStringOrEmpty(body.targetId) || void 0;
if (Object.hasOwn(body, "selector") && kind !== "wait") return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE);
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const cdpUrl = profileCtx.profile.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
switch (kind) {
case "click": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
const doubleClick = toBoolean(body.doubleClick) ?? false;
const timeoutMs = toNumber(body.timeoutMs);
const buttonRaw = toStringOrEmpty(body.button) || "";
const button = buttonRaw ? parseClickButton(buttonRaw) : void 0;
if (buttonRaw && !button) return jsonError(res, 400, "button must be left|right|middle");
const parsedModifiers = parseClickModifiers(toStringArray(body.modifiers) ?? []);
if (parsedModifiers.error) return jsonError(res, 400, parsedModifiers.error);
const modifiers = parsedModifiers.modifiers;
const clickRequest = {
cdpUrl,
targetId: tab.targetId,
ref,
doubleClick
};
if (button) clickRequest.button = button;
if (modifiers) clickRequest.modifiers = modifiers;
if (timeoutMs) clickRequest.timeoutMs = timeoutMs;
await pw.clickViaPlaywright(clickRequest);
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url
});
}
case "type": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (typeof body.text !== "string") return jsonError(res, 400, "text is required");
const text = body.text;
const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false;
const timeoutMs = toNumber(body.timeoutMs);
const typeRequest = {
cdpUrl,
targetId: tab.targetId,
ref,
text,
submit,
slowly
};
if (timeoutMs) typeRequest.timeoutMs = timeoutMs;
await pw.typeViaPlaywright(typeRequest);
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "press": {
const key = toStringOrEmpty(body.key);
if (!key) return jsonError(res, 400, "key is required");
const delayMs = toNumber(body.delayMs);
await pw.pressKeyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
key,
delayMs: delayMs ?? void 0
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "hover": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.hoverViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
timeoutMs: timeoutMs ?? void 0
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
const timeoutMs = toNumber(body.timeoutMs);
const scrollRequest = {
cdpUrl,
targetId: tab.targetId,
ref
};
if (timeoutMs) scrollRequest.timeoutMs = timeoutMs;
await pw.scrollIntoViewViaPlaywright(scrollRequest);
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef);
const endRef = toStringOrEmpty(body.endRef);
if (!startRef || !endRef) return jsonError(res, 400, "startRef and endRef are required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.dragViaPlaywright({
cdpUrl,
targetId: tab.targetId,
startRef,
endRef,
timeoutMs: timeoutMs ?? void 0
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "select": {
const ref = toStringOrEmpty(body.ref);
const values = toStringArray(body.values);
if (!ref || !values?.length) return jsonError(res, 400, "ref and values are required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.selectOptionViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
values,
timeoutMs: timeoutMs ?? void 0
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "fill": {
const fields = (Array.isArray(body.fields) ? body.fields : []).map((field) => {
if (!field || typeof field !== "object") return null;
const rec = field;
const ref = toStringOrEmpty(rec.ref);
const type = toStringOrEmpty(rec.type);
if (!ref || !type) return null;
const value = typeof rec.value === "string" || typeof rec.value === "number" || typeof rec.value === "boolean" ? rec.value : void 0;
return value === void 0 ? {
ref,
type
} : {
ref,
type,
value
};
}).filter((field) => field !== null);
if (!fields.length) return jsonError(res, 400, "fields are required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.fillFormViaPlaywright({
cdpUrl,
targetId: tab.targetId,
fields,
timeoutMs: timeoutMs ?? void 0
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "resize": {
const width = toNumber(body.width);
const height = toNumber(body.height);
if (!width || !height) return jsonError(res, 400, "width and height are required");
await pw.resizeViewportViaPlaywright({
cdpUrl,
targetId: tab.targetId,
width,
height
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url
});
}
case "wait": {
const timeMs = toNumber(body.timeMs);
const text = toStringOrEmpty(body.text) || void 0;
const textGone = toStringOrEmpty(body.textGone) || void 0;
const selector = toStringOrEmpty(body.selector) || void 0;
const url = toStringOrEmpty(body.url) || void 0;
const loadStateRaw = toStringOrEmpty(body.loadState);
const loadState = loadStateRaw === "load" || loadStateRaw === "domcontentloaded" || loadStateRaw === "networkidle" ? loadStateRaw : void 0;
const fn = toStringOrEmpty(body.fn) || void 0;
const timeoutMs = toNumber(body.timeoutMs) ?? void 0;
if (fn && !evaluateEnabled) return jsonError(res, 403, ["wait --fn is disabled by config (browser.evaluateEnabled=false).", "Docs: /gateway/configuration#browser-openclaw-managed-browser"].join("\n"));
if (timeMs === void 0 && !text && !textGone && !selector && !url && !loadState && !fn) return jsonError(res, 400, "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn");
await pw.waitForViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timeMs,
text,
textGone,
selector,
url,
loadState,
fn,
timeoutMs
});
return res.json({
ok: true,
targetId: tab.targetId
});
}
case "evaluate": {
if (!evaluateEnabled) return jsonError(res, 403, ["act:evaluate is disabled by config (browser.evaluateEnabled=false).", "Docs: /gateway/configuration#browser-openclaw-managed-browser"].join("\n"));
const fn = toStringOrEmpty(body.fn);
if (!fn) return jsonError(res, 400, "fn is required");
const ref = toStringOrEmpty(body.ref) || void 0;
const result = await pw.evaluateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
fn,
ref
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result
});
}
case "close":
await pw.closePageViaPlaywright({
cdpUrl,
targetId: tab.targetId
});
return res.json({
ok: true,
targetId: tab.targetId
});
default: return jsonError(res, 400, "unsupported kind");
}
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/hooks/file-chooser", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const ref = toStringOrEmpty(body.ref) || void 0;
const inputRef = toStringOrEmpty(body.inputRef) || void 0;
const element = toStringOrEmpty(body.element) || void 0;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) return jsonError(res, 400, "paths are required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
if (inputRef || element) {
if (ref) return jsonError(res, 400, "ref cannot be combined with inputRef/element");
await pw.setInputFilesViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? void 0
});
if (ref) await pw.clickViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref
});
}
res.json({ ok: true });
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/hooks/dialog", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || void 0;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === void 0) return jsonError(res, 400, "accept is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
await pw.armDialogViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? void 0
});
res.json({ ok: true });
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/wait/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const out = toStringOrEmpty(body.path) || void 0;
const timeoutMs = toNumber(body.timeoutMs);
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "wait for download");
if (!pw) return;
const result = await pw.waitForDownloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
path: out,
timeoutMs: timeoutMs ?? void 0
});
res.json({
ok: true,
targetId: tab.targetId,
download: result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) return jsonError(res, 400, "ref is required");
if (!out) return jsonError(res, 400, "path is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "download");
if (!pw) return;
const result = await pw.downloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
path: out,
timeoutMs: timeoutMs ?? void 0
});
res.json({
ok: true,
targetId: tab.targetId,
download: result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/response/body", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) return jsonError(res, 400, "url is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "response body");
if (!pw) return;
const result = await pw.responseBodyViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? void 0,
maxChars: maxChars ?? void 0
});
res.json({
ok: true,
targetId: tab.targetId,
response: result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/highlight", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "highlight");
if (!pw) return;
await pw.highlightViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref
});
res.json({
ok: true,
targetId: tab.targetId
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}
//#endregion
//#region src/browser/routes/agent.debug.ts
function registerBrowserAgentDebugRoutes(app, ctx) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const level = typeof req.query.level === "string" ? req.query.level : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || void 0);
const pw = await requirePwAi(res, "console messages");
if (!pw) return;
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
level: level.trim() || void 0
});
res.json({
ok: true,
messages,
targetId: tab.targetId
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.get("/errors", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const clear = toBoolean(req.query.clear) ?? false;
try {
const tab = await profileCtx.ensureTabAvailable(targetId || void 0);
const pw = await requirePwAi(res, "page errors");
if (!pw) return;
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
clear
});
res.json({
ok: true,
targetId: tab.targetId,
...result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.get("/requests", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
try {
const tab = await profileCtx.ensureTabAvailable(targetId || void 0);
const pw = await requirePwAi(res, "network requests");
if (!pw) return;
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
filter: filter.trim() || void 0,
clear
});
res.json({
ok: true,
targetId: tab.targetId,
...result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/trace/start", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const screenshots = toBoolean(body.screenshots) ?? void 0;
const snapshots = toBoolean(body.snapshots) ?? void 0;
const sources = toBoolean(body.sources) ?? void 0;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "trace start");
if (!pw) return;
await pw.traceStartViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources
});
res.json({
ok: true,
targetId: tab.targetId
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/trace/stop", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const out = toStringOrEmpty(body.path) || "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "trace stop");
if (!pw) return;
const id = crypto.randomUUID();
const dir = "/tmp/openclaw";
await fs$1.mkdir(dir, { recursive: true });
const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`);
await pw.traceStopViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
path: tracePath
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath)
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}
//#endregion
//#region src/media/store.ts
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MEDIA_MAX_BYTES = 5 * 1024 * 1024;
const MAX_BYTES = MEDIA_MAX_BYTES;
const DEFAULT_TTL_MS = 120 * 1e3;
/**
* Sanitize a filename for cross-platform safety.
* Removes chars unsafe on Windows/SharePoint/all platforms.
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name) {
const trimmed = name.trim();
if (!trimmed) return "";
return trimmed.replace(/[^\p{L}\p{N}._-]+/gu, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
function getMediaDir() {
return resolveMediaDir();
}
async function ensureMediaDir() {
const mediaDir = resolveMediaDir();
await fs$1.mkdir(mediaDir, {
recursive: true,
mode: 448
});
return mediaDir;
}
async function saveMediaBuffer(buffer, contentType, subdir = "inbound", maxBytes = MAX_BYTES, originalFilename) {
if (buffer.byteLength > maxBytes) throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
const dir = path.join(resolveMediaDir(), subdir);
await fs$1.mkdir(dir, {
recursive: true,
mode: 448
});
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? void 0);
const mime = await detectMime({
buffer,
headerMime: contentType
});
const ext = headerExt ?? extensionForMime(mime) ?? "";
let id;
if (originalFilename) {
const base = path.parse(originalFilename).name;
const sanitized = sanitizeFilename(base);
id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
} else id = ext ? `${uuid}${ext}` : uuid;
const dest = path.join(dir, id);
await fs$1.writeFile(dest, buffer, { mode: 384 });
return {
id,
path: dest,
size: buffer.byteLength,
contentType: mime
};
}
//#endregion
//#region src/browser/screenshot.ts
const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2e3;
const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
async function normalizeBrowserScreenshot(buffer, opts) {
const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE));
const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES));
const meta = await getImageMetadata(buffer);
const width = Number(meta?.width ?? 0);
const height = Number(meta?.height ?? 0);
const maxDim = Math.max(width, height);
if (buffer.byteLength <= maxBytes && (maxDim === 0 || width <= maxSide && height <= maxSide)) return { buffer };
const qualities = [
85,
75,
65,
55,
45,
35
];
const sideGrid = [
maxDim > 0 ? Math.min(maxSide, maxDim) : maxSide,
1800,
1600,
1400,
1200,
1e3,
800
].map((v) => Math.min(maxSide, v)).filter((v, i, arr) => v > 0 && arr.indexOf(v) === i).toSorted((a, b) => b - a);
let smallest = null;
for (const side of sideGrid) for (const quality of qualities) {
const out = await resizeToJpeg({
buffer,
maxSide: side,
quality,
withoutEnlargement: true
});
if (!smallest || out.byteLength < smallest.size) smallest = {
buffer: out,
size: out.byteLength
};
if (out.byteLength <= maxBytes) return {
buffer: out,
contentType: "image/jpeg"
};
}
const best = smallest?.buffer ?? buffer;
throw new Error(`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`);
}
//#endregion
//#region src/browser/routes/agent.snapshot.ts
function registerBrowserAgentSnapshotRoutes(app, ctx) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || void 0;
if (!url) return jsonError(res, 400, "url is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
const result = await pw.navigateViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
url
});
res.json({
ok: true,
targetId: tab.targetId,
...result
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/pdf", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = toStringOrEmpty(readBody(req).targetId) || void 0;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "pdf");
if (!pw) return;
const pdf = await pw.pdfViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId
});
await ensureMediaDir();
const saved = await saveMediaBuffer(pdf.buffer, "application/pdf", "browser", pdf.buffer.byteLength);
res.json({
ok: true,
path: path.resolve(saved.path),
targetId: tab.targetId,
url: tab.url
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/screenshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || void 0;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || void 0;
const element = toStringOrEmpty(body.element) || void 0;
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) return jsonError(res, 400, "fullPage is not supported for element screenshots");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
let buffer;
if (profileCtx.profile.driver === "extension" || !tab.wsUrl || Boolean(ref) || Boolean(element)) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) return;
buffer = (await pw.takeScreenshotViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type
})).buffer;
} else buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : void 0
});
const normalized = await normalizeBrowserScreenshot(buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES
});
await ensureMediaDir();
const saved = await saveMediaBuffer(normalized.buffer, normalized.contentType ?? `image/${type}`, "browser", DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES);
res.json({
ok: true,
path: path.resolve(saved.path),
targetId: tab.targetId,
url: tab.url
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const mode = req.query.mode === "efficient" ? "efficient" : void 0;
const labels = toBoolean(req.query.labels) ?? void 0;
const format = (req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : void 0) ?? (mode ? "ai" : await getPwAiModule() ? "ai" : "aria");
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : void 0;
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
const maxCharsRaw = typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : void 0;
const limit = Number.isFinite(limitRaw) ? limitRaw : void 0;
const resolvedMaxChars = format === "ai