openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
454 lines (453 loc) • 19.6 kB
JavaScript
import { i as extractErrorCode } from "./error-coercion-CvJK4lmH.mjs";
import { r as asNullableRecord } from "./record-coerce-TIsquoVR.mjs";
import { o as racePromiseWithAbortSignal } from "./src-D1BHGNTj.mjs";
import { t as createSubsystemLogger } from "./subsystem-I-uqdx4f.mjs";
import { t as truncateUtf8Prefix } from "./utf8-truncate-_hf7tp13.mjs";
import "./error-runtime-BmospGvZ.mjs";
import "./string-coerce-runtime-50qKeWeL.mjs";
import { o as sanitizeUntrustedFileName } from "./security-runtime-Cl_Gnohk.mjs";
import "./text-utility-runtime-BDs7x8Fk.mjs";
import "./runtime-env-C0XPA9NZ.mjs";
import "./time-runtime-Bfsc2GHI.mjs";
import { m as parseBrowserErrorPayload } from "./errors-BqlAThZX.mjs";
import { i as resolveExistingUploadPaths, r as DEFAULT_UPLOAD_DIR } from "./paths-DxRACgE0.mjs";
import path from "node:path";
import fs from "node:fs/promises";
//#region extensions/browser/src/browser-proxy-envelope.ts
/** 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`);
}
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) {
const root = asNullableRecord(result);
if (!root) return;
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 = asNullableRecord(root.download);
if (download) visitPath(download, "path");
if (Array.isArray(root.downloads)) for (const entry of root.downloads) {
const record = asNullableRecord(entry);
if (record) visitPath(record, "path");
}
}
/** Build a route-failure envelope while allowing only closed Browser metadata. */
function createBrowserProxyFailure(status, body, route) {
return {
error: {
status,
body: parseBrowserErrorPayload(body) ?? { 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) {
const candidate = asNullableRecord(asNullableRecord(value)?.error);
if (!candidate) return null;
if (typeof candidate.status !== "number" || !Number.isInteger(candidate.status) || candidate.status < 400 || candidate.status > 599) return null;
const body = parseBrowserErrorPayload(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
/**
* 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 cleanupTimers = /* @__PURE__ */ new Map();
const recoveryPromises = /* @__PURE__ */ new Map();
const recoveryRetryTimers = /* @__PURE__ */ new Map();
const stagingLocks = /* @__PURE__ */ new Map();
let activeCleanup = 0;
let activeRecovery = 0;
function hasBrowserProxyUploadWork() {
return activeCleanup > 0 || activeRecovery > 0 || stagingLocks.size > 0;
}
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 safe = sanitizeUntrustedFileName(name, "upload").replace(/[!%]/gu, "_");
const bounded = truncateUtf8Prefix(safe, MAX_STAGED_NAME_BYTES).replace(/[.\s]+$/u, "");
return sanitizeUntrustedFileName(bounded, "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) {
activeCleanup += 1;
const timer = cleanupTimers.get(directory);
if (timer) {
clearTimeout(timer);
cleanupTimers.delete(directory);
}
try {
if ((await fs.lstat(directory)).isDirectory()) {
for (const entry of await fs.readdir(directory)) if (entry !== BROWSER_PROXY_UPLOAD_MARKER_NAME) await fs.rm(path.join(directory, entry), {
recursive: true,
force: true
});
}
await fs.rm(directory, {
recursive: true,
force: true
});
} catch (error) {
if (extractErrorCode(error) === "ENOENT") return;
logger.warn(`browser proxy upload cleanup failed; retrying: ${String(error)}`);
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
} finally {
activeCleanup -= 1;
}
}
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.set(uploadDir, ensureBrowserProxyUploadCleanup({
uploadDir,
retentionMs
}));
}, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
recoveryRetryTimers.set(uploadDir, timer);
timer.unref?.();
}
async function runRecovery(params) {
activeRecovery += 1;
try {
await recoverStagedUploads(params);
clearRecoveryRetry(params.uploadDir);
} catch (error) {
logger.warn(`browser proxy upload recovery failed; retrying: ${String(error)}`);
scheduleRecoveryRetry(params.uploadDir, params.retentionMs);
} finally {
activeRecovery -= 1;
}
}
/** 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
});
recoveryPromises.set(uploadDir, recovery);
return recovery;
}
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);
tail.then(() => {
if (stagingLocks.get(uploadDir) === tail) stagingLocks.delete(uploadDir);
});
try {
signal?.throwIfAborted();
await racePromiseWithAbortSignal(previous, signal, ({ reason }) => reason instanceof Error ? reason : new Error(String(reason)));
return await task();
} finally {
release();
}
}
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;
}
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();
if (recoveryRetryTimers.has(uploadDir)) {
clearRecoveryRetry(uploadDir);
recoveryPromises.delete(uploadDir);
}
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 { prepareBrowserProxyUploadRequest as a, BROWSER_PROXY_MAX_FILE_BYTES as c, assertBrowserProxyFileCountWithinLimit as d, createBrowserProxyFailure as f, visitBrowserProxyFilePaths as h, isBrowserProxyUploadRequest as i, BROWSER_PROXY_OWNED_TAB_CLOSE_PATH as l, parseBrowserProxyRoute as m, ensureBrowserProxyUploadCleanup as n, stageBrowserProxyUploadRequest as o, parseBrowserProxyFailure as p, hasBrowserProxyUploadWork as r, BROWSER_PROXY_ERROR_ENVELOPE as s, discardStagedBrowserProxyUpload as t, assertBrowserProxyFileBytesWithinLimits as u };