UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

1,568 lines (1,556 loc) 78.9 kB
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