UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

481 lines (480 loc) 20.3 kB
import { r as asNullableRecord } from "./record-coerce-DItp3I4t.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { t as truncateUtf8Prefix } from "./utf8-truncate-CCgJrv8A.js"; import "./runtime-env-0Q-gCyUb.js"; import "./string-coerce-runtime-GQa0ehRA.js"; import "./text-utility-runtime-BjzvUG99.js"; import { p as parseBrowserErrorPayload } from "./errors-6hPg4OWh.js"; import { i as resolveExistingUploadPaths, r as DEFAULT_UPLOAD_DIR } from "./paths-CA_QMzCv.js"; import path from "node:path"; import fs from "node:fs/promises"; //#region extensions/browser/src/browser-proxy-envelope.ts /** * Browser node-proxy response envelope shared by the node host and Gateway. */ /** Additive opt-in for structured browser route errors over node.invoke. */ const BROWSER_PROXY_ERROR_ENVELOPE = "browser-v1"; /** Additive request envelope for Gateway-owned files sent to a browser node. */ const BROWSER_PROXY_UPLOAD_ENVELOPE = "browser-upload-v1"; /** Private node-host operation; unknown older nodes reject it before closing anything. */ const BROWSER_PROXY_OWNED_TAB_CLOSE_PATH = "/__openclaw/session-tab/close-owned"; const BROWSER_PROXY_MAX_FILE_BYTES = 10485760; const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16777216; const BROWSER_PROXY_MAX_FILES = 256; /** Bound filesystem work even when one action emits many tiny downloads. */ function assertBrowserProxyFileCountWithinLimit(fileCount, direction = "response") { if (fileCount > BROWSER_PROXY_MAX_FILES) throw new Error(`browser proxy ${direction} exceeds 256 file limit`); } /** Enforce the shared per-file and raw aggregate Browser proxy limits. */ function assertBrowserProxyFileBytesWithinLimits(fileBytes, totalBytes) { if (fileBytes > 10485760) throw new Error("browser proxy file exceeds 10 MiB limit"); if (totalBytes > BROWSER_PROXY_MAX_TOTAL_FILE_BYTES) throw new Error("browser proxy files exceed 16 MiB aggregate limit"); } /** Visit the route-owned file paths that may cross the Browser node boundary. */ function visitBrowserProxyFilePaths(result, visit) { if (!result || typeof result !== "object" || Array.isArray(result)) return; const root = result; const visitPath = (owner, key) => { const filePath = owner[key]; if (typeof filePath !== "string" || !filePath.trim()) return; const replacement = visit(filePath); if (typeof replacement === "string") owner[key] = replacement; }; visitPath(root, "path"); visitPath(root, "imagePath"); const download = root.download; if (download && typeof download === "object" && !Array.isArray(download)) visitPath(download, "path"); if (Array.isArray(root.downloads)) { for (const entry of root.downloads) if (entry && typeof entry === "object" && !Array.isArray(entry)) visitPath(entry, "path"); } } function normalizeBrowserProxyErrorBody(value, fallback) { const parsed = parseBrowserErrorPayload(value); if (parsed) return parsed; return fallback ? { error: fallback } : null; } /** Build a route-failure envelope while allowing only closed Browser metadata. */ function createBrowserProxyFailure(status, body, route) { return { error: { status, body: normalizeBrowserProxyErrorBody(body, `HTTP ${status}`) ?? { error: `HTTP ${status}` } }, ...route ? { route } : {} }; } function parseBrowserProxyRoute(value) { const route = asNullableRecord(asNullableRecord(value)?.route); if (!route) return; if (route.status === "unavailable") return { status: "unavailable" }; if (route.status !== "resolved" || typeof route.profile !== "string" || !route.profile || route.profile.trim() !== route.profile || route.driver !== "openclaw" && route.driver !== "existing-session" && route.driver !== "extension") return; return { status: "resolved", profile: route.profile, driver: route.driver }; } /** Parse an untrusted node response without forwarding arbitrary metadata. */ function parseBrowserProxyFailure(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const error = value.error; if (!error || typeof error !== "object" || Array.isArray(error)) return null; const candidate = error; if (!Number.isInteger(candidate.status) || candidate.status < 400 || candidate.status > 599) return null; const body = normalizeBrowserProxyErrorBody(candidate.body); if (!body) return null; const route = parseBrowserProxyRoute(value); return { error: { status: candidate.status, body }, ...route ? { route } : {} }; } //#endregion //#region extensions/browser/src/browser-proxy-upload.ts /** * Browser proxy upload transport. * * Existing Browser upload paths are Gateway-owned. Proxied requests carry * bounded bytes to the node, which stages private copies under its upload root. */ const logger = createSubsystemLogger("browser"); const BROWSER_PROXY_UPLOAD_ROOT_NAME = ".proxy-uploads"; const BROWSER_PROXY_UPLOAD_PREFIX = "upload-"; const BROWSER_PROXY_UPLOAD_MARKER_NAME = ".openclaw-browser-proxy-upload-v1"; const BROWSER_PROXY_UPLOAD_MARKER_CONTENT = "openclaw-browser-proxy-upload-v1\n"; const BROWSER_PROXY_UPLOAD_RETENTION_MS = 864e5; const BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS = 36e5; const BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES = 268435456; const BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES = 64; const BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH = Math.ceil(BROWSER_PROXY_MAX_FILE_BYTES / 3) * 4; const MAX_STAGED_NAME_BYTES = 180; const PORTABLE_NAME_FORBIDDEN = /* @__PURE__ */ new Set([ "<", ">", ":", "\"", "/", "\\", "|", "?", "*", "%", "!" ]); const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu; const cleanupTimers = /* @__PURE__ */ new Map(); const recoveryPromises = /* @__PURE__ */ new Map(); const recoveryRetryTimers = /* @__PURE__ */ new Map(); const stagingLocks = /* @__PURE__ */ new Map(); function isFileChooserRequest(method, requestPath) { return method.toUpperCase() === "POST" && `/${requestPath.trim().replace(/^\/+/u, "")}` === "/hooks/file-chooser"; } /** Identify upload requests before reading files or applying transport-only limits. */ function isBrowserProxyUploadRequest(params) { if (!isFileChooserRequest(params.method, params.path)) return false; const body = asNullableRecord(params.body); return Boolean(body && Array.isArray(body.paths) && body.paths.length > 0); } function readUploadPaths(body) { if (!Array.isArray(body.paths) || body.paths.length === 0) return null; if (!body.paths.every((entry) => typeof entry === "string")) throw new Error("browser proxy upload paths must contain only strings"); return body.paths; } async function readBrowserProxyUploadFiles(paths, signal) { assertBrowserProxyFileCountWithinLimit(paths.length, "request"); const files = []; let totalBytes = 0; for (const filePath of paths) { signal?.throwIfAborted(); const stat = await fs.stat(filePath).catch(() => null); if (!stat?.isFile()) throw new Error(`browser proxy upload file not found: ${filePath}`); assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size); const buffer = await fs.readFile(filePath, signal ? { signal } : void 0); assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength); totalBytes += buffer.byteLength; files.push({ name: path.basename(filePath), contentBase64: buffer.toString("base64") }); } return files; } /** Build a node-only upload envelope while retaining the original body for host fallback. */ async function prepareBrowserProxyUploadRequest(params) { params.signal?.throwIfAborted(); if (!isFileChooserRequest(params.method, params.path)) return { body: params.body }; const body = asNullableRecord(params.body); if (!body) return { body: params.body }; const requestedPaths = readUploadPaths(body); if (!requestedPaths) return { body: params.body }; assertBrowserProxyFileCountWithinLimit(requestedPaths.length, "request"); const resolved = await resolveExistingUploadPaths({ requestedPaths, ...params.uploadDir ? { uploadDir: params.uploadDir } : {}, ...params.inboundMediaDir ? { inboundMediaDir: params.inboundMediaDir } : {} }); if (!resolved.ok) throw new Error(resolved.error); const upload = { envelope: BROWSER_PROXY_UPLOAD_ENVELOPE, files: await readBrowserProxyUploadFiles(resolved.paths, params.signal) }; const { paths: _paths, ...bodyWithoutPaths } = body; return { body: bodyWithoutPaths, upload }; } function sanitizeUploadName(name) { const basename = path.posix.basename(name.replaceAll("\\", "/")); const cleaned = Array.from(basename, (character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 31 || codePoint === 127 || PORTABLE_NAME_FORBIDDEN.has(character) ? "_" : character; }).join("").trim().replace(/[. ]+$/u, ""); const portable = WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned; return truncateUtf8Prefix(portable && portable !== "." && portable !== ".." ? portable : "upload", MAX_STAGED_NAME_BYTES) || "upload"; } function decodedBase64Size(value) { if (value.length % 4 !== 0) throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding"); const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; return value.length / 4 * 3 - padding; } function decodeUploadFile(file, totalBytes) { if (!file || typeof file.name !== "string" || !file.name.trim() || typeof file.contentBase64 !== "string" || file.contentBase64.length > BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH) throw new Error("INVALID_REQUEST: invalid browser proxy upload file"); const estimatedBytes = decodedBase64Size(file.contentBase64); assertBrowserProxyFileBytesWithinLimits(estimatedBytes, totalBytes + estimatedBytes); const buffer = Buffer.from(file.contentBase64, "base64"); if (buffer.toString("base64") !== file.contentBase64) throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding"); assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength); return buffer; } async function removeStagedUpload(directory) { const timer = cleanupTimers.get(directory); if (timer) { clearTimeout(timer); cleanupTimers.delete(directory); } try { await fs.rm(directory, { recursive: true, force: true }); } catch (error) { logger.warn(`browser proxy upload cleanup failed; retrying: ${String(error)}`); scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS); } } async function readDirectoryBytes(directory, signal) { signal?.throwIfAborted(); let entries; try { entries = await fs.readdir(directory, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return 0; throw error; } let totalBytes = 0; for (const entry of entries) { signal?.throwIfAborted(); const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { totalBytes += await readDirectoryBytes(entryPath, signal); continue; } if (!entry.isFile()) continue; const stat = await fs.stat(entryPath).catch(() => null); totalBytes += stat?.isFile() ? stat.size : 0; } return totalBytes; } async function readOwnedStagedUploads(stagingRoot, signal) { signal?.throwIfAborted(); let entries; try { entries = await fs.readdir(stagingRoot, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return []; throw error; } return (await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(BROWSER_PROXY_UPLOAD_PREFIX)).map(async (entry) => { signal?.throwIfAborted(); const directory = path.join(stagingRoot, entry.name); const stat = await fs.stat(directory).catch(() => null); if (!stat?.isDirectory()) return null; if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return null; if (await fs.readFile(path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME), "utf8").catch(() => null) !== BROWSER_PROXY_UPLOAD_MARKER_CONTENT) return null; return { bytes: await readDirectoryBytes(directory, signal), directory, mtimeMs: stat.mtimeMs }; }))).filter((upload) => upload !== null); } function scheduleCleanup(directory, delayMs) { if (cleanupTimers.has(directory)) return; const timer = setTimeout(() => { cleanupTimers.delete(directory); removeStagedUpload(directory); }, Math.max(0, delayMs)); cleanupTimers.set(directory, timer); timer.unref?.(); } async function recoverStagedUploads(params) { const { uploadDir, retentionMs, nowMs, limits } = params; const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME); const retained = []; for (const upload of await readOwnedStagedUploads(stagingRoot)) if (retentionMs - Math.max(0, nowMs - upload.mtimeMs) <= 0) await removeStagedUpload(upload.directory); else retained.push(upload); retained.sort((left, right) => left.mtimeMs - right.mtimeMs); let totalBytes = retained.reduce((total, upload) => total + upload.bytes, 0); while (retained.length > limits.maxRetainedDirectories || totalBytes > limits.maxRetainedBytes) { const oldest = retained.shift(); if (!oldest) break; totalBytes -= oldest.bytes; await removeStagedUpload(oldest.directory); } for (const upload of retained) { const remaining = retentionMs - Math.max(0, nowMs - upload.mtimeMs); scheduleCleanup(upload.directory, remaining); } } function clearRecoveryRetry(uploadDir) { const timer = recoveryRetryTimers.get(uploadDir); if (!timer) return; clearTimeout(timer); recoveryRetryTimers.delete(uploadDir); } function scheduleRecoveryRetry(uploadDir, retentionMs) { if (recoveryRetryTimers.has(uploadDir)) return; const timer = setTimeout(() => { recoveryRetryTimers.delete(uploadDir); recoveryPromises.delete(uploadDir); ensureBrowserProxyUploadCleanup({ uploadDir, retentionMs }); }, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS); recoveryRetryTimers.set(uploadDir, timer); timer.unref?.(); } async function runRecovery(params) { try { await recoverStagedUploads(params); clearRecoveryRetry(params.uploadDir); } catch (error) { logger.warn(`browser proxy upload recovery failed; retrying: ${String(error)}`); scheduleRecoveryRetry(params.uploadDir, params.retentionMs); } } /** Restores cleanup timers for staged uploads left by a previous node process. */ function ensureBrowserProxyUploadCleanup(options) { const uploadDir = options?.uploadDir ?? DEFAULT_UPLOAD_DIR; const retentionMs = options?.retentionMs ?? BROWSER_PROXY_UPLOAD_RETENTION_MS; const nowMs = options?.nowMs ?? Date.now(); const limits = { maxRetainedBytes: options?.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES, maxRetainedDirectories: options?.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES }; if (options?.retentionMs !== void 0 || options?.nowMs !== void 0 || options?.maxRetainedBytes !== void 0 || options?.maxRetainedDirectories !== void 0) return runRecovery({ uploadDir, retentionMs, nowMs, limits }); const existing = recoveryPromises.get(uploadDir); if (existing) return existing; const recovery = runRecovery({ uploadDir, retentionMs, nowMs, limits }).finally(() => { if (recoveryRetryTimers.has(uploadDir)) recoveryPromises.delete(uploadDir); }); recoveryPromises.set(uploadDir, recovery); return recovery; } async function waitForStagingLock(previous, signal) { if (!signal) { await previous; return; } signal.throwIfAborted(); let onAbort; const aborted = new Promise((_, reject) => { onAbort = () => { try { signal.throwIfAborted(); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); } }; signal.addEventListener("abort", onAbort, { once: true }); }); try { await Promise.race([previous, aborted]); } finally { if (onAbort) signal.removeEventListener("abort", onAbort); } } async function withStagingLock(uploadDir, task, signal) { const previous = stagingLocks.get(uploadDir) ?? Promise.resolve(); let release = () => {}; const current = new Promise((resolve) => { release = resolve; }); const tail = previous.then(() => current); stagingLocks.set(uploadDir, tail); try { await waitForStagingLock(previous, signal); return await task(); } finally { release(); if (stagingLocks.get(uploadDir) === tail) stagingLocks.delete(uploadDir); } } function validateUploadEnvelope(upload) { if (!upload || upload.envelope !== "browser-upload-v1" || !Array.isArray(upload.files) || upload.files.length === 0) throw new Error("INVALID_REQUEST: invalid browser proxy upload envelope"); assertBrowserProxyFileCountWithinLimit(upload.files.length, "request"); return upload.files; } /** Stage a validated upload envelope under the node's managed Browser upload root. */ async function stageBrowserProxyUploadRequest(params) { params.signal?.throwIfAborted(); if (params.upload === void 0) return { body: params.body }; if (!isFileChooserRequest(params.method, params.path)) throw new Error("INVALID_REQUEST: browser proxy upload requires the file chooser route"); const body = asNullableRecord(params.body); if (!body || Object.hasOwn(body, "paths")) throw new Error("INVALID_REQUEST: browser proxy upload body must omit paths"); const files = validateUploadEnvelope(params.upload); const uploadDir = params.uploadDir ?? DEFAULT_UPLOAD_DIR; const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME); await fs.mkdir(stagingRoot, { recursive: true, mode: 448 }); params.signal?.throwIfAborted(); await ensureBrowserProxyUploadCleanup({ uploadDir }); params.signal?.throwIfAborted(); const decodedFiles = []; let fileBytes = 0; for (const file of files) { const buffer = decodeUploadFile(file, fileBytes); fileBytes += buffer.byteLength; decodedFiles.push({ buffer, name: file.name }); } const retainedRequestBytes = fileBytes + Buffer.byteLength(BROWSER_PROXY_UPLOAD_MARKER_CONTENT); const limits = { maxRetainedBytes: params.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES, maxRetainedDirectories: params.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES }; return await withStagingLock(uploadDir, async () => { params.signal?.throwIfAborted(); const retained = await readOwnedStagedUploads(stagingRoot, params.signal); const retainedBytes = retained.reduce((total, upload) => total + upload.bytes, 0); if (retained.length >= limits.maxRetainedDirectories || retainedBytes + retainedRequestBytes > limits.maxRetainedBytes) throw new Error("RESOURCE_EXHAUSTED: browser proxy upload staging limit reached"); const directory = await fs.mkdtemp(path.join(stagingRoot, BROWSER_PROXY_UPLOAD_PREFIX)); const stagedPaths = []; try { await fs.writeFile(path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME), BROWSER_PROXY_UPLOAD_MARKER_CONTENT, { flag: "wx", mode: 384, signal: params.signal }); for (const [index, file] of decodedFiles.entries()) { params.signal?.throwIfAborted(); const fileDirectory = path.join(directory, String(index)); await fs.mkdir(fileDirectory, { mode: 448 }); params.signal?.throwIfAborted(); const filePath = path.join(fileDirectory, sanitizeUploadName(file.name)); await fs.writeFile(filePath, file.buffer, { flag: "wx", mode: 384, signal: params.signal }); stagedPaths.push(filePath); } } catch (error) { await removeStagedUpload(directory); throw error; } scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_RETENTION_MS); return { body: { ...body, paths: stagedPaths }, directory }; }, params.signal); } /** Remove staged copies after a request fails before Browser ownership is established. */ async function discardStagedBrowserProxyUpload(staged) { if (staged.directory) await removeStagedUpload(staged.directory); } //#endregion export { stageBrowserProxyUploadRequest as a, BROWSER_PROXY_OWNED_TAB_CLOSE_PATH as c, createBrowserProxyFailure as d, parseBrowserProxyFailure as f, prepareBrowserProxyUploadRequest as i, assertBrowserProxyFileBytesWithinLimits as l, visitBrowserProxyFilePaths as m, ensureBrowserProxyUploadCleanup as n, BROWSER_PROXY_ERROR_ENVELOPE as o, parseBrowserProxyRoute as p, isBrowserProxyUploadRequest as r, BROWSER_PROXY_MAX_FILE_BYTES as s, discardStagedBrowserProxyUpload as t, assertBrowserProxyFileCountWithinLimit as u };