import { i as resolveGatewayPort, t as STATE_DIR } from "./paths-VslOJiD2.js"; import { A as normalizeAccountId$1, C as resolveOpenClawPackageRootSync, D as buildAgentMainSessionKey, M as normalizeMainKey, N as resolveAgentIdFromSessionKey, T as DEFAULT_AGENT_ID, _ as DEFAULT_TOOLS_FILENAME, c as resolveDefaultAgentId, d as DEFAULT_AGENTS_FILENAME, f as DEFAULT_AGENT_WORKSPACE_DIR, g as DEFAULT_SOUL_FILENAME, h as DEFAULT_IDENTITY_FILENAME, j as normalizeAgentId, l as resolveSessionAgentId, m as DEFAULT_HEARTBEAT_FILENAME, n as resolveAgentConfig, p as DEFAULT_BOOTSTRAP_FILENAME, v as DEFAULT_USER_FILENAME, w as DEFAULT_ACCOUNT_ID, y as ensureAgentWorkspace } from "./agent-scope-COnICB_7.js"; import { A as normalizeE164, C as CONFIG_DIR, N as resolveUserPath, _ as normalizeAnyChannelId, b as getActivePluginRegistry, g as getChatChannelMeta, h as CHAT_CHANNEL_ORDER, j as resolveConfigDir, l as createSubsystemLogger, m as CHANNEL_IDS, n as runExec, u as defaultRuntime, x as requireActivePluginRegistry, y as normalizeChatChannelId } from "./exec-B7WKla_0.js"; import { D as isTruthyEnvValue, O as parseBooleanValue } from "./model-selection-Cs1y6OBv.js"; import { t as formatCliCommand } from "./command-format-SkzzRqR1.js"; import { _ as resolveEnableState, f as loadPluginManifestRegistry, g as normalizePluginsConfig, h as MANIFEST_KEY, i as writeConfigFile, k as resolveWhatsAppAccount, m as LEGACY_MANIFEST_KEYS, t as loadConfig, v as resolveMemorySlotDecision } from "./config-DZE9oO5f.js"; import { C as DEFAULT_AI_SNAPSHOT_MAX_CHARS, D as DEFAULT_OPENCLAW_BROWSER_ENABLED, E as DEFAULT_OPENCLAW_BROWSER_COLOR, O as DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, S as DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, T as DEFAULT_BROWSER_EVALUATE_ENABLED, a as resolveOpenClawUserDataDir, c as captureScreenshot, d as normalizeCdpWsUrl, f as snapshotAria, g as stopChromeExtensionRelayServer, h as ensureChromeExtensionRelayServer, i as launchOpenClawChrome, l as createTargetViaCdp, m as getHeadersWithAuth, n as isChromeCdpReady, o as stopOpenClawChrome, p as appendCdpPath, r as isChromeReachable, s as resolveBrowserExecutableForPlatform, v as extractErrorCode, w as DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, x as DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, y as formatErrorMessage } from "./chrome-Dh3zHf7s.js"; import { r as resolveSessionTranscriptPath, t as resolveDefaultSessionStorePath } from "./paths-xPuk88Yf.js"; import { t as emitSessionTranscriptUpdate } from "./transcript-events-DW_H__a1.js"; import os from "node:os"; import path from "node:path"; import fs from "node:fs"; import JSON5 from "json5"; import fs$1 from "node:fs/promises"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import crypto, { createHash } from "node:crypto"; import { CURRENT_SESSION_VERSION, SessionManager, formatSkillsForPrompt, loadSkillsFromDir } from "@mariozechner/pi-coding-agent"; import { fileTypeFromBuffer } from "file-type"; import YAML from "yaml"; import express from "express"; import { lookup } from "node:dns"; import { lookup as lookup$1 } from "node:dns/promises"; import { Agent } from "undici"; //#region src/discord/token.ts function normalizeDiscordToken(raw) { if (!raw) return; const trimmed = raw.trim(); if (!trimmed) return; return trimmed.replace(/^Bot\s+/i, ""); } function resolveDiscordToken(cfg, opts = {}) { const accountId = normalizeAccountId$1(opts.accountId); const discordCfg = cfg?.channels?.discord; const accountToken = normalizeDiscordToken((accountId !== DEFAULT_ACCOUNT_ID ? discordCfg?.accounts?.[accountId] : discordCfg?.accounts?.[DEFAULT_ACCOUNT_ID])?.token ?? void 0); if (accountToken) return { token: accountToken, source: "config" }; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const configToken = allowEnv ? normalizeDiscordToken(discordCfg?.token ?? void 0) : void 0; if (configToken) return { token: configToken, source: "config" }; const envToken = allowEnv ? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN) : void 0; if (envToken) return { token: envToken, source: "env" }; return { token: "", source: "none" }; } //#endregion //#region src/discord/accounts.ts function listConfiguredAccountIds$2(cfg) { const accounts = cfg.channels?.discord?.accounts; if (!accounts || typeof accounts !== "object") return []; return Object.keys(accounts).filter(Boolean); } function listDiscordAccountIds(cfg) { const ids = listConfiguredAccountIds$2(cfg); if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; return ids.toSorted((a, b) => a.localeCompare(b)); } function resolveAccountConfig$4(cfg, accountId) { const accounts = cfg.channels?.discord?.accounts; if (!accounts || typeof accounts !== "object") return; return accounts[accountId]; } function mergeDiscordAccountConfig(cfg, accountId) { const { accounts: _ignored, ...base } = cfg.channels?.discord ?? {}; const account = resolveAccountConfig$4(cfg, accountId) ?? {}; return { ...base, ...account }; } function resolveDiscordAccount(params) { const accountId = normalizeAccountId$1(params.accountId); const baseEnabled = params.cfg.channels?.discord?.enabled !== false; const merged = mergeDiscordAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const tokenResolution = resolveDiscordToken(params.cfg, { accountId }); return { accountId, enabled, name: merged.name?.trim() || void 0, token: tokenResolution.token, tokenSource: tokenResolution.source, config: merged }; } function listEnabledDiscordAccounts(cfg) { return listDiscordAccountIds(cfg).map((accountId) => resolveDiscordAccount({ cfg, accountId })).filter((account) => account.enabled); } //#endregion //#region src/channels/chat-type.ts function normalizeChatType(raw) { const value = raw?.trim().toLowerCase(); if (!value) return; if (value === "direct" || value === "dm") return "direct"; if (value === "group") return "group"; if (value === "channel") return "channel"; } //#endregion //#region src/slack/token.ts function normalizeSlackToken(raw) { const trimmed = raw?.trim(); return trimmed ? trimmed : void 0; } function resolveSlackBotToken(raw) { return normalizeSlackToken(raw); } function resolveSlackAppToken(raw) { return normalizeSlackToken(raw); } //#endregion //#region src/slack/accounts.ts function resolveAccountConfig$3(cfg, accountId) { const accounts = cfg.channels?.slack?.accounts; if (!accounts || typeof accounts !== "object") return; return accounts[accountId]; } function mergeSlackAccountConfig(cfg, accountId) { const { accounts: _ignored, ...base } = cfg.channels?.slack ?? {}; const account = resolveAccountConfig$3(cfg, accountId) ?? {}; return { ...base, ...account }; } function resolveSlackAccount(params) { const accountId = normalizeAccountId$1(params.accountId); const baseEnabled = params.cfg.channels?.slack?.enabled !== false; const merged = mergeSlackAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : void 0; const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : void 0; const configBot = resolveSlackBotToken(merged.botToken); const configApp = resolveSlackAppToken(merged.appToken); const botToken = configBot ?? envBot; const appToken = configApp ?? envApp; const botTokenSource = configBot ? "config" : envBot ? "env" : "none"; const appTokenSource = configApp ? "config" : envApp ? "env" : "none"; return { accountId, enabled, name: merged.name?.trim() || void 0, botToken, appToken, botTokenSource, appTokenSource, config: merged, groupPolicy: merged.groupPolicy, textChunkLimit: merged.textChunkLimit, mediaMaxMb: merged.mediaMaxMb, reactionNotifications: merged.reactionNotifications, reactionAllowlist: merged.reactionAllowlist, replyToMode: merged.replyToMode, replyToModeByChatType: merged.replyToModeByChatType, actions: merged.actions, slashCommand: merged.slashCommand, dm: merged.dm, channels: merged.channels }; } function resolveSlackReplyToMode(account, chatType) { const normalized = normalizeChatType(chatType ?? void 0); if (normalized && account.replyToModeByChatType?.[normalized] !== void 0) return account.replyToModeByChatType[normalized] ?? "off"; if (normalized === "direct" && account.dm?.replyToMode !== void 0) return account.dm.replyToMode; return account.replyToMode ?? "off"; } //#endregion //#region src/routing/bindings.ts function normalizeBindingChannelId(raw) { const normalized = normalizeChatChannelId(raw); if (normalized) return normalized; return (raw ?? "").trim().toLowerCase() || null; } function listBindings(cfg) { return Array.isArray(cfg.bindings) ? cfg.bindings : []; } function listBoundAccountIds(cfg, channelId) { const normalizedChannel = normalizeBindingChannelId(channelId); if (!normalizedChannel) return []; const ids = /* @__PURE__ */ new Set(); for (const binding of listBindings(cfg)) { if (!binding || typeof binding !== "object") continue; const match = binding.match; if (!match || typeof match !== "object") continue; const channel = normalizeBindingChannelId(match.channel); if (!channel || channel !== normalizedChannel) continue; const accountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; if (!accountId || accountId === "*") continue; ids.add(normalizeAccountId$1(accountId)); } return Array.from(ids).toSorted((a, b) => a.localeCompare(b)); } function resolveDefaultAgentBoundAccountId(cfg, channelId) { const normalizedChannel = normalizeBindingChannelId(channelId); if (!normalizedChannel) return null; const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); for (const binding of listBindings(cfg)) { if (!binding || typeof binding !== "object") continue; if (normalizeAgentId(binding.agentId) !== defaultAgentId) continue; const match = binding.match; if (!match || typeof match !== "object") continue; const channel = normalizeBindingChannelId(match.channel); if (!channel || channel !== normalizedChannel) continue; const accountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; if (!accountId || accountId === "*") continue; return normalizeAccountId$1(accountId); } return null; } //#endregion //#region src/telegram/token.ts function resolveTelegramToken(cfg, opts = {}) { const accountId = normalizeAccountId$1(opts.accountId); const telegramCfg = cfg?.channels?.telegram; const resolveAccountCfg = (id) => { const accounts = telegramCfg?.accounts; if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) return; const direct = accounts[id]; if (direct) return direct; const matchKey = Object.keys(accounts).find((key) => normalizeAccountId$1(key) === id); return matchKey ? accounts[matchKey] : void 0; }; const accountCfg = resolveAccountCfg(accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID); const accountTokenFile = accountCfg?.tokenFile?.trim(); if (accountTokenFile) { if (!fs.existsSync(accountTokenFile)) { opts.logMissingFile?.(`channels.telegram.accounts.${accountId}.tokenFile not found: ${accountTokenFile}`); return { token: "", source: "none" }; } try { const token = fs.readFileSync(accountTokenFile, "utf-8").trim(); if (token) return { token, source: "tokenFile" }; } catch (err) { opts.logMissingFile?.(`channels.telegram.accounts.${accountId}.tokenFile read failed: ${String(err)}`); return { token: "", source: "none" }; } return { token: "", source: "none" }; } const accountToken = accountCfg?.botToken?.trim(); if (accountToken) return { token: accountToken, source: "config" }; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const tokenFile = telegramCfg?.tokenFile?.trim(); if (tokenFile && allowEnv) { if (!fs.existsSync(tokenFile)) { opts.logMissingFile?.(`channels.telegram.tokenFile not found: ${tokenFile}`); return { token: "", source: "none" }; } try { const token = fs.readFileSync(tokenFile, "utf-8").trim(); if (token) return { token, source: "tokenFile" }; } catch (err) { opts.logMissingFile?.(`channels.telegram.tokenFile read failed: ${String(err)}`); return { token: "", source: "none" }; } } const configToken = telegramCfg?.botToken?.trim(); if (configToken && allowEnv) return { token: configToken, source: "config" }; const envToken = allowEnv ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : ""; if (envToken) return { token: envToken, source: "env" }; return { token: "", source: "none" }; } //#endregion //#region src/telegram/accounts.ts const debugAccounts = (...args) => { if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS)) console.warn("[telegram:accounts]", ...args); }; function listConfiguredAccountIds$1(cfg) { const accounts = cfg.channels?.telegram?.accounts; if (!accounts || typeof accounts !== "object") return []; const ids = /* @__PURE__ */ new Set(); for (const key of Object.keys(accounts)) { if (!key) continue; ids.add(normalizeAccountId$1(key)); } return [...ids]; } function listTelegramAccountIds(cfg) { const ids = Array.from(new Set([...listConfiguredAccountIds$1(cfg), ...listBoundAccountIds(cfg, "telegram")])); debugAccounts("listTelegramAccountIds", ids); if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; return ids.toSorted((a, b) => a.localeCompare(b)); } function resolveDefaultTelegramAccountId(cfg) { const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); if (boundDefault) return boundDefault; const ids = listTelegramAccountIds(cfg); if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID; } function resolveAccountConfig$2(cfg, accountId) { const accounts = cfg.channels?.telegram?.accounts; if (!accounts || typeof accounts !== "object") return; const direct = accounts[accountId]; if (direct) return direct; const normalized = normalizeAccountId$1(accountId); const matchKey = Object.keys(accounts).find((key) => normalizeAccountId$1(key) === normalized); return matchKey ? accounts[matchKey] : void 0; } function mergeTelegramAccountConfig(cfg, accountId) { const { accounts: _ignored, ...base } = cfg.channels?.telegram ?? {}; const account = resolveAccountConfig$2(cfg, accountId) ?? {}; return { ...base, ...account }; } function resolveTelegramAccount(params) { const hasExplicitAccountId = Boolean(params.accountId?.trim()); const baseEnabled = params.cfg.channels?.telegram?.enabled !== false; const resolve = (accountId) => { const merged = mergeTelegramAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); debugAccounts("resolve", { accountId, enabled, tokenSource: tokenResolution.source }); return { accountId, enabled, name: merged.name?.trim() || void 0, token: tokenResolution.token, tokenSource: tokenResolution.source, config: merged }; }; const primary = resolve(normalizeAccountId$1(params.accountId)); if (hasExplicitAccountId) return primary; if (primary.tokenSource !== "none") return primary; const fallbackId = resolveDefaultTelegramAccountId(params.cfg); if (fallbackId === primary.accountId) return primary; const fallback = resolve(fallbackId); if (fallback.tokenSource === "none") return primary; return fallback; } function listEnabledTelegramAccounts(cfg) { return listTelegramAccountIds(cfg).map((accountId) => resolveTelegramAccount({ cfg, accountId })).filter((account) => account.enabled); } //#endregion //#region src/whatsapp/normalize.ts const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; const WHATSAPP_LID_RE = /^(\d+)@lid$/i; function stripWhatsAppTargetPrefixes(value) { let candidate = value.trim(); for (;;) { const before = candidate; candidate = candidate.replace(/^whatsapp:/i, "").trim(); if (candidate === before) return candidate; } } function isWhatsAppGroupJid(value) { const candidate = stripWhatsAppTargetPrefixes(value); if (!candidate.toLowerCase().endsWith("@g.us")) return false; const localPart = candidate.slice(0, candidate.length - 5); if (!localPart || localPart.includes("@")) return false; return /^[0-9]+(-[0-9]+)*$/.test(localPart); } /** * Check if value looks like a WhatsApp user target (e.g. "41796666864:0@s.whatsapp.net" or "123@lid"). */ function isWhatsAppUserTarget(value) { const candidate = stripWhatsAppTargetPrefixes(value); return WHATSAPP_USER_JID_RE.test(candidate) || WHATSAPP_LID_RE.test(candidate); } /** * Extract the phone number from a WhatsApp user JID. * "41796666864:0@s.whatsapp.net" -> "41796666864" * "123456@lid" -> "123456" */ function extractUserJidPhone(jid) { const userMatch = jid.match(WHATSAPP_USER_JID_RE); if (userMatch) return userMatch[1]; const lidMatch = jid.match(WHATSAPP_LID_RE); if (lidMatch) return lidMatch[1]; return null; } function normalizeWhatsAppTarget(value) { const candidate = stripWhatsAppTargetPrefixes(value); if (!candidate) return null; if (isWhatsAppGroupJid(candidate)) return `${candidate.slice(0, candidate.length - 5)}@g.us`; if (isWhatsAppUserTarget(candidate)) { const phone = extractUserJidPhone(candidate); if (!phone) return null; const normalized = normalizeE164(phone); return normalized.length > 1 ? normalized : null; } if (candidate.includes("@")) return null; const normalized = normalizeE164(candidate); return normalized.length > 1 ? normalized : null; } //#endregion //#region src/channels/plugins/index.ts function listPluginChannels() { return requireActivePluginRegistry().channels.map((entry) => entry.plugin); } function dedupeChannels(channels) { const seen = /* @__PURE__ */ new Set(); const resolved = []; for (const plugin of channels) { const id = String(plugin.id).trim(); if (!id || seen.has(id)) continue; seen.add(id); resolved.push(plugin); } return resolved; } function listChannelPlugins() { return dedupeChannels(listPluginChannels()).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB); if (orderA !== orderB) return orderA - orderB; return a.id.localeCompare(b.id); }); } function getChannelPlugin(id) { const resolvedId = String(id).trim(); if (!resolvedId) return; return listChannelPlugins().find((plugin) => plugin.id === resolvedId); } function normalizeChannelId(raw) { return normalizeAnyChannelId(raw); } //#endregion //#region src/signal/accounts.ts function listConfiguredAccountIds(cfg) { const accounts = cfg.channels?.signal?.accounts; if (!accounts || typeof accounts !== "object") return []; return Object.keys(accounts).filter(Boolean); } function listSignalAccountIds(cfg) { const ids = listConfiguredAccountIds(cfg); if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; return ids.toSorted((a, b) => a.localeCompare(b)); } function resolveAccountConfig$1(cfg, accountId) { const accounts = cfg.channels?.signal?.accounts; if (!accounts || typeof accounts !== "object") return; return accounts[accountId]; } function mergeSignalAccountConfig(cfg, accountId) { const { accounts: _ignored, ...base } = cfg.channels?.signal ?? {}; const account = resolveAccountConfig$1(cfg, accountId) ?? {}; return { ...base, ...account }; } function resolveSignalAccount(params) { const accountId = normalizeAccountId$1(params.accountId); const baseEnabled = params.cfg.channels?.signal?.enabled !== false; const merged = mergeSignalAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const host = merged.httpHost?.trim() || "127.0.0.1"; const port = merged.httpPort ?? 8080; const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`; const configured = Boolean(merged.account?.trim() || merged.httpUrl?.trim() || merged.cliPath?.trim() || merged.httpHost?.trim() || typeof merged.httpPort === "number" || typeof merged.autoStart === "boolean"); return { accountId, enabled, name: merged.name?.trim() || void 0, baseUrl, configured, config: merged }; } function listEnabledSignalAccounts(cfg) { return listSignalAccountIds(cfg).map((accountId) => resolveSignalAccount({ cfg, accountId })).filter((account) => account.enabled); } //#endregion //#region src/media/constants.ts const MAX_IMAGE_BYTES$1 = 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$1; 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/gateway/protocol/client-info.ts const GATEWAY_CLIENT_IDS = { WEBCHAT_UI: "webchat-ui", CONTROL_UI: "openclaw-control-ui", WEBCHAT: "webchat", CLI: "cli", GATEWAY_CLIENT: "gateway-client", MACOS_APP: "openclaw-macos", IOS_APP: "openclaw-ios", ANDROID_APP: "openclaw-android", NODE_HOST: "node-host", TEST: "test", FINGERPRINT: "fingerprint", PROBE: "openclaw-probe" }; const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS; const GATEWAY_CLIENT_MODES = { WEBCHAT: "webchat", CLI: "cli", UI: "ui", BACKEND: "backend", NODE: "node", PROBE: "probe", TEST: "test" }; const GATEWAY_CLIENT_ID_SET = new Set(Object.values(GATEWAY_CLIENT_IDS)); const GATEWAY_CLIENT_MODE_SET = new Set(Object.values(GATEWAY_CLIENT_MODES)); //#endregion //#region src/utils/message-channel.ts const INTERNAL_MESSAGE_CHANNEL = "webchat"; const MARKDOWN_CAPABLE_CHANNELS = new Set([ "slack", "telegram", "signal", "discord", "googlechat", "tui", INTERNAL_MESSAGE_CHANNEL ]); function isInternalMessageChannel(raw) { return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL; } function normalizeMessageChannel(raw) { const normalized = raw?.trim().toLowerCase(); if (!normalized) return; if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL; const builtIn = normalizeChatChannelId(normalized); if (builtIn) return builtIn; return (getActivePluginRegistry()?.channels.find((entry) => { if (entry.plugin.id.toLowerCase() === normalized) return true; return (entry.plugin.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === normalized); }))?.plugin.id ?? normalized; } const listPluginChannelIds = () => { const registry = getActivePluginRegistry(); if (!registry) return []; return registry.channels.map((entry) => entry.plugin.id); }; const listDeliverableMessageChannels = () => Array.from(new Set([...CHANNEL_IDS, ...listPluginChannelIds()])); const listGatewayMessageChannels = () => [...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]; function isGatewayMessageChannel(value) { return listGatewayMessageChannels().includes(value); } function isDeliverableMessageChannel(value) { return listDeliverableMessageChannels().includes(value); } function resolveGatewayMessageChannel(raw) { const normalized = normalizeMessageChannel(raw); if (!normalized) return; return isGatewayMessageChannel(normalized) ? normalized : void 0; } function resolveMessageChannel(primary, fallback) { return normalizeMessageChannel(primary) ?? normalizeMessageChannel(fallback); } function isMarkdownCapableMessageChannel(raw) { const channel = normalizeMessageChannel(raw); if (!channel) return false; return MARKDOWN_CAPABLE_CHANNELS.has(channel); } //#endregion //#region src/agents/pi-embedded-helpers/bootstrap.ts function isBase64Signature(value) { const trimmed = value.trim(); if (!trimmed) return false; const compact = trimmed.replace(/\s+/g, ""); if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false; const isUrl = compact.includes("-") || compact.includes("_"); try { const buf = Buffer.from(compact, isUrl ? "base64url" : "base64"); if (buf.length === 0) return false; const encoded = buf.toString(isUrl ? "base64url" : "base64"); const normalize = (input) => input.replace(/=+$/g, ""); return normalize(encoded) === normalize(compact); } catch { return false; } } /** * Strips Claude-style thought_signature fields from content blocks. * * Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids * like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures. */ function stripThoughtSignatures(content, options) { if (!Array.isArray(content)) return content; const allowBase64Only = options?.allowBase64Only ?? false; const includeCamelCase = options?.includeCamelCase ?? false; const shouldStripSignature = (value) => { if (!allowBase64Only) return typeof value === "string" && value.startsWith("msg_"); return typeof value !== "string" || !isBase64Signature(value); }; return content.map((block) => { if (!block || typeof block !== "object") return block; const rec = block; const stripSnake = shouldStripSignature(rec.thought_signature); const stripCamel = includeCamelCase ? shouldStripSignature(rec.thoughtSignature) : false; if (!stripSnake && !stripCamel) return block; const next = { ...rec }; if (stripSnake) delete next.thought_signature; if (stripCamel) delete next.thoughtSignature; return next; }); } const DEFAULT_BOOTSTRAP_MAX_CHARS = 2e4; const BOOTSTRAP_HEAD_RATIO = .7; const BOOTSTRAP_TAIL_RATIO = .2; function resolveBootstrapMaxChars(cfg) { const raw = cfg?.agents?.defaults?.bootstrapMaxChars; if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw); return DEFAULT_BOOTSTRAP_MAX_CHARS; } function trimBootstrapContent(content, fileName, maxChars) { const trimmed = content.trimEnd(); if (trimmed.length <= maxChars) return { content: trimmed, truncated: false, maxChars, originalLength: trimmed.length }; const headChars = Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO); const tailChars = Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO); const head = trimmed.slice(0, headChars); const tail = trimmed.slice(-tailChars); return { content: [ head, [ "", `[...truncated, read ${fileName} for full content...]`, `…(truncated ${fileName}: kept ${headChars}+${tailChars} chars of ${trimmed.length})…`, "" ].join("\n"), tail ].join("\n"), truncated: true, maxChars, originalLength: trimmed.length }; } async function ensureSessionHeader$1(params) { const file = params.sessionFile; try { await fs$1.stat(file); return; } catch {} await fs$1.mkdir(path.dirname(file), { recursive: true }); const entry = { type: "session", version: 2, id: params.sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), cwd: params.cwd }; await fs$1.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); } function buildBootstrapContextFiles(files, opts) { const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS; const result = []; for (const file of files) { if (file.missing) { result.push({ path: file.name, content: `[MISSING] Expected at: ${file.path}` }); continue; } const trimmed = trimBootstrapContent(file.content ?? "", file.name, maxChars); if (!trimmed.content) continue; if (trimmed.truncated) opts?.warn?.(`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`); result.push({ path: file.name, content: trimmed.content }); } return result; } function sanitizeGoogleTurnOrdering(messages) { const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)"; const first = messages[0]; const role = first?.role; const content = first?.content; if (role === "user" && typeof content === "string" && content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT) return messages; if (role !== "assistant") return messages; return [{ role: "user", content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT, timestamp: Date.now() }, ...messages]; } //#endregion //#region src/agents/sandbox/constants.ts const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(os.homedir(), ".openclaw", "sandboxes"); const DEFAULT_SANDBOX_IMAGE = "openclaw-sandbox:bookworm-slim"; const DEFAULT_SANDBOX_CONTAINER_PREFIX = "openclaw-sbx-"; const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_IDLE_HOURS = 24; const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; const DEFAULT_TOOL_ALLOW = [ "exec", "process", "read", "write", "edit", "apply_patch", "image", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status" ]; const DEFAULT_TOOL_DENY = [ "browser", "canvas", "nodes", "cron", "gateway", ...CHANNEL_IDS ]; const DEFAULT_SANDBOX_BROWSER_IMAGE = "openclaw-sandbox-browser:bookworm-slim"; const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12e3; const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; const resolvedSandboxStateDir = STATE_DIR ?? path.join(os.homedir(), ".openclaw"); const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox"); const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json"); //#endregion //#region src/agents/tool-policy.ts const TOOL_NAME_ALIASES = { bash: "exec", "apply-patch": "apply_patch" }; const TOOL_GROUPS = { "group:memory": ["memory_search", "memory_get"], "group:web": ["web_search", "web_fetch"], "group:fs": [ "read", "write", "edit", "apply_patch" ], "group:runtime": ["exec", "process"], "group:sessions": [ "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status" ], "group:ui": ["browser", "canvas"], "group:automation": ["cron", "gateway"], "group:messaging": ["message"], "group:nodes": ["nodes"], "group:openclaw": [ "browser", "canvas", "nodes", "cron", "message", "gateway", "agents_list", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "memory_search", "memory_get", "web_search", "web_fetch", "image" ] }; const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login"]); const TOOL_PROFILES = { minimal: { allow: ["session_status"] }, coding: { allow: [ "group:fs", "group:runtime", "group:sessions", "group:memory", "image" ] }, messaging: { allow: [ "group:messaging", "sessions_list", "sessions_history", "sessions_send", "session_status" ] }, full: {} }; function normalizeToolName(name) { const normalized = name.trim().toLowerCase(); return TOOL_NAME_ALIASES[normalized] ?? normalized; } function isOwnerOnlyToolName(name) { return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name)); } function applyOwnerOnlyToolPolicy(tools, senderIsOwner) { const withGuard = tools.map((tool) => { if (!isOwnerOnlyToolName(tool.name)) return tool; if (senderIsOwner || !tool.execute) return tool; return { ...tool, execute: async () => { throw new Error("Tool restricted to owner senders."); } }; }); if (senderIsOwner) return withGuard; return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name)); } function normalizeToolList(list) { if (!list) return []; return list.map(normalizeToolName).filter(Boolean); } function expandToolGroups(list) { const normalized = normalizeToolList(list); const expanded = []; for (const value of normalized) { const group = TOOL_GROUPS[value]; if (group) { expanded.push(...group); continue; } expanded.push(value); } return Array.from(new Set(expanded)); } function collectExplicitAllowlist(policies) { const entries = []; for (const policy of policies) { if (!policy?.allow) continue; for (const value of policy.allow) { if (typeof value !== "string") continue; const trimmed = value.trim(); if (trimmed) entries.push(trimmed); } } return entries; } function buildPluginToolGroups(params) { const all = []; const byPlugin = /* @__PURE__ */ new Map(); for (const tool of params.tools) { const meta = params.toolMeta(tool); if (!meta) continue; const name = normalizeToolName(tool.name); all.push(name); const pluginId = meta.pluginId.toLowerCase(); const list = byPlugin.get(pluginId) ?? []; list.push(name); byPlugin.set(pluginId, list); } return { all, byPlugin }; } function expandPluginGroups(list, groups) { if (!list || list.length === 0) return list; const expanded = []; for (const entry of list) { const normalized = normalizeToolName(entry); if (normalized === "group:plugins") { if (groups.all.length > 0) expanded.push(...groups.all); else expanded.push(normalized); continue; } const tools = groups.byPlugin.get(normalized); if (tools && tools.length > 0) { expanded.push(...tools); continue; } expanded.push(normalized); } return Array.from(new Set(expanded)); } function expandPolicyWithPluginGroups(policy, groups) { if (!policy) return; return { allow: expandPluginGroups(policy.allow, groups), deny: expandPluginGroups(policy.deny, groups) }; } function stripPluginOnlyAllowlist(policy, groups, coreTools) { if (!policy?.allow || policy.allow.length === 0) return { policy, unknownAllowlist: [], strippedAllowlist: false }; const normalized = normalizeToolList(policy.allow); if (normalized.length === 0) return { policy, unknownAllowlist: [], strippedAllowlist: false }; const pluginIds = new Set(groups.byPlugin.keys()); const pluginTools = new Set(groups.all); const unknownAllowlist = []; let hasCoreEntry = false; for (const entry of normalized) { if (entry === "*") { hasCoreEntry = true; continue; } const isPluginEntry = entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry); const isCoreEntry = expandToolGroups([entry]).some((tool) => coreTools.has(tool)); if (isCoreEntry) hasCoreEntry = true; if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry); } const strippedAllowlist = !hasCoreEntry; if (strippedAllowlist) {} return { policy: strippedAllowlist ? { ...policy, allow: void 0 } : policy, unknownAllowlist: Array.from(new Set(unknownAllowlist)), strippedAllowlist }; } function resolveToolProfilePolicy(profile) { if (!profile) return; const resolved = TOOL_PROFILES[profile]; if (!resolved) return; if (!resolved.allow && !resolved.deny) return; return { allow: resolved.allow ? [...resolved.allow] : void 0, deny: resolved.deny ? [...resolved.deny] : void 0 }; } //#endregion //#region src/agents/sandbox/tool-policy.ts function compilePattern(pattern) { const normalized = pattern.trim().toLowerCase(); if (!normalized) return { kind: "exact", value: "" }; if (normalized === "*") return { kind: "all" }; if (!normalized.includes("*")) return { kind: "exact", value: normalized }; const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return { kind: "regex", value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`) }; } function compilePatterns(patterns) { if (!Array.isArray(patterns)) return []; return expandToolGroups(patterns).map(compilePattern).filter((pattern) => pattern.kind !== "exact" || pattern.value); } function matchesAny(name, patterns) { for (const pattern of patterns) { if (pattern.kind === "all") return true; if (pattern.kind === "exact" && name === pattern.value) return true; if (pattern.kind === "regex" && pattern.value.test(name)) return true; } return false; } function isToolAllowed(policy, name) { const normalized = name.trim().toLowerCase(); if (matchesAny(normalized, compilePatterns(policy.deny))) return false; const allow = compilePatterns(policy.allow); if (allow.length === 0) return true; return matchesAny(normalized, allow); } function resolveSandboxToolPolicyForAgent(cfg, agentId) { const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : void 0; const agentAllow = agentConfig?.tools?.sandbox?.tools?.allow; const agentDeny = agentConfig?.tools?.sandbox?.tools?.deny; const globalAllow = cfg?.tools?.sandbox?.tools?.allow; const globalDeny = cfg?.tools?.sandbox?.tools?.deny; const allowSource = Array.isArray(agentAllow) ? { source: "agent", key: "agents.list[].tools.sandbox.tools.allow" } : Array.isArray(globalAllow) ? { source: "global", key: "tools.sandbox.tools.allow" } : { source: "default", key: "tools.sandbox.tools.allow" }; const denySource = Array.isArray(agentDeny) ? { source: "agent", key: "agents.list[].tools.sandbox.tools.deny" } : Array.isArray(globalDeny) ? { source: "global", key: "tools.sandbox.tools.deny" } : { source: "default", key: "tools.sandbox.tools.deny" }; const deny = Array.isArray(agentDeny) ? agentDeny : Array.isArray(globalDeny) ? globalDeny : [...DEFAULT_TOOL_DENY]; const allow = Array.isArray(agentAllow) ? agentAllow : Array.isArray(globalAllow) ? globalAllow : [...DEFAULT_TOOL_ALLOW]; const expandedDeny = expandToolGroups(deny); let expandedAllow = expandToolGroups(allow); if (!expandedDeny.map((v) => v.toLowerCase()).includes("image") && !expandedAllow.map((v) => v.toLowerCase()).includes("image")) expandedAllow = [...expandedAllow, "image"]; return { allow: expandedAllow, deny: expandedDeny, sources: { allow: allowSource, deny: denySource } }; } //#endregion //#region src/agents/sandbox/config.ts function resolveSandboxScope(params) { if (params.scope) return params.scope; if (typeof params.perSession === "boolean") return params.perSession ? "session" : "shared"; return "agent"; } function resolveSandboxDockerConfig(params) { const agentDocker = params.scope === "shared" ? void 0 : params.agentDocker; const globalDocker = params.globalDocker; const env = agentDocker?.env ? { ...globalDocker?.env ?? { LANG: "C.UTF-8" }, ...agentDocker.env } : globalDocker?.env ?? { LANG: "C.UTF-8" }; const ulimits = agentDocker?.ulimits ? { ...globalDocker?.ulimits, ...agentDocker.ulimits } : globalDocker?.ulimits; const binds = [...globalDocker?.binds ?? [], ...agentDocker?.binds ?? []]; return { image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, containerPrefix: agentDocker?.containerPrefix ?? globalDocker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX, workdir: agentDocker?.workdir ?? globalDocker?.workdir ?? DEFAULT_SANDBOX_WORKDIR, readOnlyRoot: agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true, tmpfs: agentDocker?.tmpfs ?? globalDocker?.tmpfs ?? [ "/tmp", "/var/tmp", "/run" ], network: agentDocker?.network ?? globalDocker?.network ?? "none", user: agentDocker?.user ?? globalDocker?.user, capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], env, setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand, pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit, memory: agentDocker?.memory ?? globalDocker?.memory, memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap, cpus: agentDocker?.cpus ?? globalDocker?.cpus, ulimits, seccompProfile: agentDocker?.seccompProfile ?? globalDocker?.seccompProfile, apparmorProfile: agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile, dns: agentDocker?.dns ?? globalDocker?.dns, extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, binds: binds.length ? binds : void 0 }; } function resolveSandboxBrowserConfig(params) { const agentBrowser = params.scope === "shared" ? void 0 : params.agentBrowser; const globalBrowser = params.globalBrowser; return { enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false, image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, containerPrefix: agentBrowser?.containerPrefix ?? globalBrowser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX, cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, noVncPort: agentBrowser?.noVncPort ?? globalBrowser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true, allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false, autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true, autoStartTimeoutMs: agentBrowser?.autoStartTimeoutMs ?? globalBrowser?.autoStartTimeoutMs ?? DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS }; } function resolveSandboxPruneConfig(params) { const agentPrune = params.scope === "shared" ? void 0 : params.agentPrune; const globalPrune = params.globalPrune; return { idleHours: agentPrune?.idleHours ?? globalPrune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS, maxAgeDays: agentPrune?.maxAgeDays ?? globalPrune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS }; } function resolveSandboxConfigForAgent(cfg, agentId) { const agent = cfg?.agents?.defaults?.sandbox; let agentSandbox; const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : void 0; if (agentConfig?.sandbox) agentSandbox = agentConfig.sandbox; const scope = resolveSandboxScope({ scope: agentSandbox?.scope ?? agent?.scope, perSession: agentSandbox?.perSession ?? agent?.perSession }); const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, agentId); return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: agentSandbox?.workspaceRoot ?? agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, docker: resolveSandboxDockerConfig({ scope, globalDocker: agent?.docker, agentDocker: agentSandbox?.docker }), browser: resolveSandboxBrowserConfig({ scope, globalBrowser: agent?.browser, agentBrowser: agentSandbox?.browser }), tools: { allow: toolPolicy.allow, deny: toolPolicy.deny }, prune: resolveSandboxPruneConfig({ scope, globalPrune: agent?.prune, agentPrune: agentSandbox?.prune }) }; } //#endregion //#region src/markdown/frontmatter.ts function stripQuotes(value) { if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1); return value; } function coerceFrontmatterValue(value) { if (value === null || value === void 0) return; if (typeof value === "string") return value.trim(); if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "object") try { return JSON.stringify(value); } catch { return; } } function parseYamlFrontmatter(block) { try { const parsed = YAML.parse(block); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const result = {}; for (const [rawKey, value] of Object.entries(parsed)) { const key = rawKey.trim(); if (!key) continue; const coerced = coerceFrontmatterValue(value); if (coerced === void 0) continue; result[key] = coerced; } return result; } catch { return null; } } function extractMultiLineValue(lines, startIndex) { const match = lines[startIndex].match(/^([\w-]+):\s*(.*)$/); if (!match) return { value: "", linesConsumed: 1 }; const inlineValue = match[2].trim(); if (inlineValue) return { value: inlineValue, linesConsumed: 1 }; const valueLines = []; let i = startIndex + 1; while (i < lines.length) { const line = lines[i]; if (line.length > 0 && !line.startsWith(" ") && !line.startsWith(" ")) break; valueLines.push(line); i++; } return { value: valueLines.join("\n").trim(), linesConsumed: i - startIndex }; } function parseLineFrontmatter(block) { const frontmatter = {}; const lines = block.split("\n"); let i = 0; while (i < lines.length) { const match = lines[i].match(/^([\w-]+):\s*(.*)$/); if (!match) { i++; continue; } const key = match[1]; const inlineValue = match[2].trim(); if (!key) { i++; continue; } if (!inlineValue && i + 1 < lines.length) { const nextLine = lines[i + 1]; if (nextLine.startsWith(" ") || nextLine.startsWith(" ")) { const { value, linesConsumed } = extractMultiLineValue(lines, i); if (value) frontmatter[key] = value; i += linesConsumed; continue; } } const value = stripQuotes(inlineValue); if (value) frontmatter[key] = value; i++; } return frontmatter; } function parseFrontmatterBlock(content) { const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (!normalized.startsWith("---")) return {}; const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) return {}; const block = normalized.slice(4, endIndex); const lineParsed = parseLineFrontmatter(block); const yamlParsed = parseYamlFrontmatter(block); if (yamlParsed === null) return lineParsed; const merged = { ...yamlParsed }; for (const [key, value] of Object.entries(lineParsed)) if (value.startsWith("{") || value.startsWith("[")) merged[key] = value; return merged; } //#endregion //#region src/agents/skills/frontmatter.ts function parseFrontmatter(content) { return parseFrontmatterBlock(content); } function normalizeStringList(input) { if (!input) return []; if (Array.isArray(input)) return input.map((value) => String(value).trim()).filter(Boolean); if (typeof input === "string") return input.split(",").map((value) => value.trim()).filter(Boolean); return []; } function parseInstallSpec(input) { if (!input || typeof input !== "object") return; const raw = input; const kind = (typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "").trim().toLowerCase(); if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") return; const spec = { kind }; if (typeof raw.id === "string") spec.id = raw.id; if (typeof raw.label === "string") spec.label = raw.label; const bins = normalizeStringList(raw.bins); if (bins.length > 0) spec.bins = bins; const osList = normalizeStringList(raw.os); if (osList.length > 0) spec.os = osList; if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.module === "string") spec.module = raw.module; if (typeof raw.url === "string") spec.url = raw.url; if (typeof raw.archive === "string") spec.archive = raw.archive; if (typeof raw.extract === "boolean") spec.extract = raw.extract; if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents; if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir; return spec; } function getFrontmatterValue(frontmatter, key) { const raw = frontmatter[key]; return typeof raw === "string" ? raw : void 0; } function parseFrontmatterBool(value, fallback) { const parsed = parseBooleanValue(value); return parsed === void 0 ? fallback : parsed; } function resolveOpenClawMetadata(frontmatter) { const raw = getFrontmatterValue(frontmatter, "metadata"); if (!raw) return; try { const parsed = JSON5.parse(raw); if (!parsed || typeof parsed !== "object") return; const metadataRawCandidates = [MANIFEST_KEY, ...LEGACY_MANIFEST_KEYS]; let metadataRaw; for (const key of metadataRawCandidates) { const candidate = parsed[key]; if (candidate && typeof candidate === "object") { metadataRaw = candidate; break; } } if (!metadataRaw || typeof metadataRaw !== "object") return; const metadataObj = metadataRaw; const requiresRaw = typeof metadataObj.requires === "object" && metadataObj.requires !== null ? metadataObj.requires : void 0; const install = (Array.isArray(metadataObj.install) ? metadataObj.install : []).map((entry) => parseInstallSpec(entry)).filter((entry) => Boolean(entry)); const osRaw = normalizeStringList(metadataObj.os); return { always: typeof metadataObj.always === "boolean" ? metadataObj.always : void 0, emoji: typeof metadataObj.emoji === "string" ? metadataObj.emoji : void 0, homepage: typeof metadataObj.homepage === "string" ? metadataObj.homepage : void 0, skillKey: typeof metadataObj.skillKey === "string" ? metadataObj.skillKey : void 0, primaryEnv: typeof metadataObj.primaryEnv === "string" ? metadataObj.primaryEnv : void 0, os: osRaw.length > 0 ? osRaw : void 0, requires: requiresRaw ? { bins: normalizeStringList(requiresRaw.bins), anyBins: normalizeStringList(requiresRaw.anyBins), env: normalizeStringList(requiresRaw.env), config: normalizeStringList(requiresRaw.config) } : void 0, install: install.length > 0 ? install : void 0 }; } catch { return; } } function resolveSkillInvocationPolicy(frontmatter) { return { userInvocable: parseFrontmatterBool(getFrontmatterValue(frontmatter, "user-invocable"), true), disableModelInvocation: parseFrontmatterBool(getFrontmatterValue(frontmatter, "disable-model-invocation"), false) }; } function resolveSkillKey(skill, entry) { return entry?.metadata?.skillKey ?? skill.name; } //#endregion //#region src/agents/skills/config.ts const DEFAULT_CONFIG_VALUES = { "browser.enabled": true, "browser.evaluateEnabled": true }; function isTruthy(value) { if (value === void 0 || value === null) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") return value.trim().length > 0; return true; } function resolveConfigPath(config, pathStr) { const parts = pathStr.split(".").filter(Boolean); let current = config; for (const part of parts) { if (typeof current !== "object" || current === null) return; current = current[part]; } return current; } function isConfigPathTruthy(config, pathStr) { const value = resolveConfigPath(config, pathStr); if (value === void 0 && pathStr in DEFAULT_CONFIG_VALUES) return DEFAULT_CONFIG_VALUES[pathStr]; return isTruthy(value); } function resolveSkillConfig(config, skillKey) { const skills = config?.skills?.entries; if (!skills || typeof skills !== "object") return; const entry = skills[skillKey]; if (!entry || typeof entry !== "object") return; return entry; } function resolveRuntimePlatform() { return process.platform; } function normalizeAllowlist(input) { if (!input) return; if (!Array.isArray(input)) return; const normalized = input.map((entry) => String(entry).trim()).filter(Boolean); return normalized.length > 0 ? normalized : void 0; } const BUNDLED_SOURCES = new Set(["openclaw-bundled"]); function isBundledSkill(entry) { return BUNDLED_SOURCES.has(entry.skill.source); } function isBundledSkillAllowed(entry, allowlist) { if (!allowlist || allowlist.length === 0) return true; if (!isBundledSkill(entry)) return true; const key = resolveSkillKey(entry.skill, entry); return allowlist.includes(key) || allowlist.includes(entry.skill.name); } function hasBinary(bin) { const parts = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); for (const part of parts) { const candidate = path.join(part, bin); try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch {} } return false; } function shouldIncludeSkill(params) { const { entry, config, eligibility } = params; const skillConfig = resolveSkillConfig(config, resolveSkillKey(entry.skill, entry)); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); const osList = entry.metadata?.os ?? []; const remotePlatforms = eligibility?.remote?.platforms ?? []; if (skillConfig?.enabled === false) return false; if (!isBundledSkillAllowed(entry, allowBundled)) return false; if (osList.length > 0 && !osList.includes(resolveRuntimePlatform()) && !remotePlatforms.some((platform) => osList.includes(platform))) return false; if (entry.metadata?.always === true) return true; const requiredBins = entry.metadata?.requires?.bins ?? []; if (requiredBins.length > 0) for (const bin of requiredBins) { if (hasBinary(bin)) continue; if (eligibility?.remote?.hasBin?.(bin)) continue; return false; } const requiredAnyBins = entry.metadata?.requires?.anyBins ?? []; if (requiredAnyBins.length > 0) { if (!(requiredAnyBins.some((bin) => hasBinary(bin)) || eligibility?.remote?.hasAnyBin?.(requiredAnyBins))) return false; } const requiredEnv = entry.metadata?.requires?.env ?? []; if (requiredEnv.length > 0) for (const envName of requiredEnv) { if (process.env[envName]) continue; if (skillConfig?.env?.[envName]) continue; if (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName) continue; return false; } const requiredConfig = entry.metadata?.requires?.config ?? []; if (requiredConfig.length > 0) { for (const configPath of requiredConfig) if (!isConfigPathTruthy(config, configPath)) return false; } return true; } //#endregion //#region src/agents/skills/env-overrides.ts function applySkillEnvOverrides(params) { const { skills, config } = params; const updates = []; for (const entry of skills) { const skillConfig = resolveSkillConfig(config, resolveSkillKey(entry.skill, entry)); if (!skillConfig) continue; if (skillConfig.env) for (const [envKey, envValue] of Object.entries(skillConfig.env)) { if (!envValue || process.env[envKey]) continue; updates.push({ key: envKey, prev: process.env[envKey] }); process.env[envKey] = envValue; } const primaryEnv = entry.metadata?.primaryEnv; if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) { updates.push({ key: primaryEnv, prev: process.env[primaryEnv] }); process.env[primaryEnv] = skillConfig.apiKey; } } return () => { for (const update of updates) if (update.prev === void 0) delete process.env[update.key]; else process.env[update.key] = update.prev; }; } function applySkillEnvOverridesFromSnapshot(params) { const { snapshot, config } = params; if (!snapshot) return () => {}; const updates = []; for (const skill of snapshot.skills) { const skillConfig = resolveSkillConfig(config, skill.name); if (!skillConfig) continue; if (skillConfig.env) for (const [envKey, envValue] of Object.entries(skillConfig.env)) { if (!envValue || process.env[envKey]) continue; updates.push({ key: envKey, prev: process.env[envKey] }); process.env[envKey] = envValue; } if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) { updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] }); process.env[skill.primaryEnv] = skillConfig.apiKey; } } return () => { for (const update of updates) if (update.prev === void 0) delete process.env[update.key]; else process.env[update.key] = update.prev; }; } //#endregion //#region src/agents/skills/bundled-dir.ts function looksLikeSkillsDir(dir) { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith(".")) continue; const fullPath = path.join(dir, entry.name); if (entry.isFile() && entry.name.endsWith(".md")) return true; if (entry.isDirectory()) { if (fs.existsSync(path.join(fullPath, "SKILL.md"))) return true; } } } catch { return false; } return false; } function resolveBundledSkillsDir(opts = {}) { const override = process.env.OPENCLAW_BUNDLED_SKILLS_DIR?.trim(); if (override) return override; try { const execPath = opts.execPath ?? process.execPath; const execDir = path.dirname(execPath); const sibling = path.join(execDir, "skills"); if (fs.existsSync(sibling)) return sibling; } catch {} try { const moduleUrl = opts.moduleUrl ?? import.meta.url; const moduleDir = path.dirname(fileURLToPath(moduleUrl)); const packageRoot = resolveOpenClawPackageRootSync({ argv1: opts.argv1 ?? process.argv[1], moduleUrl, cwd: opts.cwd ?? process.cwd() }); if (packageRoot) { const candidate = path.join(packageRoot, "skills"); if (looksLikeSkillsDir(candidate)) return candidate; } let current = moduleDir; for (let depth = 0; depth < 6; depth += 1) { const candidate = path.join(current, "skills"); if (looksLikeSkillsDir(candidate)) return candidate; const next = path.dirname(current); if (next === current) break; current = next; } } catch {} } //#endregion //#region src/agents/skills/plugin-skills.ts const log$1 = createSubsystemLogger("skills"); function resolvePluginSkillDirs(params) { const workspaceDir = params.workspaceDir.trim(); if (!workspaceDir) return []; const registry = loadPluginManifestRegistry({ workspaceDir, config: params.config }); if (registry.plugins.length === 0) return []; const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); const memorySlot = normalizedPlugins.slots.memory; let selectedMemoryPluginId = null; const seen = /* @__PURE__ */ new Set(); const resolved = []; for (const record of registry.plugins) { if (!record.skills || record.skills.length === 0) continue; if (!resolveEnableState(record.id, record.origin, normalizedPlugins).enabled) continue; const memoryDecision = resolveMemorySlotDecision({ id: record.id, kind: record.kind, slot: memorySlot, selectedId: selectedMemoryPluginId }); if (!memoryDecision.enabled) continue; if (memoryDecision.selected && record.kind === "memory") selectedMemoryPluginId = record.id; for (const raw of record.skills) { const trimmed = raw.trim(); if (!trimmed) continue; const candidate = path.resolve(record.rootDir, trimmed); if (!fs.existsSync(candidate)) { log$1.warn(`plugin skill path not found (${record.id}): ${candidate}`); continue; } if (seen.has(candidate)) continue; seen.add(candidate); resolved.push(candidate); } } return resolved; } //#endregion //#region src/agents/skills/serialize.ts const SKILLS_SYNC_QUEUE = /* @__PURE__ */ new Map(); async function serializeByKey(key, task) { const next = (SKILLS_SYNC_QUEUE.get(key) ?? Promise.resolve()).then(task, task); SKILLS_SYNC_QUEUE.set(key, next); try { return await next; } finally { if (SKILLS_SYNC_QUEUE.get(key) === next) SKILLS_SYNC_QUEUE.delete(key); } } //#endregion //#region src/agents/skills/workspace.ts const fsp = fs.promises; const skillsLogger = createSubsystemLogger("skills"); const skillCommandDebugOnce = /* @__PURE__ */ new Set(); function debugSkillCommandOnce(messageKey, message, meta) { if (skillCommandDebugOnce.has(messageKey)) return; skillCommandDebugOnce.add(messageKey); skillsLogger.debug(message, meta); } function filterSkillEntries(entries, config, skillFilter, eligibility) { let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility })); if (skillFilter !== void 0) { const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean); const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; console.log(`[skills] Applying skill filter: ${label}`); filtered = normalized.length > 0 ? filtered.filter((entry) => normalized.includes(entry.skill.name)) : []; console.log(`[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`); } return filtered; } const SKILL_COMMAND_MAX_LENGTH = 32; const SKILL_COMMAND_FALLBACK = "skill"; const SKILL_COMMAND_DESCRIPTION_MAX_LENGTH = 100; function sanitizeSkillCommandName(raw) { return raw.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, SKILL_COMMAND_MAX_LENGTH) || SKILL_COMMAND_FALLBACK; } function resolveUniqueSkillCommandName(base, used) { const normalizedBase = base.toLowerCase(); if (!used.has(normalizedBase)) return base; for (let index = 2; index < 1e3; index += 1) { const suffix = `_${index}`; const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length); const candidate = `${base.slice(0, maxBaseLength)}${suffix}`; const candidateKey = candidate.toLowerCase(); if (!used.has(candidateKey)) return candidate; } return `${base.slice(0, Math.max(1, SKILL_COMMAND_MAX_LENGTH - 2))}_x`; } function loadSkillEntries(workspaceDir, opts) { const loadSkills = (params) => { const loaded = loadSkillsFromDir(params); if (Array.isArray(loaded)) return loaded; if (loaded && typeof loaded === "object" && "skills" in loaded && Array.isArray(loaded.skills)) return loaded.skills; return []; }; const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); const workspaceSkillsDir = path.join(workspaceDir, "skills"); const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); const extraDirs = (opts?.config?.skills?.load?.extraDirs ?? []).map((d) => typeof d === "string" ? d.trim() : "").filter(Boolean); const pluginSkillDirs = resolvePluginSkillDirs({ workspaceDir, config: opts?.config }); const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs]; const bundledSkills = bundledSkillsDir ? loadSkills({ dir: bundledSkillsDir, source: "openclaw-bundled" }) : []; const extraSkills = mergedExtraDirs.flatMap((dir) => { return loadSkills({ dir: resolveUserPath(dir), source: "openclaw-extra" }); }); const managedSkills = loadSkills({ dir: managedSkillsDir, source: "openclaw-managed" }); const workspaceSkills = loadSkills({ dir: workspaceSkillsDir, source: "openclaw-workspace" }); const merged = /* @__PURE__ */ new Map(); for (const skill of extraSkills) merged.set(skill.name, skill); for (const skill of bundledSkills) merged.set(skill.name, skill); for (const skill of managedSkills) merged.set(skill.name, skill); for (const skill of workspaceSkills) merged.set(skill.name, skill); return Array.from(merged.values()).map((skill) => { let frontmatter = {}; try { frontmatter = parseFrontmatter(fs.readFileSync(skill.filePath, "utf-8")); } catch {} return { skill, frontmatter, metadata: resolveOpenClawMetadata(frontmatter), invocation: resolveSkillInvocationPolicy(frontmatter) }; }); } function buildWorkspaceSkillSnapshot(workspaceDir, opts) { const eligible = filterSkillEntries(opts?.entries ?? loadSkillEntries(workspaceDir, opts), opts?.config, opts?.skillFilter, opts?.eligibility); const resolvedSkills = eligible.filter((entry) => entry.invocation?.disableModelInvocation !== true).map((entry) => entry.skill); return { prompt: [opts?.eligibility?.remote?.note?.trim(), formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"), skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.metadata?.primaryEnv })), resolvedSkills, version: opts?.snapshotVersion }; } function buildWorkspaceSkillsPrompt(workspaceDir, opts) { const promptEntries = filterSkillEntries(opts?.entries ?? loadSkillEntries(workspaceDir, opts), opts?.config, opts?.skillFilter, opts?.eligibility).filter((entry) => entry.invocation?.disableModelInvocation !== true); return [opts?.eligibility?.remote?.note?.trim(), formatSkillsForPrompt(promptEntries.map((entry) => entry.skill))].filter(Boolean).join("\n"); } function resolveSkillsPromptForRun(params) { const snapshotPrompt = params.skillsSnapshot?.prompt?.trim(); if (snapshotPrompt) return snapshotPrompt; if (params.entries && params.entries.length > 0) { const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, { entries: params.entries, config: params.config }); return prompt.trim() ? prompt : ""; } return ""; } function loadWorkspaceSkillEntries(workspaceDir, opts) { return loadSkillEntries(workspaceDir, opts); } async function syncSkillsToWorkspace(params) { const sourceDir = resolveUserPath(params.sourceWorkspaceDir); const targetDir = resolveUserPath(params.targetWorkspaceDir); if (sourceDir === targetDir) return; await serializeByKey(`syncSkills:${targetDir}`, async () => { const targetSkillsDir = path.join(targetDir, "skills"); const entries = loadSkillEntries(sourceDir, { config: params.config, managedSkillsDir: params.managedSkillsDir, bundledSkillsDir: params.bundledSkillsDir }); await fsp.rm(targetSkillsDir, { recursive: true, force: true }); await fsp.mkdir(targetSkillsDir, { recursive: true }); for (const entry of entries) { const dest = path.join(targetSkillsDir, entry.skill.name); try { await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true }); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.warn(`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`); } } }); } function buildWorkspaceSkillCommandSpecs(workspaceDir, opts) { const userInvocable = filterSkillEntries(opts?.entries ?? loadSkillEntries(workspaceDir, opts), opts?.config, opts?.skillFilter, opts?.eligibility).filter((entry) => entry.invocation?.userInvocable !== false); const used = /* @__PURE__ */ new Set(); for (const reserved of opts?.reservedNames ?? []) used.add(reserved.toLowerCase()); const specs = []; for (const entry of userInvocable) { const rawName = entry.skill.name; const base = sanitizeSkillCommandName(rawName); if (base !== rawName) debugSkillCommandOnce(`sanitize:${rawName}:${base}`, `Sanitized skill command name "${rawName}" to "/${base}".`, { rawName, sanitized: `/${base}` }); const unique = resolveUniqueSkillCommandName(base, used); if (unique !== base) debugSkillCommandOnce(`dedupe:${rawName}:${unique}`, `De-duplicated skill command name for "${rawName}" to "/${unique}".`, { rawName, deduped: `/${unique}` }); used.add(unique.toLowerCase()); const rawDescription = entry.skill.description?.trim() || rawName; const description = rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH ? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" : rawDescription; const dispatch = (() => { const kindRaw = (entry.frontmatter?.["command-dispatch"] ?? entry.frontmatter?.["command_dispatch"] ?? "").trim().toLowerCase(); if (!kindRaw) return; if (kindRaw !== "tool") return; const toolName = (entry.frontmatter?.["command-tool"] ?? entry.frontmatter?.["command_tool"] ?? "").trim(); if (!toolName) { debugSkillCommandOnce(`dispatch:missingTool:${rawName}`, `Skill command "/${unique}" requested tool dispatch but did not provide command-tool. Ignoring dispatch.`, { skillName: rawName, command: unique }); return; } const argModeRaw = (entry.frontmatter?.["command-arg-mode"] ?? entry.frontmatter?.["command_arg_mode"] ?? "").trim().toLowerCase(); if (!(!argModeRaw || argModeRaw === "raw" ? "raw" : null)) debugSkillCommandOnce(`dispatch:badArgMode:${rawName}:${argModeRaw}`, `Skill command "/${unique}" requested tool dispatch but has unknown command-arg-mode. Falling back to raw.`, { skillName: rawName, command: unique, argMode: argModeRaw }); return { kind: "tool", toolName, argMode: "raw" }; })(); specs.push({ name: unique, skillName: rawName, description, ...dispatch ? { dispatch } : {} }); } return specs; } //#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/pw-ai-module.ts let pwAiModuleSoft = null; let pwAiModuleStrict = null; function isModuleNotFoundError(err) { if (extractErrorCode(err) === "ERR_MODULE_NOT_FOUND") return true; const msg = formatErrorMessage(err); return msg.includes("Cannot find module") || msg.includes("Cannot find package") || msg.includes("Failed to resolve import") || msg.includes("Failed to resolve entry for package") || msg.includes("Failed to load url"); } async function loadPwAiModule(mode) { try { return await import("./pw-ai-DQU7mIqX.js"); } catch (err) { if (mode === "soft") return null; if (isModuleNotFoundError(err)) return null; throw err; } } async function getPwAiModule$1(opts) { if ((opts?.mode ?? "soft") === "soft") { if (!pwAiModuleSoft) pwAiModuleSoft = loadPwAiModule("soft"); return await pwAiModuleSoft; } if (!pwAiModuleStrict) pwAiModuleStrict = loadPwAiModule("strict"); return await pwAiModuleStrict; } //#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/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/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/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/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" ? hasMaxChars ? typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0 ? Math.floor(maxCharsRaw) : void 0 : mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS : DEFAULT_AI_SNAPSHOT_MAX_CHARS : void 0; const interactiveRaw = toBoolean(req.query.interactive); const compactRaw = toBoolean(req.query.compact); const depthRaw = toNumber(req.query.depth); const refsModeRaw = toStringOrEmpty(req.query.refs).trim(); const refsMode = refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : void 0; const interactive = interactiveRaw ?? (mode === "efficient" ? true : void 0); const compact = compactRaw ?? (mode === "efficient" ? true : void 0); const depth = depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : void 0); const selector = toStringOrEmpty(req.query.selector); const frameSelector = toStringOrEmpty(req.query.frame); try { const tab = await profileCtx.ensureTabAvailable(targetId || void 0); if ((labels || mode === "efficient") && format === "aria") return jsonError(res, 400, "labels/mode=efficient require format=ai"); if (format === "ai") { const pw = await requirePwAi(res, "ai snapshot"); if (!pw) return; const snap = labels === true || mode === "efficient" || interactive === true || compact === true || depth !== void 0 || Boolean(selector.trim()) || Boolean(frameSelector.trim()) ? await pw.snapshotRoleViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, selector: selector.trim() || void 0, frameSelector: frameSelector.trim() || void 0, refsMode, options: { interactive: interactive ?? void 0, compact: compact ?? void 0, maxDepth: depth ?? void 0 } }) : await pw.snapshotAiViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, ...typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {} }).catch(async (err) => { if (String(err).toLowerCase().includes("_snapshotforai")) return await pw.snapshotRoleViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, selector: selector.trim() || void 0, frameSelector: frameSelector.trim() || void 0, refsMode, options: { interactive: interactive ?? void 0, compact: compact ?? void 0, maxDepth: depth ?? void 0 } }); throw err; }); if (labels) { const labeled = await pw.screenshotWithLabelsViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, refs: "refs" in snap ? snap.refs : {}, type: "png" }); const normalized = await normalizeBrowserScreenshot(labeled.buffer, { maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES }); await ensureMediaDir(); const saved = await saveMediaBuffer(normalized.buffer, normalized.contentType ?? "image/png", "browser", DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES); const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png"; return res.json({ ok: true, format, targetId: tab.targetId, url: tab.url, labels: true, labelsCount: labeled.labels, labelsSkipped: labeled.skipped, imagePath: path.resolve(saved.path), imageType, ...snap }); } return res.json({ ok: true, format, targetId: tab.targetId, url: tab.url, ...snap }); } const snap = profileCtx.profile.driver === "extension" || !tab.wsUrl ? requirePwAi(res, "aria snapshot").then(async (pw) => { if (!pw) return null; return await pw.snapshotAriaViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, limit }); }) : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit }); const resolved = await Promise.resolve(snap); if (!resolved) return; return res.json({ ok: true, format, targetId: tab.targetId, url: tab.url, ...resolved }); } catch (err) { handleRouteError(ctx, res, err); } }); } //#endregion //#region src/browser/routes/agent.storage.ts function registerBrowserAgentStorageRoutes(app, ctx) { app.get("/cookies", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; try { const tab = await profileCtx.ensureTabAvailable(targetId || void 0); const pw = await requirePwAi(res, "cookies"); if (!pw) return; const result = await pw.cookiesGetViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId }); res.json({ ok: true, targetId: tab.targetId, ...result }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/cookies/set", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const cookie = body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie) ? body.cookie : null; if (!cookie) return jsonError(res, 400, "cookie is required"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "cookies set"); if (!pw) return; await pw.cookiesSetViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, cookie: { name: toStringOrEmpty(cookie.name), value: toStringOrEmpty(cookie.value), url: toStringOrEmpty(cookie.url) || void 0, domain: toStringOrEmpty(cookie.domain) || void 0, path: toStringOrEmpty(cookie.path) || void 0, expires: toNumber(cookie.expires) ?? void 0, httpOnly: toBoolean(cookie.httpOnly) ?? void 0, secure: toBoolean(cookie.secure) ?? void 0, sameSite: cookie.sameSite === "Lax" || cookie.sameSite === "None" || cookie.sameSite === "Strict" ? cookie.sameSite : void 0 } }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/cookies/clear", 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, "cookies clear"); if (!pw) return; await pw.cookiesClearViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.get("/storage/:kind", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const kind = toStringOrEmpty(req.params.kind); if (kind !== "local" && kind !== "session") return jsonError(res, 400, "kind must be local|session"); const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; const key = typeof req.query.key === "string" ? req.query.key : ""; try { const tab = await profileCtx.ensureTabAvailable(targetId || void 0); const pw = await requirePwAi(res, "storage get"); if (!pw) return; const result = await pw.storageGetViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, kind, key: key.trim() || void 0 }); res.json({ ok: true, targetId: tab.targetId, ...result }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/storage/:kind/set", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const kind = toStringOrEmpty(req.params.kind); if (kind !== "local" && kind !== "session") return jsonError(res, 400, "kind must be local|session"); const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const key = toStringOrEmpty(body.key); if (!key) return jsonError(res, 400, "key is required"); const value = typeof body.value === "string" ? body.value : ""; try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "storage set"); if (!pw) return; await pw.storageSetViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, kind, key, value }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/storage/:kind/clear", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const kind = toStringOrEmpty(req.params.kind); if (kind !== "local" && kind !== "session") return jsonError(res, 400, "kind must be local|session"); const targetId = toStringOrEmpty(readBody(req).targetId) || void 0; try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "storage clear"); if (!pw) return; await pw.storageClearViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, kind }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/offline", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const offline = toBoolean(body.offline); if (offline === void 0) return jsonError(res, 400, "offline is required"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "offline"); if (!pw) return; await pw.setOfflineViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, offline }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/headers", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const headers = body.headers && typeof body.headers === "object" && !Array.isArray(body.headers) ? body.headers : null; if (!headers) return jsonError(res, 400, "headers is required"); const parsed = {}; for (const [k, v] of Object.entries(headers)) if (typeof v === "string") parsed[k] = v; try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "headers"); if (!pw) return; await pw.setExtraHTTPHeadersViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, headers: parsed }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/credentials", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const clear = toBoolean(body.clear) ?? false; const username = toStringOrEmpty(body.username) || void 0; const password = typeof body.password === "string" ? body.password : void 0; try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "http credentials"); if (!pw) return; await pw.setHttpCredentialsViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, username, password, clear }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/geolocation", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const clear = toBoolean(body.clear) ?? false; const latitude = toNumber(body.latitude); const longitude = toNumber(body.longitude); const accuracy = toNumber(body.accuracy) ?? void 0; const origin = toStringOrEmpty(body.origin) || void 0; try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "geolocation"); if (!pw) return; await pw.setGeolocationViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, latitude, longitude, accuracy, origin, clear }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/media", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const schemeRaw = toStringOrEmpty(body.colorScheme); const colorScheme = schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference" ? schemeRaw : schemeRaw === "none" ? null : void 0; if (colorScheme === void 0) return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "media emulation"); if (!pw) return; await pw.emulateMediaViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, colorScheme }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/timezone", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const timezoneId = toStringOrEmpty(body.timezoneId); if (!timezoneId) return jsonError(res, 400, "timezoneId is required"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "timezone"); if (!pw) return; await pw.setTimezoneViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, timezoneId }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/locale", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const locale = toStringOrEmpty(body.locale); if (!locale) return jsonError(res, 400, "locale is required"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "locale"); if (!pw) return; await pw.setLocaleViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, locale }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); app.post("/set/device", async (req, res) => { const profileCtx = resolveProfileContext(req, res, ctx); if (!profileCtx) return; const body = readBody(req); const targetId = toStringOrEmpty(body.targetId) || void 0; const name = toStringOrEmpty(body.name); if (!name) return jsonError(res, 400, "name is required"); try { const tab = await profileCtx.ensureTabAvailable(targetId); const pw = await requirePwAi(res, "device emulation"); if (!pw) return; await pw.setDeviceViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, name }); res.json({ ok: true, targetId: tab.targetId }); } catch (err) { handleRouteError(ctx, res, err); } }); } //#endregion //#region src/browser/routes/agent.ts function registerBrowserAgentRoutes(app, ctx) { registerBrowserAgentSnapshotRoutes(app, ctx); registerBrowserAgentActRoutes(app, ctx); registerBrowserAgentDebugRoutes(app, ctx); registerBrowserAgentStorageRoutes(app, ctx); } //#endregion //#region src/config/port-defaults.ts function isValidPort(port) { return Number.isFinite(port) && port > 0 && port <= 65535; } function clampPort(port, fallback) { return isValidPort(port) ? port : fallback; } function derivePort(base, offset, fallback) { return clampPort(base + offset, fallback); } const DEFAULT_BROWSER_CONTROL_PORT = 18791; const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; const DEFAULT_BROWSER_CDP_PORT_RANGE_END = 18899; function deriveDefaultBrowserControlPort(gatewayPort) { return derivePort(gatewayPort, 2, DEFAULT_BROWSER_CONTROL_PORT); } function deriveDefaultBrowserCdpPortRange(browserControlPort) { const start = derivePort(browserControlPort, 9, DEFAULT_BROWSER_CDP_PORT_RANGE_START); const end = clampPort(start + (DEFAULT_BROWSER_CDP_PORT_RANGE_END - DEFAULT_BROWSER_CDP_PORT_RANGE_START), DEFAULT_BROWSER_CDP_PORT_RANGE_END); if (end < start) return { start, end: start }; return { start, end }; } //#endregion //#region src/browser/profiles.ts /** * CDP port allocation for browser profiles. * * Default port range: 18800-18899 (100 profiles max) * Ports are allocated once at profile creation and persisted in config. * Multi-instance: callers may pass an explicit range to avoid collisions. * * Reserved ports (do not use for CDP): * 18789 - Gateway WebSocket * 18790 - Bridge * 18791 - Browser control server * 18792-18799 - Reserved for future one-off services (canvas at 18793) */ const CDP_PORT_RANGE_START = 18800; const CDP_PORT_RANGE_END = 18899; const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; function isValidProfileName(name) { if (!name || name.length > 64) return false; return PROFILE_NAME_REGEX.test(name); } function allocateCdpPort(usedPorts, range) { const start = range?.start ?? CDP_PORT_RANGE_START; const end = range?.end ?? CDP_PORT_RANGE_END; if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) return null; if (start > end) return null; for (let port = start; port <= end; port++) if (!usedPorts.has(port)) return port; return null; } function getUsedPorts(profiles) { if (!profiles) return /* @__PURE__ */ new Set(); const used = /* @__PURE__ */ new Set(); for (const profile of Object.values(profiles)) { if (typeof profile.cdpPort === "number") { used.add(profile.cdpPort); continue; } const rawUrl = profile.cdpUrl?.trim(); if (!rawUrl) continue; try { const parsed = new URL(rawUrl); const port = parsed.port && Number.parseInt(parsed.port, 10) > 0 ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80; if (!Number.isNaN(port) && port > 0 && port <= 65535) used.add(port); } catch {} } return used; } const PROFILE_COLORS = [ "#FF4500", "#0066CC", "#00AA00", "#9933FF", "#FF6699", "#00CCCC", "#FF9900", "#6666FF", "#CC3366", "#339966" ]; function allocateColor(usedColors) { for (const color of PROFILE_COLORS) if (!usedColors.has(color.toUpperCase())) return color; return PROFILE_COLORS[usedColors.size % PROFILE_COLORS.length] ?? PROFILE_COLORS[0]; } function getUsedColors(profiles) { if (!profiles) return /* @__PURE__ */ new Set(); return new Set(Object.values(profiles).map((p) => p.color.toUpperCase())); } //#endregion //#region src/browser/config.ts function isLoopbackHost(host) { const h = host.trim().toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h === "[::1]" || h === "::1" || h === "[::]" || h === "::"; } function normalizeHexColor(raw) { const value = (raw ?? "").trim(); if (!value) return DEFAULT_OPENCLAW_BROWSER_COLOR; const normalized = value.startsWith("#") ? value : `#${value}`; if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) return DEFAULT_OPENCLAW_BROWSER_COLOR; return normalized.toUpperCase(); } function normalizeTimeoutMs(raw, fallback) { const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; return value < 0 ? fallback : value; } function parseHttpUrl(raw, label) { const trimmed = raw.trim(); const parsed = new URL(trimmed); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`); const port = parsed.port && Number.parseInt(parsed.port, 10) > 0 ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80; if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error(`${label} has invalid port: ${parsed.port}`); return { parsed, port, normalized: parsed.toString().replace(/\/$/, "") }; } /** * Ensure the default "openclaw" profile exists in the profiles map. * Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing. */ function ensureDefaultProfile(profiles, defaultColor, legacyCdpPort, derivedDefaultCdpPort) { const result = { ...profiles }; if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, color: defaultColor }; return result; } /** * Ensure a built-in "chrome" profile exists for the Chrome extension relay. * * Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile. * It points at the local relay CDP endpoint (controlPort + 1). */ function ensureDefaultChromeExtensionProfile(profiles, controlPort) { const result = { ...profiles }; if (result.chrome) return result; const relayPort = controlPort + 1; if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) return result; if (getUsedPorts(result).has(relayPort)) return result; result.chrome = { driver: "extension", cdpUrl: `http://127.0.0.1:${relayPort}`, color: "#00AA00" }; return result; } function resolveBrowserConfig(cfg, rootConfig) { const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED; const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; const controlPort = deriveDefaultBrowserControlPort(resolveGatewayPort(rootConfig) ?? DEFAULT_BROWSER_CONTROL_PORT); const defaultColor = normalizeHexColor(cfg?.color); const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500); const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpHandshakeTimeoutMs, Math.max(2e3, remoteCdpTimeoutMs * 2)); const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); let cdpInfo; if (rawCdpUrl) cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl"); else { const derivedPort = controlPort + 1; if (derivedPort > 65535) throw new Error(`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`); const derived = new URL(`http://127.0.0.1:${derivedPort}`); cdpInfo = { parsed: derived, port: derivedPort, normalized: derived.toString().replace(/\/$/, "") }; } const headless = cfg?.headless === true; const noSandbox = cfg?.noSandbox === true; const attachOnly = cfg?.attachOnly === true; const executablePath = cfg?.executablePath?.trim() || void 0; const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || void 0; const legacyCdpPort = rawCdpUrl ? cdpInfo.port : void 0; const profiles = ensureDefaultChromeExtensionProfile(ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start), controlPort); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; const defaultProfile = defaultProfileFromConfig ?? (profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME] ? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME : DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); return { enabled, evaluateEnabled, controlPort, cdpProtocol, cdpHost: cdpInfo.parsed.hostname, cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), remoteCdpTimeoutMs, remoteCdpHandshakeTimeoutMs, color: defaultColor, executablePath, headless, noSandbox, attachOnly, defaultProfile, profiles }; } /** * Resolve a profile by name from the config. * Returns null if the profile doesn't exist. */ function resolveProfile(resolved, profileName) { const profile = resolved.profiles[profileName]; if (!profile) return null; const rawProfileUrl = profile.cdpUrl?.trim() ?? ""; let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl = ""; const driver = profile.driver === "extension" ? "extension" : "openclaw"; if (rawProfileUrl) { const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`); cdpHost = parsed.parsed.hostname; cdpPort = parsed.port; cdpUrl = parsed.normalized; } else if (cdpPort) cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`; else throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`); return { name: profileName, cdpPort, cdpUrl, cdpHost, cdpIsLoopback: isLoopbackHost(cdpHost), color: profile.color, driver }; } //#endregion //#region src/browser/trash.ts async function movePathToTrash(targetPath) { try { await runExec("trash", [targetPath], { timeoutMs: 1e4 }); return targetPath; } catch { const trashDir = path.join(os.homedir(), ".Trash"); fs.mkdirSync(trashDir, { recursive: true }); const base = path.basename(targetPath); let dest = path.join(trashDir, `${base}-${Date.now()}`); if (fs.existsSync(dest)) dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`); fs.renameSync(targetPath, dest); return dest; } } //#endregion //#region src/browser/profiles-service.ts const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; function createBrowserProfilesService(ctx) { const listProfiles = async () => { return await ctx.listProfiles(); }; const createProfile = async (params) => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || void 0; const driver = params.driver === "extension" ? "extension" : void 0; if (!isValidProfileName(name)) throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only"); const state = ctx.state(); const resolvedProfiles = state.resolved.profiles; if (name in resolvedProfiles) throw new Error(`profile "${name}" already exists`); const cfg = loadConfig(); const rawProfiles = cfg.browser?.profiles ?? {}; if (name in rawProfiles) throw new Error(`profile "${name}" already exists`); const usedColors = getUsedColors(resolvedProfiles); const profileColor = params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); let profileConfig; if (rawCdpUrl) profileConfig = { cdpUrl: parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl").normalized, ...driver ? { driver } : {}, color: profileColor }; else { const cdpPort = allocateCdpPort(getUsedPorts(resolvedProfiles), deriveDefaultBrowserCdpPortRange(state.resolved.controlPort)); if (cdpPort === null) throw new Error("no available CDP ports in range"); profileConfig = { cdpPort, ...driver ? { driver } : {}, color: profileColor }; } await writeConfigFile({ ...cfg, browser: { ...cfg.browser, profiles: { ...rawProfiles, [name]: profileConfig } } }); state.resolved.profiles[name] = profileConfig; const resolved = resolveProfile(state.resolved, name); if (!resolved) throw new Error(`profile "${name}" not found after creation`); return { ok: true, profile: name, cdpPort: resolved.cdpPort, cdpUrl: resolved.cdpUrl, color: resolved.color, isRemote: !resolved.cdpIsLoopback }; }; const deleteProfile = async (nameRaw) => { const name = nameRaw.trim(); if (!name) throw new Error("profile name is required"); if (!isValidProfileName(name)) throw new Error("invalid profile name"); const cfg = loadConfig(); const profiles = cfg.browser?.profiles ?? {}; if (!(name in profiles)) throw new Error(`profile "${name}" not found`); if (name === (cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME)) throw new Error(`cannot delete the default profile "${name}"; change browser.defaultProfile first`); let deleted = false; const state = ctx.state(); if (resolveProfile(state.resolved, name)?.cdpIsLoopback) { try { await ctx.forProfile(name).stopRunningBrowser(); } catch {} const userDataDir = resolveOpenClawUserDataDir(name); const profileDir = path.dirname(userDataDir); if (fs.existsSync(profileDir)) { await movePathToTrash(profileDir); deleted = true; } } const { [name]: _removed, ...remainingProfiles } = profiles; await writeConfigFile({ ...cfg, browser: { ...cfg.browser, profiles: remainingProfiles } }); delete state.resolved.profiles[name]; state.profiles.delete(name); return { ok: true, profile: name, deleted }; }; return { listProfiles, createProfile, deleteProfile }; } //#endregion //#region src/browser/routes/basic.ts function registerBrowserBasicRoutes(app, ctx) { app.get("/profiles", async (_req, res) => { try { const profiles = await createBrowserProfilesService(ctx).listProfiles(); res.json({ profiles }); } catch (err) { jsonError(res, 500, String(err)); } }); app.get("/", async (req, res) => { let current; try { current = ctx.state(); } catch { return jsonError(res, 503, "browser server not started"); } const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); const [cdpHttp, cdpReady] = await Promise.all([profileCtx.isHttpReachable(300), profileCtx.isReachable(600)]); const profileState = current.profiles.get(profileCtx.profile.name); let detectedBrowser = null; let detectedExecutablePath = null; let detectError = null; try { const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform); if (detected) { detectedBrowser = detected.kind; detectedExecutablePath = detected.path; } } catch (err) { detectError = String(err); } res.json({ enabled: current.resolved.enabled, profile: profileCtx.profile.name, running: cdpReady, cdpReady, cdpHttp, pid: profileState?.running?.pid ?? null, cdpPort: profileCtx.profile.cdpPort, cdpUrl: profileCtx.profile.cdpUrl, chosenBrowser: profileState?.running?.exe.kind ?? null, detectedBrowser, detectedExecutablePath, detectError, userDataDir: profileState?.running?.userDataDir ?? null, color: profileCtx.profile.color, headless: current.resolved.headless, noSandbox: current.resolved.noSandbox, executablePath: current.resolved.executablePath ?? null, attachOnly: current.resolved.attachOnly }); }); app.post("/start", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); try { await profileCtx.ensureBrowserAvailable(); res.json({ ok: true, profile: profileCtx.profile.name }); } catch (err) { jsonError(res, 500, String(err)); } }); app.post("/stop", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); try { const result = await profileCtx.stopRunningBrowser(); res.json({ ok: true, stopped: result.stopped, profile: profileCtx.profile.name }); } catch (err) { jsonError(res, 500, String(err)); } }); app.post("/reset-profile", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); try { const result = await profileCtx.resetProfile(); res.json({ ok: true, profile: profileCtx.profile.name, ...result }); } catch (err) { jsonError(res, 500, String(err)); } }); app.post("/profiles/create", async (req, res) => { const name = toStringOrEmpty(req.body?.name); const color = toStringOrEmpty(req.body?.color); const cdpUrl = toStringOrEmpty(req.body?.cdpUrl); const driver = toStringOrEmpty(req.body?.driver); if (!name) return jsonError(res, 400, "name is required"); try { const result = await createBrowserProfilesService(ctx).createProfile({ name, color: color || void 0, cdpUrl: cdpUrl || void 0, driver: driver === "extension" ? "extension" : void 0 }); res.json(result); } catch (err) { const msg = String(err); if (msg.includes("already exists")) return jsonError(res, 409, msg); if (msg.includes("invalid profile name")) return jsonError(res, 400, msg); if (msg.includes("no available CDP ports")) return jsonError(res, 507, msg); if (msg.includes("cdpUrl")) return jsonError(res, 400, msg); jsonError(res, 500, msg); } }); app.delete("/profiles/:name", async (req, res) => { const name = toStringOrEmpty(req.params.name); if (!name) return jsonError(res, 400, "profile name is required"); try { const result = await createBrowserProfilesService(ctx).deleteProfile(name); res.json(result); } catch (err) { const msg = String(err); if (msg.includes("invalid profile name")) return jsonError(res, 400, msg); if (msg.includes("default profile")) return jsonError(res, 400, msg); if (msg.includes("not found")) return jsonError(res, 404, msg); jsonError(res, 500, msg); } }); } //#endregion //#region src/browser/routes/tabs.ts function registerBrowserTabRoutes(app, ctx) { app.get("/tabs", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); try { if (!await profileCtx.isReachable(300)) return res.json({ running: false, tabs: [] }); const tabs = await profileCtx.listTabs(); res.json({ running: true, tabs }); } catch (err) { jsonError(res, 500, String(err)); } }); app.post("/tabs/open", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); const url = toStringOrEmpty(req.body?.url); if (!url) return jsonError(res, 400, "url is required"); try { await profileCtx.ensureBrowserAvailable(); const tab = await profileCtx.openTab(url); res.json(tab); } catch (err) { jsonError(res, 500, String(err)); } }); app.post("/tabs/focus", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty(req.body?.targetId); if (!targetId) return jsonError(res, 400, "targetId is required"); try { if (!await profileCtx.isReachable(300)) return jsonError(res, 409, "browser not running"); await profileCtx.focusTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); if (mapped) return jsonError(res, mapped.status, mapped.message); jsonError(res, 500, String(err)); } }); app.delete("/tabs/:targetId", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty(req.params.targetId); if (!targetId) return jsonError(res, 400, "targetId is required"); try { if (!await profileCtx.isReachable(300)) return jsonError(res, 409, "browser not running"); await profileCtx.closeTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); if (mapped) return jsonError(res, mapped.status, mapped.message); jsonError(res, 500, String(err)); } }); app.post("/tabs/action", async (req, res) => { const profileCtx = getProfileContext(req, ctx); if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error); const action = toStringOrEmpty(req.body?.action); const index = toNumber(req.body?.index); try { if (action === "list") { if (!await profileCtx.isReachable(300)) return res.json({ ok: true, tabs: [] }); const tabs = await profileCtx.listTabs(); return res.json({ ok: true, tabs }); } if (action === "new") { await profileCtx.ensureBrowserAvailable(); const tab = await profileCtx.openTab("about:blank"); return res.json({ ok: true, tab }); } if (action === "close") { const tabs = await profileCtx.listTabs(); const target = typeof index === "number" ? tabs[index] : tabs.at(0); if (!target) return jsonError(res, 404, "tab not found"); await profileCtx.closeTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } if (action === "select") { if (typeof index !== "number") return jsonError(res, 400, "index is required"); const target = (await profileCtx.listTabs())[index]; if (!target) return jsonError(res, 404, "tab not found"); await profileCtx.focusTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } return jsonError(res, 400, "unknown tab action"); } catch (err) { const mapped = ctx.mapTabError(err); if (mapped) return jsonError(res, mapped.status, mapped.message); jsonError(res, 500, String(err)); } }); } //#endregion //#region src/browser/routes/index.ts function registerBrowserRoutes(app, ctx) { registerBrowserBasicRoutes(app, ctx); registerBrowserTabRoutes(app, ctx); registerBrowserAgentRoutes(app, ctx); } //#endregion //#region src/browser/target-id.ts function resolveTargetIdFromTabs(input, tabs) { const needle = input.trim(); if (!needle) return { ok: false, reason: "not_found" }; const exact = tabs.find((t) => t.targetId === needle); if (exact) return { ok: true, targetId: exact.targetId }; const lower = needle.toLowerCase(); const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower)); const only = matches.length === 1 ? matches[0] : void 0; if (only) return { ok: true, targetId: only }; if (matches.length === 0) return { ok: false, reason: "not_found" }; return { ok: false, reason: "ambiguous", matches }; } //#endregion //#region src/browser/server-context.ts /** * Normalize a CDP WebSocket URL to use the correct base URL. */ function normalizeWsUrl(raw, cdpBaseUrl) { if (!raw) return; try { return normalizeCdpWsUrl(raw, cdpBaseUrl); } catch { return raw; } } async function fetchJson(url, timeoutMs = 1500, init) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const headers = getHeadersWithAuth(url, init?.headers || {}); const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { clearTimeout(t); } } async function fetchOk(url, timeoutMs = 1500, init) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const headers = getHeadersWithAuth(url, init?.headers || {}); const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); } finally { clearTimeout(t); } } /** * Create a profile-scoped context for browser operations. */ function createProfileContext(opts, profile) { const state = () => { const current = opts.getState(); if (!current) throw new Error("Browser server not started"); return current; }; const getProfileState = () => { const current = state(); let profileState = current.profiles.get(profile.name); if (!profileState) { profileState = { profile, running: null, lastTargetId: null }; current.profiles.set(profile.name, profileState); } return profileState; }; const setProfileRunning = (running) => { const profileState = getProfileState(); profileState.running = running; }; const listTabs = async () => { if (!profile.cdpIsLoopback) { const listPagesViaPlaywright = (await getPwAiModule$1({ mode: "strict" }))?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") return (await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl })).map((p) => ({ targetId: p.targetId, title: p.title, url: p.url, type: p.type })); } return (await fetchJson(appendCdpPath(profile.cdpUrl, "/json/list"))).map((t) => ({ targetId: t.id ?? "", title: t.title ?? "", url: t.url ?? "", wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl), type: t.type })).filter((t) => Boolean(t.targetId)); }; const openTab = async (url) => { if (!profile.cdpIsLoopback) { const createPageViaPlaywright = (await getPwAiModule$1({ mode: "strict" }))?.createPageViaPlaywright; if (typeof createPageViaPlaywright === "function") { const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url }); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; return { targetId: page.targetId, title: page.title, url: page.url, type: page.type }; } } const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url }).then((r) => r.targetId).catch(() => null); if (createdViaCdp) { const profileState = getProfileState(); profileState.lastTargetId = createdViaCdp; const deadline = Date.now() + 2e3; while (Date.now() < deadline) { const found = (await listTabs().catch(() => [])).find((t) => t.targetId === createdViaCdp); if (found) return found; await new Promise((r) => setTimeout(r, 100)); } return { targetId: createdViaCdp, title: "", url, type: "page" }; } const encoded = encodeURIComponent(url); const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new")); const endpoint = endpointUrl.search ? (() => { endpointUrl.searchParams.set("url", url); return endpointUrl.toString(); })() : `${endpointUrl.toString()}?${encoded}`; const created = await fetchJson(endpoint, 1500, { method: "PUT" }).catch(async (err) => { if (String(err).includes("HTTP 405")) return await fetchJson(endpoint, 1500); throw err; }); if (!created.id) throw new Error("Failed to open tab (missing id)"); const profileState = getProfileState(); profileState.lastTargetId = created.id; return { targetId: created.id, title: created.title ?? "", url: created.url ?? url, wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), type: created.type }; }; const resolveRemoteHttpTimeout = (timeoutMs) => { if (profile.cdpIsLoopback) return timeoutMs ?? 300; const resolved = state().resolved; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs); return resolved.remoteCdpTimeoutMs; }; const resolveRemoteWsTimeout = (timeoutMs) => { if (profile.cdpIsLoopback) { const base = timeoutMs ?? 300; return Math.max(200, Math.min(2e3, base * 2)); } const resolved = state().resolved; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) return Math.max(Math.floor(timeoutMs) * 2, resolved.remoteCdpHandshakeTimeoutMs); return resolved.remoteCdpHandshakeTimeoutMs; }; const isReachable = async (timeoutMs) => { const httpTimeout = resolveRemoteHttpTimeout(timeoutMs); const wsTimeout = resolveRemoteWsTimeout(timeoutMs); return await isChromeCdpReady(profile.cdpUrl, httpTimeout, wsTimeout); }; const isHttpReachable = async (timeoutMs) => { const httpTimeout = resolveRemoteHttpTimeout(timeoutMs); return await isChromeReachable(profile.cdpUrl, httpTimeout); }; const attachRunning = (running) => { setProfileRunning(running); running.proc.on("exit", () => { if (!opts.getState()) return; if (getProfileState().running?.pid === running.pid) setProfileRunning(null); }); }; const ensureBrowserAvailable = async () => { const current = state(); const remoteCdp = !profile.cdpIsLoopback; const isExtension = profile.driver === "extension"; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); if (isExtension && remoteCdp) throw new Error(`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`); if (isExtension) { if (!httpReachable) { await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }); if (await isHttpReachable(1200)) {} else throw new Error(`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`); } if (await isReachable(600)) return; throw new Error(`Chrome extension relay is running, but no tab is connected. Click the OpenClaw Chrome extension icon on a tab to attach it (profile "${profile.name}").`); } if (!httpReachable) { if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); if (await isHttpReachable(1200)) return; } if (current.resolved.attachOnly || remoteCdp) throw new Error(remoteCdp ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`); attachRunning(await launchOpenClawChrome(current.resolved, profile)); return; } if (await isReachable()) return; if (!profileState.running) throw new Error(`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. Run action=reset-profile profile=${profile.name} to kill the process.`); if (current.resolved.attachOnly || remoteCdp) { if (opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); if (await isReachable(1200)) return; } throw new Error(remoteCdp ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`); } await stopOpenClawChrome(profileState.running); setProfileRunning(null); attachRunning(await launchOpenClawChrome(current.resolved, profile)); if (!await isReachable(600)) throw new Error(`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`); }; const ensureTabAvailable = async (targetId) => { await ensureBrowserAvailable(); const profileState = getProfileState(); if ((await listTabs()).length === 0) { if (profile.driver === "extension") throw new Error(`tab not found (no attached Chrome tabs for profile "${profile.name}"). Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).`); await openTab("about:blank"); } const tabs = await listTabs(); const candidates = profile.driver === "extension" || !profile.cdpIsLoopback ? tabs : tabs.filter((t) => Boolean(t.wsUrl)); const resolveById = (raw) => { const resolved = resolveTargetIdFromTabs(raw, candidates); if (!resolved.ok) { if (resolved.reason === "ambiguous") return "AMBIGUOUS"; return null; } return candidates.find((t) => t.targetId === resolved.targetId) ?? null; }; const pickDefault = () => { const last = profileState.lastTargetId?.trim() || ""; const lastResolved = last ? resolveById(last) : null; if (lastResolved && lastResolved !== "AMBIGUOUS") return lastResolved; return candidates.find((t) => (t.type ?? "page") === "page") ?? candidates.at(0) ?? null; }; let chosen = targetId ? resolveById(targetId) : pickDefault(); if (!chosen && profile.driver === "extension" && candidates.length === 1) chosen = candidates[0] ?? null; if (chosen === "AMBIGUOUS") throw new Error("ambiguous target id prefix"); if (!chosen) throw new Error("tab not found"); profileState.lastTargetId = chosen.targetId; return chosen; }; const focusTab = async (targetId) => { const resolved = resolveTargetIdFromTabs(targetId, await listTabs()); if (!resolved.ok) { if (resolved.reason === "ambiguous") throw new Error("ambiguous target id prefix"); throw new Error("tab not found"); } if (!profile.cdpIsLoopback) { const focusPageByTargetIdViaPlaywright = (await getPwAiModule$1({ mode: "strict" }))?.focusPageByTargetIdViaPlaywright; if (typeof focusPageByTargetIdViaPlaywright === "function") { await focusPageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolved.targetId }); const profileState = getProfileState(); profileState.lastTargetId = resolved.targetId; return; } } await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`)); const profileState = getProfileState(); profileState.lastTargetId = resolved.targetId; }; const closeTab = async (targetId) => { const resolved = resolveTargetIdFromTabs(targetId, await listTabs()); if (!resolved.ok) { if (resolved.reason === "ambiguous") throw new Error("ambiguous target id prefix"); throw new Error("tab not found"); } if (!profile.cdpIsLoopback) { const closePageByTargetIdViaPlaywright = (await getPwAiModule$1({ mode: "strict" }))?.closePageByTargetIdViaPlaywright; if (typeof closePageByTargetIdViaPlaywright === "function") { await closePageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolved.targetId }); return; } } await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`)); }; const stopRunningBrowser = async () => { if (profile.driver === "extension") return { stopped: await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }) }; const profileState = getProfileState(); if (!profileState.running) return { stopped: false }; await stopOpenClawChrome(profileState.running); setProfileRunning(null); return { stopped: true }; }; const resetProfile = async () => { if (profile.driver === "extension") { await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); return { moved: false, from: profile.cdpUrl }; } if (!profile.cdpIsLoopback) throw new Error(`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`); const userDataDir = resolveOpenClawUserDataDir(profile.name); const profileState = getProfileState(); if (await isHttpReachable(300) && !profileState.running) try { await (await import("./pw-ai-DQU7mIqX.js")).closePlaywrightBrowserConnection(); } catch {} if (profileState.running) await stopRunningBrowser(); try { await (await import("./pw-ai-DQU7mIqX.js")).closePlaywrightBrowserConnection(); } catch {} if (!fs.existsSync(userDataDir)) return { moved: false, from: userDataDir }; return { moved: true, from: userDataDir, to: await movePathToTrash(userDataDir) }; }; return { profile, ensureBrowserAvailable, ensureTabAvailable, isHttpReachable, isReachable, listTabs, openTab, focusTab, closeTab, stopRunningBrowser, resetProfile }; } function createBrowserRouteContext(opts) { const state = () => { const current = opts.getState(); if (!current) throw new Error("Browser server not started"); return current; }; const forProfile = (profileName) => { const current = state(); const name = profileName ?? current.resolved.defaultProfile; const profile = resolveProfile(current.resolved, name); if (!profile) { const available = Object.keys(current.resolved.profiles).join(", "); throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`); } return createProfileContext(opts, profile); }; const listProfiles = async () => { const current = state(); const result = []; for (const name of Object.keys(current.resolved.profiles)) { const profileState = current.profiles.get(name); const profile = resolveProfile(current.resolved, name); if (!profile) continue; let tabCount = 0; let running = false; if (profileState?.running) { running = true; try { tabCount = (await createProfileContext(opts, profile).listTabs()).filter((t) => t.type === "page").length; } catch {} } else try { if (await isChromeReachable(profile.cdpUrl, 200)) { running = true; tabCount = (await createProfileContext(opts, profile).listTabs().catch(() => [])).filter((t) => t.type === "page").length; } } catch {} result.push({ name, cdpPort: profile.cdpPort, cdpUrl: profile.cdpUrl, color: profile.color, running, tabCount, isDefault: name === current.resolved.defaultProfile, isRemote: !profile.cdpIsLoopback }); } return result; }; const getDefaultContext = () => forProfile(); const mapTabError = (err) => { const msg = String(err); if (msg.includes("ambiguous target id prefix")) return { status: 409, message: "ambiguous target id prefix" }; if (msg.includes("tab not found")) return { status: 404, message: msg }; if (msg.includes("not found")) return { status: 404, message: msg }; return null; }; return { state, forProfile, listProfiles, ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(), ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId), isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs), isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs), listTabs: () => getDefaultContext().listTabs(), openTab: (url) => getDefaultContext().openTab(url), focusTab: (targetId) => getDefaultContext().focusTab(targetId), closeTab: (targetId) => getDefaultContext().closeTab(targetId), stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(), resetProfile: () => getDefaultContext().resetProfile(), mapTabError }; } //#endregion //#region src/browser/bridge-server.ts async function startBrowserBridgeServer(params) { const host = params.host ?? "127.0.0.1"; const port = params.port ?? 0; const app = express(); app.use(express.json({ limit: "1mb" })); const authToken = params.authToken?.trim(); if (authToken) app.use((req, res, next) => { if (String(req.headers.authorization ?? "").trim() === `Bearer ${authToken}`) return next(); res.status(401).send("Unauthorized"); }); const state = { server: null, port, resolved: params.resolved, profiles: /* @__PURE__ */ new Map() }; registerBrowserRoutes(app, createBrowserRouteContext({ getState: () => state, onEnsureAttachTarget: params.onEnsureAttachTarget })); const server = await new Promise((resolve, reject) => { const s = app.listen(port, host, () => resolve(s)); s.once("error", reject); }); const resolvedPort = server.address()?.port ?? port; state.server = server; state.port = resolvedPort; state.resolved.controlPort = resolvedPort; return { server, port: resolvedPort, baseUrl: `http://${host}:${resolvedPort}`, state }; } async function stopBrowserBridgeServer(server) { await new Promise((resolve) => { server.close(() => resolve()); }); } //#endregion //#region src/agents/sandbox/browser-bridges.ts const BROWSER_BRIDGES = /* @__PURE__ */ new Map(); //#endregion //#region src/agents/sandbox/config-hash.ts function isPrimitive(value) { return value === null || typeof value !== "object" && typeof value !== "function"; } function normalizeForHash(value) { if (value === void 0) return; if (Array.isArray(value)) { const normalized = value.map(normalizeForHash).filter((item) => item !== void 0); const primitives = normalized.filter(isPrimitive); if (primitives.length === normalized.length) return [...primitives].toSorted((a, b) => primitiveToString(a).localeCompare(primitiveToString(b))); return normalized; } if (value && typeof value === "object") { const entries = Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b)); const normalized = {}; for (const [key, entryValue] of entries) { const next = normalizeForHash(entryValue); if (next !== void 0) normalized[key] = next; } return normalized; } return value; } function primitiveToString(value) { if (value === null) return "null"; if (typeof value === "string") return value; if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "true" : "false"; return JSON.stringify(value); } function computeSandboxConfigHash(input) { const payload = normalizeForHash(input); const raw = JSON.stringify(payload); return crypto.createHash("sha1").update(raw).digest("hex"); } //#endregion //#region src/agents/sandbox/registry.ts async function readRegistry() { try { const raw = await fs$1.readFile(SANDBOX_REGISTRY_PATH, "utf-8"); const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.entries)) return parsed; } catch {} return { entries: [] }; } async function writeRegistry(registry) { await fs$1.mkdir(SANDBOX_STATE_DIR, { recursive: true }); await fs$1.writeFile(SANDBOX_REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, "utf-8"); } async function updateRegistry(entry) { const registry = await readRegistry(); const existing = registry.entries.find((item) => item.containerName === entry.containerName); const next = registry.entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, configHash: entry.configHash ?? existing?.configHash }); await writeRegistry({ entries: next }); } async function removeRegistryEntry(containerName) { const registry = await readRegistry(); const next = registry.entries.filter((item) => item.containerName !== containerName); if (next.length === registry.entries.length) return; await writeRegistry({ entries: next }); } async function readBrowserRegistry() { try { const raw = await fs$1.readFile(SANDBOX_BROWSER_REGISTRY_PATH, "utf-8"); const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.entries)) return parsed; } catch {} return { entries: [] }; } async function writeBrowserRegistry(registry) { await fs$1.mkdir(SANDBOX_STATE_DIR, { recursive: true }); await fs$1.writeFile(SANDBOX_BROWSER_REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, "utf-8"); } async function updateBrowserRegistry(entry) { const registry = await readBrowserRegistry(); const existing = registry.entries.find((item) => item.containerName === entry.containerName); const next = registry.entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image }); await writeBrowserRegistry({ entries: next }); } async function removeBrowserRegistryEntry(containerName) { const registry = await readBrowserRegistry(); const next = registry.entries.filter((item) => item.containerName !== containerName); if (next.length === registry.entries.length) return; await writeBrowserRegistry({ entries: next }); } //#endregion //#region src/agents/sandbox/shared.ts function slugifySessionKey(value) { const trimmed = value.trim() || "session"; const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 8); return `${trimmed.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "session"}-${hash}`; } function resolveSandboxWorkspaceDir(root, sessionKey) { const resolvedRoot = resolveUserPath(root); const slug = slugifySessionKey(sessionKey); return path.join(resolvedRoot, slug); } function resolveSandboxScopeKey(scope, sessionKey) { const trimmed = sessionKey.trim() || "main"; if (scope === "shared") return "shared"; if (scope === "session") return trimmed; return `agent:${resolveAgentIdFromSessionKey(trimmed)}`; } function resolveSandboxAgentId(scopeKey) { const trimmed = scopeKey.trim(); if (!trimmed || trimmed === "shared") return; const parts = trimmed.split(":").filter(Boolean); if (parts[0] === "agent" && parts[1]) return normalizeAgentId(parts[1]); return resolveAgentIdFromSessionKey(trimmed); } //#endregion //#region src/agents/sandbox/docker.ts const HOT_CONTAINER_WINDOW_MS = 300 * 1e3; function execDocker(args, opts) { return new Promise((resolve, reject) => { const child = spawn("docker", args, { stdio: [ "ignore", "pipe", "pipe" ] }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("close", (code) => { const exitCode = code ?? 0; if (exitCode !== 0 && !opts?.allowFailure) { reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); return; } resolve({ stdout, stderr, code: exitCode }); }); }); } async function readDockerPort(containerName, port) { const result = await execDocker([ "port", containerName, `${port}/tcp` ], { allowFailure: true }); if (result.code !== 0) return null; const match = (result.stdout.trim().split(/\r?\n/)[0] ?? "").match(/:(\d+)\s*$/); if (!match) return null; const mapped = Number.parseInt(match[1] ?? "", 10); return Number.isFinite(mapped) ? mapped : null; } async function dockerImageExists(image) { const result = await execDocker([ "image", "inspect", image ], { allowFailure: true }); if (result.code === 0) return true; const stderr = result.stderr.trim(); if (stderr.includes("No such image")) return false; throw new Error(`Failed to inspect sandbox image: ${stderr}`); } async function ensureDockerImage(image) { if (await dockerImageExists(image)) return; if (image === DEFAULT_SANDBOX_IMAGE) { await execDocker(["pull", "debian:bookworm-slim"]); await execDocker([ "tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE ]); return; } throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); } async function dockerContainerState(name) { const result = await execDocker([ "inspect", "-f", "{{.State.Running}}", name ], { allowFailure: true }); if (result.code !== 0) return { exists: false, running: false }; return { exists: true, running: result.stdout.trim() === "true" }; } function normalizeDockerLimit(value) { if (value === void 0 || value === null) return; if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0; const trimmed = value.trim(); return trimmed ? trimmed : void 0; } function formatUlimitValue(name, value) { if (!name.trim()) return null; if (typeof value === "number" || typeof value === "string") { const raw = String(value).trim(); return raw ? `${name}=${raw}` : null; } const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : void 0; const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : void 0; if (soft === void 0 && hard === void 0) return null; if (soft === void 0) return `${name}=${hard}`; if (hard === void 0) return `${name}=${soft}`; return `${name}=${soft}:${hard}`; } function buildSandboxCreateArgs(params) { const createdAtMs = params.createdAtMs ?? Date.now(); const args = [ "create", "--name", params.name ]; args.push("--label", "openclaw.sandbox=1"); args.push("--label", `openclaw.sessionKey=${params.scopeKey}`); args.push("--label", `openclaw.createdAtMs=${createdAtMs}`); if (params.configHash) args.push("--label", `openclaw.configHash=${params.configHash}`); for (const [key, value] of Object.entries(params.labels ?? {})) if (key && value) args.push("--label", `${key}=${value}`); if (params.cfg.readOnlyRoot) args.push("--read-only"); for (const entry of params.cfg.tmpfs) args.push("--tmpfs", entry); if (params.cfg.network) args.push("--network", params.cfg.network); if (params.cfg.user) args.push("--user", params.cfg.user); for (const cap of params.cfg.capDrop) args.push("--cap-drop", cap); args.push("--security-opt", "no-new-privileges"); if (params.cfg.seccompProfile) args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`); if (params.cfg.apparmorProfile) args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`); for (const entry of params.cfg.dns ?? []) if (entry.trim()) args.push("--dns", entry); for (const entry of params.cfg.extraHosts ?? []) if (entry.trim()) args.push("--add-host", entry); if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) args.push("--pids-limit", String(params.cfg.pidsLimit)); const memory = normalizeDockerLimit(params.cfg.memory); if (memory) args.push("--memory", memory); const memorySwap = normalizeDockerLimit(params.cfg.memorySwap); if (memorySwap) args.push("--memory-swap", memorySwap); if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) args.push("--cpus", String(params.cfg.cpus)); for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) { const formatted = formatUlimitValue(name, value); if (formatted) args.push("--ulimit", formatted); } if (params.cfg.binds?.length) for (const bind of params.cfg.binds) args.push("-v", bind); return args; } async function createSandboxContainer(params) { const { name, cfg, workspaceDir, scopeKey } = params; await ensureDockerImage(cfg.image); const args = buildSandboxCreateArgs({ name, cfg, scopeKey, configHash: params.configHash }); args.push("--workdir", cfg.workdir); const mainMountSuffix = params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`); if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; args.push("-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`); } args.push(cfg.image, "sleep", "infinity"); await execDocker(args); await execDocker(["start", name]); if (cfg.setupCommand?.trim()) await execDocker([ "exec", "-i", name, "sh", "-lc", cfg.setupCommand ]); } async function readContainerConfigHash(containerName) { const readLabel = async (label) => { const result = await execDocker([ "inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName ], { allowFailure: true }); if (result.code !== 0) return null; const raw = result.stdout.trim(); if (!raw || raw === "") return null; return raw; }; return await readLabel("openclaw.configHash"); } function formatSandboxRecreateHint(params) { if (params.scope === "session") return formatCliCommand(`openclaw sandbox recreate --session ${params.sessionKey}`); if (params.scope === "agent") return formatCliCommand(`openclaw sandbox recreate --agent ${resolveSandboxAgentId(params.sessionKey) ?? "main"}`); return formatCliCommand("openclaw sandbox recreate --all"); } async function ensureSandboxContainer(params) { const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); const containerName = `${params.cfg.docker.containerPrefix}${slug}`.slice(0, 63); const expectedHash = computeSandboxConfigHash({ docker: params.cfg.docker, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir }); const now = Date.now(); const state = await dockerContainerState(containerName); let hasContainer = state.exists; let running = state.running; let currentHash = null; let hashMismatch = false; let registryEntry; if (hasContainer) { registryEntry = (await readRegistry()).entries.find((entry) => entry.containerName === containerName); currentHash = await readContainerConfigHash(containerName); if (!currentHash) currentHash = registryEntry?.configHash ?? null; hashMismatch = !currentHash || currentHash !== expectedHash; if (hashMismatch) { const lastUsedAtMs = registryEntry?.lastUsedAtMs; if (running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS)) { const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); defaultRuntime.log(`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`); } else { await execDocker([ "rm", "-f", containerName ], { allowFailure: true }); hasContainer = false; running = false; } } } if (!hasContainer) await createSandboxContainer({ name: containerName, cfg: params.cfg.docker, workspaceDir: params.workspaceDir, workspaceAccess: params.cfg.workspaceAccess, agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, configHash: expectedHash }); else if (!running) await execDocker(["start", containerName]); await updateRegistry({ containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, configHash: hashMismatch && running ? currentHash ?? void 0 : expectedHash }); return containerName; } //#endregion //#region src/agents/sandbox/browser.ts async function waitForSandboxCdp(params) { const deadline = Date.now() + Math.max(0, params.timeoutMs); const url = `http://127.0.0.1:${params.cdpPort}/json/version`; while (Date.now() < deadline) { try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 1e3); try { if ((await fetch(url, { signal: ctrl.signal })).ok) return true; } finally { clearTimeout(t); } } catch {} await new Promise((r) => setTimeout(r, 150)); } return false; } function buildSandboxBrowserResolvedConfig(params) { return { enabled: true, evaluateEnabled: params.evaluateEnabled, controlPort: params.controlPort, cdpProtocol: "http", cdpHost: "127.0.0.1", cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3e3, color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: void 0, headless: params.headless, noSandbox: false, attachOnly: true, defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, profiles: { [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: { cdpPort: params.cdpPort, color: DEFAULT_OPENCLAW_BROWSER_COLOR } } }; } async function ensureSandboxBrowserImage(image) { if ((await execDocker([ "image", "inspect", image ], { allowFailure: true })).code === 0) return; throw new Error(`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`); } async function ensureSandboxBrowser(params) { if (!params.cfg.browser.enabled) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null; const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const containerName = `${params.cfg.browser.containerPrefix}${slug}`.slice(0, 63); const state = await dockerContainerState(containerName); if (!state.exists) { await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE); const args = buildSandboxCreateArgs({ name: containerName, cfg: params.cfg.docker, scopeKey: params.scopeKey, labels: { "openclaw.sandboxBrowser": "1" } }); const mainMountSuffix = params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`); if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; args.push("-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`); } args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); args.push(params.cfg.browser.image); await execDocker(args); await execDocker(["start", containerName]); } else if (!state.running) await execDocker(["start", containerName]); const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort); if (!mappedCdp) throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); const mappedNoVnc = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? await readDockerPort(containerName, params.cfg.browser.noVncPort) : null; const existing = BROWSER_BRIDGES.get(params.scopeKey); const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) : null; const shouldReuse = existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; if (existing && !shouldReuse) { await stopBrowserBridgeServer(existing.bridge.server).catch(() => void 0); BROWSER_BRIDGES.delete(params.scopeKey); } const bridge = (() => { if (shouldReuse && existing) return existing.bridge; return null; })(); const ensureBridge = async () => { if (bridge) return bridge; const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { const state = await dockerContainerState(containerName); if (state.exists && !state.running) await execDocker(["start", containerName]); if (!await waitForSandboxCdp({ cdpPort: mappedCdp, timeoutMs: params.cfg.browser.autoStartTimeoutMs })) throw new Error(`Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`); } : void 0; return await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ controlPort: 0, cdpPort: mappedCdp, headless: params.cfg.browser.headless, evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED }), onEnsureAttachTarget }); }; const resolvedBridge = await ensureBridge(); if (!shouldReuse) BROWSER_BRIDGES.set(params.scopeKey, { bridge: resolvedBridge, containerName }); const now = Date.now(); await updateBrowserRegistry({ containerName, sessionKey: params.scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.browser.image, cdpPort: mappedCdp, noVncPort: mappedNoVnc ?? void 0 }); const noVncUrl = mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` : void 0; return { bridgeUrl: resolvedBridge.baseUrl, noVncUrl, containerName }; } //#endregion //#region src/agents/sandbox/prune.ts let lastPruneAtMs = 0; async function pruneSandboxContainers(cfg) { const now = Date.now(); const idleHours = cfg.prune.idleHours; const maxAgeDays = cfg.prune.maxAgeDays; if (idleHours === 0 && maxAgeDays === 0) return; const registry = await readRegistry(); for (const entry of registry.entries) { const idleMs = now - entry.lastUsedAtMs; const ageMs = now - entry.createdAtMs; if (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1e3 || maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1e3) try { await execDocker([ "rm", "-f", entry.containerName ], { allowFailure: true }); } catch {} finally { await removeRegistryEntry(entry.containerName); } } } async function pruneSandboxBrowsers(cfg) { const now = Date.now(); const idleHours = cfg.prune.idleHours; const maxAgeDays = cfg.prune.maxAgeDays; if (idleHours === 0 && maxAgeDays === 0) return; const registry = await readBrowserRegistry(); for (const entry of registry.entries) { const idleMs = now - entry.lastUsedAtMs; const ageMs = now - entry.createdAtMs; if (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1e3 || maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1e3) try { await execDocker([ "rm", "-f", entry.containerName ], { allowFailure: true }); } catch {} finally { await removeBrowserRegistryEntry(entry.containerName); const bridge = BROWSER_BRIDGES.get(entry.sessionKey); if (bridge?.containerName === entry.containerName) { await stopBrowserBridgeServer(bridge.bridge.server).catch(() => void 0); BROWSER_BRIDGES.delete(entry.sessionKey); } } } } async function maybePruneSandboxes(cfg) { const now = Date.now(); if (now - lastPruneAtMs < 300 * 1e3) return; lastPruneAtMs = now; try { await pruneSandboxContainers(cfg); await pruneSandboxBrowsers(cfg); } catch (error) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error); defaultRuntime.error?.(`Sandbox prune failed: ${message ?? "unknown error"}`); } } //#endregion //#region src/config/sessions/group.ts const getGroupSurfaces = () => new Set([...listDeliverableMessageChannels(), "webchat"]); function normalizeGroupLabel(raw) { const trimmed = raw?.trim().toLowerCase() ?? ""; if (!trimmed) return ""; return trimmed.replace(/\s+/g, "-").replace(/[^a-z0-9#@._+-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } function shortenGroupId(value) { const trimmed = value?.trim() ?? ""; if (!trimmed) return ""; if (trimmed.length <= 14) return trimmed; return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; } function buildGroupDisplayName(params) { const providerKey = (params.provider?.trim().toLowerCase() || "group").trim(); const groupChannel = params.groupChannel?.trim(); const space = params.space?.trim(); const subject = params.subject?.trim(); const detail = (groupChannel && space ? `${space}${groupChannel.startsWith("#") ? "" : "#"}${groupChannel}` : groupChannel || subject || space || "") || ""; const fallbackId = params.id?.trim() || params.key; const rawLabel = detail || fallbackId; let token = normalizeGroupLabel(rawLabel); if (!token) token = normalizeGroupLabel(shortenGroupId(rawLabel)); if (!params.groupChannel && token.startsWith("#")) token = token.replace(/^#+/, ""); if (token && !/^[@#]/.test(token) && !token.startsWith("g-") && !token.includes("#")) token = `g-${token}`; return token ? `${providerKey}:${token}` : providerKey; } function resolveGroupSessionKey(ctx) { const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; const chatType = ctx.ChatType?.trim().toLowerCase(); const normalizedChatType = chatType === "channel" ? "channel" : chatType === "group" ? "group" : void 0; const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us"); if (!(normalizedChatType === "group" || normalizedChatType === "channel" || from.includes(":group:") || from.includes(":channel:") || isWhatsAppGroupId)) return null; const providerHint = ctx.Provider?.trim().toLowerCase(); const parts = from.split(":").filter(Boolean); const head = parts[0]?.trim().toLowerCase() ?? ""; const headIsSurface = head ? getGroupSurfaces().has(head) : false; const provider = headIsSurface ? head : providerHint ?? (isWhatsAppGroupId ? "whatsapp" : void 0); if (!provider) return null; const second = parts[1]?.trim().toLowerCase(); const secondIsKind = second === "group" || second === "channel"; const kind = secondIsKind ? second : from.includes(":channel:") || normalizedChatType === "channel" ? "channel" : "group"; const finalId = (headIsSurface ? secondIsKind ? parts.slice(2).join(":") : parts.slice(1).join(":") : from).trim().toLowerCase(); if (!finalId) return null; return { key: `${provider}:${kind}:${finalId}`, channel: provider, id: finalId, chatType: kind === "channel" ? "channel" : "group" }; } //#endregion //#region src/channels/conversation-label.ts function extractConversationId(from) { const trimmed = from?.trim(); if (!trimmed) return; const parts = trimmed.split(":").filter(Boolean); return parts.length > 0 ? parts[parts.length - 1] : trimmed; } function shouldAppendId(id) { if (/^[0-9]+$/.test(id)) return true; if (id.includes("@g.us")) return true; return false; } function resolveConversationLabel(ctx) { const explicit = ctx.ConversationLabel?.trim(); if (explicit) return explicit; const threadLabel = ctx.ThreadLabel?.trim(); if (threadLabel) return threadLabel; if (normalizeChatType(ctx.ChatType) === "direct") return ctx.SenderName?.trim() || ctx.From?.trim() || void 0; const base = ctx.GroupChannel?.trim() || ctx.GroupSubject?.trim() || ctx.GroupSpace?.trim() || ctx.From?.trim() || ""; if (!base) return; const id = extractConversationId(ctx.From); if (!id) return base; if (!shouldAppendId(id)) return base; if (base === id) return base; if (base.includes(id)) return base; if (base.toLowerCase().includes(" id:")) return base; if (base.startsWith("#") || base.startsWith("@")) return base; return `${base} id:${id}`; } //#endregion //#region src/imessage/accounts.ts function resolveAccountConfig(cfg, accountId) { const accounts = cfg.channels?.imessage?.accounts; if (!accounts || typeof accounts !== "object") return; return accounts[accountId]; } function mergeIMessageAccountConfig(cfg, accountId) { const { accounts: _ignored, ...base } = cfg.channels?.imessage ?? {}; const account = resolveAccountConfig(cfg, accountId) ?? {}; return { ...base, ...account }; } function resolveIMessageAccount(params) { const accountId = normalizeAccountId$1(params.accountId); const baseEnabled = params.cfg.channels?.imessage?.enabled !== false; const merged = mergeIMessageAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const configured = Boolean(merged.cliPath?.trim() || merged.dbPath?.trim() || merged.service || merged.region?.trim() || merged.allowFrom && merged.allowFrom.length > 0 || merged.groupAllowFrom && merged.groupAllowFrom.length > 0 || merged.dmPolicy || merged.groupPolicy || typeof merged.includeAttachments === "boolean" || typeof merged.mediaMaxMb === "number" || typeof merged.textChunkLimit === "number" || merged.groups && Object.keys(merged.groups).length > 0); return { accountId, enabled: baseEnabled && accountEnabled, name: merged.name?.trim() || void 0, config: merged, configured }; } //#endregion //#region src/slack/threading-tool-context.ts function buildSlackThreadingToolContext(params) { const configuredReplyToMode = resolveSlackReplyToMode(resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }), params.context.ChatType); const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode; const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; return { currentChannelId: params.context.To?.startsWith("channel:") ? params.context.To.slice(8) : void 0, currentThreadTs: threadId != null ? String(threadId) : void 0, replyToMode: effectiveReplyToMode, hasRepliedRef: params.hasRepliedRef }; } //#endregion //#region src/config/group-policy.ts function normalizeSenderKey(value) { const trimmed = value.trim(); if (!trimmed) return ""; return (trimmed.startsWith("@") ? trimmed.slice(1) : trimmed).toLowerCase(); } function resolveToolsBySender(params) { const toolsBySender = params.toolsBySender; if (!toolsBySender) return; const entries = Object.entries(toolsBySender); if (entries.length === 0) return; const normalized = /* @__PURE__ */ new Map(); let wildcard; for (const [rawKey, policy] of entries) { if (!policy) continue; const key = normalizeSenderKey(rawKey); if (!key) continue; if (key === "*") { wildcard = policy; continue; } if (!normalized.has(key)) normalized.set(key, policy); } const candidates = []; const pushCandidate = (value) => { const trimmed = value?.trim(); if (!trimmed) return; candidates.push(trimmed); }; pushCandidate(params.senderId); pushCandidate(params.senderE164); pushCandidate(params.senderUsername); pushCandidate(params.senderName); for (const candidate of candidates) { const key = normalizeSenderKey(candidate); if (!key) continue; const match = normalized.get(key); if (match) return match; } return wildcard; } function resolveChannelGroups(cfg, channel, accountId) { const normalizedAccountId = normalizeAccountId$1(accountId); const channelConfig = cfg.channels?.[channel]; if (!channelConfig) return; return channelConfig.accounts?.[normalizedAccountId]?.groups ?? channelConfig.accounts?.[Object.keys(channelConfig.accounts ?? {}).find((key) => key.toLowerCase() === normalizedAccountId.toLowerCase()) ?? ""]?.groups ?? channelConfig.groups; } function resolveChannelGroupPolicy(params) { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); const normalizedId = params.groupId?.trim(); const groupConfig = normalizedId && groups ? groups[normalizedId] : void 0; const defaultConfig = groups?.["*"]; return { allowlistEnabled, allowed: !allowlistEnabled || allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")) || (normalizedId ? Boolean(groups && Object.hasOwn(groups, normalizedId)) : false), groupConfig, defaultConfig }; } function resolveChannelGroupRequireMention(params) { const { requireMentionOverride, overrideOrder = "after-config" } = params; const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); const configMention = typeof groupConfig?.requireMention === "boolean" ? groupConfig.requireMention : typeof defaultConfig?.requireMention === "boolean" ? defaultConfig.requireMention : void 0; if (overrideOrder === "before-config" && typeof requireMentionOverride === "boolean") return requireMentionOverride; if (typeof configMention === "boolean") return configMention; if (overrideOrder !== "before-config" && typeof requireMentionOverride === "boolean") return requireMentionOverride; return true; } function resolveChannelGroupToolsPolicy(params) { const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); const groupSenderPolicy = resolveToolsBySender({ toolsBySender: groupConfig?.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); if (groupSenderPolicy) return groupSenderPolicy; if (groupConfig?.tools) return groupConfig.tools; const defaultSenderPolicy = resolveToolsBySender({ toolsBySender: defaultConfig?.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); if (defaultSenderPolicy) return defaultSenderPolicy; if (defaultConfig?.tools) return defaultConfig.tools; } //#endregion //#region src/channels/plugins/group-mentions.ts function normalizeDiscordSlug(value) { if (!value) return ""; let text = value.trim().toLowerCase(); if (!text) return ""; text = text.replace(/^[@#]+/, ""); text = text.replace(/[\s_]+/g, "-"); text = text.replace(/[^a-z0-9-]+/g, "-"); text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); return text; } function normalizeSlackSlug(raw) { const trimmed = raw?.trim().toLowerCase() ?? ""; if (!trimmed) return ""; return trimmed.replace(/\s+/g, "-").replace(/[^a-z0-9#@._+-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } function parseTelegramGroupId(value) { const raw = value?.trim() ?? ""; if (!raw) return { chatId: void 0, topicId: void 0 }; const parts = raw.split(":").filter(Boolean); if (parts.length >= 3 && parts[1] === "topic" && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[2])) return { chatId: parts[0], topicId: parts[2] }; if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) return { chatId: parts[0], topicId: parts[1] }; return { chatId: raw, topicId: void 0 }; } function resolveTelegramRequireMention(params) { const { cfg, chatId, topicId } = params; if (!chatId) return; const groupConfig = cfg.channels?.telegram?.groups?.[chatId]; const groupDefault = cfg.channels?.telegram?.groups?.["*"]; const topicConfig = topicId && groupConfig?.topics ? groupConfig.topics[topicId] : void 0; const defaultTopicConfig = topicId && groupDefault?.topics ? groupDefault.topics[topicId] : void 0; if (typeof topicConfig?.requireMention === "boolean") return topicConfig.requireMention; if (typeof defaultTopicConfig?.requireMention === "boolean") return defaultTopicConfig.requireMention; if (typeof groupConfig?.requireMention === "boolean") return groupConfig.requireMention; if (typeof groupDefault?.requireMention === "boolean") return groupDefault.requireMention; } function resolveDiscordGuildEntry(guilds, groupSpace) { if (!guilds || Object.keys(guilds).length === 0) return null; const space = groupSpace?.trim() ?? ""; if (space && guilds[space]) return guilds[space]; const normalized = normalizeDiscordSlug(space); if (normalized && guilds[normalized]) return guilds[normalized]; if (normalized) { const match = Object.values(guilds).find((entry) => normalizeDiscordSlug(entry?.slug ?? void 0) === normalized); if (match) return match; } return guilds["*"] ?? null; } function resolveTelegramGroupRequireMention(params) { const { chatId, topicId } = parseTelegramGroupId(params.groupId); const requireMention = resolveTelegramRequireMention({ cfg: params.cfg, chatId, topicId }); if (typeof requireMention === "boolean") return requireMention; return resolveChannelGroupRequireMention({ cfg: params.cfg, channel: "telegram", groupId: chatId ?? params.groupId, accountId: params.accountId }); } function resolveWhatsAppGroupRequireMention(params) { return resolveChannelGroupRequireMention({ cfg: params.cfg, channel: "whatsapp", groupId: params.groupId, accountId: params.accountId }); } function resolveIMessageGroupRequireMention(params) { return resolveChannelGroupRequireMention({ cfg: params.cfg, channel: "imessage", groupId: params.groupId, accountId: params.accountId }); } function resolveDiscordGroupRequireMention(params) { const guildEntry = resolveDiscordGuildEntry(params.cfg.channels?.discord?.guilds, params.groupSpace); const channelEntries = guildEntry?.channels; if (channelEntries && Object.keys(channelEntries).length > 0) { const groupChannel = params.groupChannel; const channelSlug = normalizeDiscordSlug(groupChannel); const entry = (params.groupId ? channelEntries[params.groupId] : void 0) ?? (channelSlug ? channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`] : void 0) ?? (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : void 0); if (entry && typeof entry.requireMention === "boolean") return entry.requireMention; } if (typeof guildEntry?.requireMention === "boolean") return guildEntry.requireMention; return true; } function resolveGoogleChatGroupRequireMention(params) { return resolveChannelGroupRequireMention({ cfg: params.cfg, channel: "googlechat", groupId: params.groupId, accountId: params.accountId }); } function resolveGoogleChatGroupToolPolicy(params) { return resolveChannelGroupToolsPolicy({ cfg: params.cfg, channel: "googlechat", groupId: params.groupId, accountId: params.accountId, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); } function resolveSlackGroupRequireMention(params) { const channels = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }).channels ?? {}; if (Object.keys(channels).length === 0) return true; const channelId = params.groupId?.trim(); const channelName = params.groupChannel?.replace(/^#/, ""); const normalizedName = normalizeSlackSlug(channelName); const candidates = [ channelId ?? "", channelName ? `#${channelName}` : "", channelName ?? "", normalizedName ].filter(Boolean); let matched; for (const candidate of candidates) if (candidate && channels[candidate]) { matched = channels[candidate]; break; } const fallback = channels["*"]; const resolved = matched ?? fallback; if (typeof resolved?.requireMention === "boolean") return resolved.requireMention; return true; } function resolveTelegramGroupToolPolicy(params) { const { chatId } = parseTelegramGroupId(params.groupId); return resolveChannelGroupToolsPolicy({ cfg: params.cfg, channel: "telegram", groupId: chatId ?? params.groupId, accountId: params.accountId, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); } function resolveWhatsAppGroupToolPolicy(params) { return resolveChannelGroupToolsPolicy({ cfg: params.cfg, channel: "whatsapp", groupId: params.groupId, accountId: params.accountId, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); } function resolveIMessageGroupToolPolicy(params) { return resolveChannelGroupToolsPolicy({ cfg: params.cfg, channel: "imessage", groupId: params.groupId, accountId: params.accountId, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); } function resolveDiscordGroupToolPolicy(params) { const guildEntry = resolveDiscordGuildEntry(params.cfg.channels?.discord?.guilds, params.groupSpace); const channelEntries = guildEntry?.channels; if (channelEntries && Object.keys(channelEntries).length > 0) { const groupChannel = params.groupChannel; const channelSlug = normalizeDiscordSlug(groupChannel); const entry = (params.groupId ? channelEntries[params.groupId] : void 0) ?? (channelSlug ? channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`] : void 0) ?? (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : void 0); const senderPolicy = resolveToolsBySender({ toolsBySender: entry?.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); if (senderPolicy) return senderPolicy; if (entry?.tools) return entry.tools; } const guildSenderPolicy = resolveToolsBySender({ toolsBySender: guildEntry?.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); if (guildSenderPolicy) return guildSenderPolicy; if (guildEntry?.tools) return guildEntry.tools; } function resolveSlackGroupToolPolicy(params) { const channels = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }).channels ?? {}; if (Object.keys(channels).length === 0) return; const channelId = params.groupId?.trim(); const channelName = params.groupChannel?.replace(/^#/, ""); const normalizedName = normalizeSlackSlug(channelName); const candidates = [ channelId ?? "", channelName ? `#${channelName}` : "", channelName ?? "", normalizedName ].filter(Boolean); let matched; for (const candidate of candidates) if (candidate && channels[candidate]) { matched = channels[candidate]; break; } const resolved = matched ?? channels["*"]; const senderPolicy = resolveToolsBySender({ toolsBySender: resolved?.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164 }); if (senderPolicy) return senderPolicy; if (resolved?.tools) return resolved.tools; } //#endregion //#region src/channels/dock.ts const formatLower = (allowFrom) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean).map((entry) => entry.toLowerCase()); const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const DOCKS = { telegram: { id: "telegram", capabilities: { chatTypes: [ "direct", "group", "channel", "thread" ], nativeCommands: true, blockStreaming: true }, outbound: { textChunkLimit: 4e3 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean).map((entry) => entry.replace(/^(telegram|tg):/i, "")).map((entry) => entry.toLowerCase()) }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, resolveToolPolicy: resolveTelegramGroupToolPolicy }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", buildToolContext: ({ context, hasRepliedRef }) => { const threadId = context.MessageThreadId ?? context.ReplyToId; return { currentChannelId: context.To?.trim() || void 0, currentThreadTs: threadId != null ? String(threadId) : void 0, hasRepliedRef }; } } }, whatsapp: { id: "whatsapp", capabilities: { chatTypes: ["direct", "group"], polls: true, reactions: true, media: true }, commands: { enforceOwnerForCommands: true, skipWhenConfigEmpty: true }, outbound: { textChunkLimit: 4e3 }, config: { resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter((entry) => Boolean(entry)).map((entry) => entry === "*" ? entry : normalizeWhatsAppTarget(entry)).filter((entry) => Boolean(entry)) }, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, resolveGroupIntroHint: () => "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant)." }, mentions: { stripPatterns: ({ ctx }) => { const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); if (!selfE164) return []; const escaped = escapeRegExp(selfE164); return [escaped, `@${escaped}`]; } }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { return { currentChannelId: context.From?.trim() || context.To?.trim() || void 0, currentThreadTs: context.ReplyToId, hasRepliedRef }; } } }, discord: { id: "discord", capabilities: { chatTypes: [ "direct", "channel", "thread" ], polls: true, reactions: true, media: true, nativeCommands: true, threads: true }, outbound: { textChunkLimit: 2e3 }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1e3 } }, elevated: { allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom) }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, resolveToolPolicy: resolveDiscordGroupToolPolicy }, mentions: { stripPatterns: () => ["<@!?\\d+>"] }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || void 0, currentThreadTs: context.ReplyToId, hasRepliedRef }) } }, googlechat: { id: "googlechat", capabilities: { chatTypes: [ "direct", "group", "thread" ], reactions: true, media: true, threads: true, blockStreaming: true }, outbound: { textChunkLimit: 4e3 }, config: { resolveAllowFrom: ({ cfg, accountId }) => { const channel = cfg.channels?.googlechat; const normalized = normalizeAccountId$1(accountId); return ((channel?.accounts?.[normalized] ?? channel?.accounts?.[Object.keys(channel?.accounts ?? {}).find((key) => key.toLowerCase() === normalized.toLowerCase()) ?? ""])?.dm?.allowFrom ?? channel?.dm?.allowFrom ?? []).map((entry) => String(entry)); }, formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean).map((entry) => entry.replace(/^(googlechat|google-chat|gchat):/i, "").replace(/^user:/i, "").replace(/^users\//i, "").toLowerCase()) }, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, resolveToolPolicy: resolveGoogleChatGroupToolPolicy }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off", buildToolContext: ({ context, hasRepliedRef }) => { const threadId = context.MessageThreadId ?? context.ReplyToId; return { currentChannelId: context.To?.trim() || void 0, currentThreadTs: threadId != null ? String(threadId) : void 0, hasRepliedRef }; } } }, slack: { id: "slack", capabilities: { chatTypes: [ "direct", "channel", "thread" ], reactions: true, media: true, nativeCommands: true, threads: true }, outbound: { textChunkLimit: 4e3 }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1e3 } }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom) }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, resolveToolPolicy: resolveSlackGroupToolPolicy }, threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), allowTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params) } }, signal: { id: "signal", capabilities: { chatTypes: ["direct", "group"], reactions: true, media: true }, outbound: { textChunkLimit: 4e3 }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1e3 } }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean).map((entry) => entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))).filter(Boolean) }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { return { currentChannelId: (context.ChatType?.toLowerCase() === "direct" ? context.From ?? context.To : context.To)?.trim() || void 0, currentThreadTs: context.ReplyToId, hasRepliedRef }; } } }, imessage: { id: "imessage", capabilities: { chatTypes: ["direct", "group"], reactions: true, media: true }, outbound: { textChunkLimit: 4e3 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean) }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, resolveToolPolicy: resolveIMessageGroupToolPolicy }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { return { currentChannelId: (context.ChatType?.toLowerCase() === "direct" ? context.From ?? context.To : context.To)?.trim() || void 0, currentThreadTs: context.ReplyToId, hasRepliedRef }; } } } }; function buildDockFromPlugin(plugin) { return { id: plugin.id, capabilities: plugin.capabilities, commands: plugin.commands, outbound: plugin.outbound?.textChunkLimit ? { textChunkLimit: plugin.outbound.textChunkLimit } : void 0, streaming: plugin.streaming ? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults } : void 0, elevated: plugin.elevated, config: plugin.config ? { resolveAllowFrom: plugin.config.resolveAllowFrom, formatAllowFrom: plugin.config.formatAllowFrom } : void 0, groups: plugin.groups, mentions: plugin.mentions, threading: plugin.threading, agentPrompt: plugin.agentPrompt }; } function listPluginDockEntries() { const registry = requireActivePluginRegistry(); const entries = []; const seen = /* @__PURE__ */ new Set(); for (const entry of registry.channels) { const plugin = entry.plugin; const id = String(plugin.id).trim(); if (!id || seen.has(id)) continue; seen.add(id); if (CHAT_CHANNEL_ORDER.includes(plugin.id)) continue; const dock = entry.dock ?? buildDockFromPlugin(plugin); entries.push({ id: plugin.id, dock, order: plugin.meta.order }); } return entries; } function listChannelDocks() { const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({ id, dock: DOCKS[id], order: getChatChannelMeta(id).order })); const pluginEntries = listPluginDockEntries(); const combined = [...baseEntries, ...pluginEntries]; combined.sort((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id); const orderA = a.order ?? (indexA === -1 ? 999 : indexA); const orderB = b.order ?? (indexB === -1 ? 999 : indexB); if (orderA !== orderB) return orderA - orderB; return String(a.id).localeCompare(String(b.id)); }); return combined.map((entry) => entry.dock); } function getChannelDock(id) { const core = DOCKS[id]; if (core) return core; const pluginEntry = requireActivePluginRegistry().channels.find((entry) => entry.plugin.id === id); if (!pluginEntry) return; return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin); } //#endregion //#region src/config/sessions/metadata.ts const mergeOrigin = (existing, next) => { if (!existing && !next) return; const merged = existing ? { ...existing } : {}; if (next?.label) merged.label = next.label; if (next?.provider) merged.provider = next.provider; if (next?.surface) merged.surface = next.surface; if (next?.chatType) merged.chatType = next.chatType; if (next?.from) merged.from = next.from; if (next?.to) merged.to = next.to; if (next?.accountId) merged.accountId = next.accountId; if (next?.threadId != null && next.threadId !== "") merged.threadId = next.threadId; return Object.keys(merged).length > 0 ? merged : void 0; }; function deriveSessionOrigin(ctx) { const label = resolveConversationLabel(ctx)?.trim(); const provider = normalizeMessageChannel(typeof ctx.OriginatingChannel === "string" && ctx.OriginatingChannel || ctx.Surface || ctx.Provider); const surface = ctx.Surface?.trim().toLowerCase(); const chatType = normalizeChatType(ctx.ChatType) ?? void 0; const from = ctx.From?.trim(); const to = (typeof ctx.OriginatingTo === "string" ? ctx.OriginatingTo : ctx.To)?.trim() ?? void 0; const accountId = ctx.AccountId?.trim(); const threadId = ctx.MessageThreadId ?? void 0; const origin = {}; if (label) origin.label = label; if (provider) origin.provider = provider; if (surface) origin.surface = surface; if (chatType) origin.chatType = chatType; if (from) origin.from = from; if (to) origin.to = to; if (accountId) origin.accountId = accountId; if (threadId != null && threadId !== "") origin.threadId = threadId; return Object.keys(origin).length > 0 ? origin : void 0; } function deriveGroupSessionPatch(params) { const resolution = params.groupResolution ?? resolveGroupSessionKey(params.ctx); if (!resolution?.channel) return null; const channel = resolution.channel; const subject = params.ctx.GroupSubject?.trim(); const space = params.ctx.GroupSpace?.trim(); const explicitChannel = params.ctx.GroupChannel?.trim(); const normalizedChannel = normalizeChannelId(channel); const isChannelProvider = Boolean(normalizedChannel && getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel")); const nextGroupChannel = explicitChannel ?? ((resolution.chatType === "channel" || isChannelProvider) && subject && subject.startsWith("#") ? subject : void 0); const nextSubject = nextGroupChannel ? void 0 : subject; const patch = { chatType: resolution.chatType ?? "group", channel, groupId: resolution.id }; if (nextSubject) patch.subject = nextSubject; if (nextGroupChannel) patch.groupChannel = nextGroupChannel; if (space) patch.space = space; const displayName = buildGroupDisplayName({ provider: channel, subject: nextSubject ?? params.existing?.subject, groupChannel: nextGroupChannel ?? params.existing?.groupChannel, space: space ?? params.existing?.space, id: resolution.id, key: params.sessionKey }); if (displayName) patch.displayName = displayName; return patch; } function deriveSessionMetaPatch(params) { const groupPatch = deriveGroupSessionPatch(params); const origin = deriveSessionOrigin(params.ctx); if (!groupPatch && !origin) return null; const patch = groupPatch ? { ...groupPatch } : {}; const mergedOrigin = mergeOrigin(params.existing?.origin, origin); if (mergedOrigin) patch.origin = mergedOrigin; return Object.keys(patch).length > 0 ? patch : null; } //#endregion //#region src/config/sessions/main-session.ts function resolveMainSessionKey(cfg) { if (cfg?.session?.scope === "global") return "global"; const agents = cfg?.agents?.list ?? []; return buildAgentMainSessionKey({ agentId: normalizeAgentId(agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID), mainKey: normalizeMainKey(cfg?.session?.mainKey) }); } function resolveAgentMainSessionKey(params) { const mainKey = normalizeMainKey(params.cfg?.session?.mainKey); return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } function canonicalizeMainSessionAlias(params) { const raw = params.sessionKey.trim(); if (!raw) return raw; const agentId = normalizeAgentId(params.agentId); const mainKey = normalizeMainKey(params.cfg?.session?.mainKey); const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey }); const agentMainAliasKey = buildAgentMainSessionKey({ agentId, mainKey: "main" }); const isMainAlias = raw === "main" || raw === mainKey || raw === agentMainSessionKey || raw === agentMainAliasKey; if (params.cfg?.session?.scope === "global" && isMainAlias) return "global"; if (isMainAlias) return agentMainSessionKey; return raw; } //#endregion //#region src/config/sessions/types.ts function mergeSessionEntry(existing, patch) { const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now()); if (!existing) return { ...patch, sessionId, updatedAt }; return { ...existing, ...patch, sessionId, updatedAt }; } const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; const DEFAULT_IDLE_MINUTES = 60; //#endregion //#region src/config/sessions/reset.ts const DEFAULT_RESET_MODE = "daily"; const DEFAULT_RESET_AT_HOUR = 4; const THREAD_SESSION_MARKERS = [":thread:", ":topic:"]; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; function isThreadSessionKey(sessionKey) { const normalized = (sessionKey ?? "").toLowerCase(); if (!normalized) return false; return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker)); } function resolveSessionResetType(params) { if (params.isThread || isThreadSessionKey(params.sessionKey)) return "thread"; if (params.isGroup) return "group"; const normalized = (params.sessionKey ?? "").toLowerCase(); if (GROUP_SESSION_MARKERS.some((marker) => normalized.includes(marker))) return "group"; return "dm"; } function resolveThreadFlag(params) { if (params.messageThreadId != null) return true; if (params.threadLabel?.trim()) return true; if (params.threadStarterBody?.trim()) return true; if (params.parentSessionKey?.trim()) return true; return isThreadSessionKey(params.sessionKey); } function resolveDailyResetAtMs(now, atHour) { const normalizedAtHour = normalizeResetAtHour(atHour); const resetAt = new Date(now); resetAt.setHours(normalizedAtHour, 0, 0, 0); if (now < resetAt.getTime()) resetAt.setDate(resetAt.getDate() - 1); return resetAt.getTime(); } function resolveSessionResetPolicy(params) { const sessionCfg = params.sessionCfg; const baseReset = params.resetOverride ?? sessionCfg?.reset; const typeReset = params.resetOverride ? void 0 : sessionCfg?.resetByType?.[params.resetType]; const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); const legacyIdleMinutes = params.resetOverride ? void 0 : sessionCfg?.idleMinutes; const mode = typeReset?.mode ?? baseReset?.mode ?? (!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE); const atHour = normalizeResetAtHour(typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR); const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes; let idleMinutes; if (idleMinutesRaw != null) { const normalized = Math.floor(idleMinutesRaw); if (Number.isFinite(normalized)) idleMinutes = Math.max(normalized, 1); } else if (mode === "idle") idleMinutes = DEFAULT_IDLE_MINUTES; return { mode, atHour, idleMinutes }; } function resolveChannelResetConfig(params) { const resetByChannel = params.sessionCfg?.resetByChannel; if (!resetByChannel) return; const normalized = normalizeMessageChannel(params.channel); const fallback = params.channel?.trim().toLowerCase(); const key = normalized ?? fallback; if (!key) return; return resetByChannel[key] ?? resetByChannel[key.toLowerCase()]; } function evaluateSessionFreshness(params) { const dailyResetAt = params.policy.mode === "daily" ? resolveDailyResetAtMs(params.now, params.policy.atHour) : void 0; const idleExpiresAt = params.policy.idleMinutes != null ? params.updatedAt + params.policy.idleMinutes * 6e4 : void 0; const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt; const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt; return { fresh: !(staleDaily || staleIdle), dailyResetAt, idleExpiresAt }; } function normalizeResetAtHour(value) { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RESET_AT_HOUR; const normalized = Math.floor(value); if (!Number.isFinite(normalized)) return DEFAULT_RESET_AT_HOUR; if (normalized < 0) return 0; if (normalized > 23) return 23; return normalized; } //#endregion //#region src/config/sessions/session-key.ts function deriveSessionKey(scope, ctx) { if (scope === "global") return "global"; const resolvedGroup = resolveGroupSessionKey(ctx); if (resolvedGroup) return resolvedGroup.key; return (ctx.From ? normalizeE164(ctx.From) : "") || "unknown"; } /** * Resolve the session key with a canonical direct-chat bucket (default: "main"). * All non-group direct chats collapse to this bucket; groups stay isolated. */ function resolveSessionKey(scope, ctx, mainKey) { const explicit = ctx.SessionKey?.trim(); if (explicit) return explicit.toLowerCase(); const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; const canonical = buildAgentMainSessionKey({ agentId: DEFAULT_AGENT_ID, mainKey: normalizeMainKey(mainKey) }); if (!(raw.includes(":group:") || raw.includes(":channel:"))) return canonical; return `agent:${DEFAULT_AGENT_ID}:${raw}`; } //#endregion //#region src/utils/account-id.ts function normalizeAccountId(value) { if (typeof value !== "string") return; return value.trim() || void 0; } //#endregion //#region src/utils/delivery-context.ts function normalizeDeliveryContext(context) { if (!context) return; const channel = typeof context.channel === "string" ? normalizeMessageChannel(context.channel) ?? context.channel.trim() : void 0; const to = typeof context.to === "string" ? context.to.trim() : void 0; const accountId = normalizeAccountId(context.accountId); const threadId = typeof context.threadId === "number" && Number.isFinite(context.threadId) ? Math.trunc(context.threadId) : typeof context.threadId === "string" ? context.threadId.trim() : void 0; const normalizedThreadId = typeof threadId === "string" ? threadId ? threadId : void 0 : threadId; if (!channel && !to && !accountId && normalizedThreadId == null) return; const normalized = { channel: channel || void 0, to: to || void 0, accountId }; if (normalizedThreadId != null) normalized.threadId = normalizedThreadId; return normalized; } function normalizeSessionDeliveryFields(source) { if (!source) return { deliveryContext: void 0, lastChannel: void 0, lastTo: void 0, lastAccountId: void 0, lastThreadId: void 0 }; const merged = mergeDeliveryContext(normalizeDeliveryContext({ channel: source.lastChannel ?? source.channel, to: source.lastTo, accountId: source.lastAccountId, threadId: source.lastThreadId }), normalizeDeliveryContext(source.deliveryContext)); if (!merged) return { deliveryContext: void 0, lastChannel: void 0, lastTo: void 0, lastAccountId: void 0, lastThreadId: void 0 }; return { deliveryContext: merged, lastChannel: merged.channel, lastTo: merged.to, lastAccountId: merged.accountId, lastThreadId: merged.threadId }; } function deliveryContextFromSession(entry) { if (!entry) return; return normalizeSessionDeliveryFields({ channel: entry.channel, lastChannel: entry.lastChannel, lastTo: entry.lastTo, lastAccountId: entry.lastAccountId, lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId, deliveryContext: entry.deliveryContext }).deliveryContext; } function mergeDeliveryContext(primary, fallback) { const normalizedPrimary = normalizeDeliveryContext(primary); const normalizedFallback = normalizeDeliveryContext(fallback); if (!normalizedPrimary && !normalizedFallback) return; return normalizeDeliveryContext({ channel: normalizedPrimary?.channel ?? normalizedFallback?.channel, to: normalizedPrimary?.to ?? normalizedFallback?.to, accountId: normalizedPrimary?.accountId ?? normalizedFallback?.accountId, threadId: normalizedPrimary?.threadId ?? normalizedFallback?.threadId }); } function deliveryContextKey(context) { const normalized = normalizeDeliveryContext(context); if (!normalized?.channel || !normalized?.to) return; const threadId = normalized.threadId != null && normalized.threadId !== "" ? String(normalized.threadId) : ""; return `${normalized.channel}|${normalized.to}|${normalized.accountId ?? ""}|${threadId}`; } //#endregion //#region src/config/cache-utils.ts function resolveCacheTtlMs(params) { const { envValue, defaultTtlMs } = params; if (envValue) { const parsed = Number.parseInt(envValue, 10); if (Number.isFinite(parsed) && parsed >= 0) return parsed; } return defaultTtlMs; } function isCacheEnabled(ttlMs) { return ttlMs > 0; } function getFileMtimeMs(filePath) { try { return fs.statSync(filePath).mtimeMs; } catch { return; } } //#endregion //#region src/config/sessions/store.ts const SESSION_STORE_CACHE = /* @__PURE__ */ new Map(); const DEFAULT_SESSION_STORE_TTL_MS = 45e3; function isSessionStoreRecord(value) { return !!value && typeof value === "object" && !Array.isArray(value); } function getSessionStoreTtl() { return resolveCacheTtlMs({ envValue: process.env.OPENCLAW_SESSION_CACHE_TTL_MS, defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS }); } function isSessionStoreCacheEnabled() { return isCacheEnabled(getSessionStoreTtl()); } function isSessionStoreCacheValid(entry) { const now = Date.now(); const ttl = getSessionStoreTtl(); return now - entry.loadedAt <= ttl; } function invalidateSessionStoreCache(storePath) { SESSION_STORE_CACHE.delete(storePath); } function normalizeSessionEntryDelivery(entry) { const normalized = normalizeSessionDeliveryFields({ channel: entry.channel, lastChannel: entry.lastChannel, lastTo: entry.lastTo, lastAccountId: entry.lastAccountId, lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId, deliveryContext: entry.deliveryContext }); const nextDelivery = normalized.deliveryContext; const sameDelivery = (entry.deliveryContext?.channel ?? void 0) === nextDelivery?.channel && (entry.deliveryContext?.to ?? void 0) === nextDelivery?.to && (entry.deliveryContext?.accountId ?? void 0) === nextDelivery?.accountId && (entry.deliveryContext?.threadId ?? void 0) === nextDelivery?.threadId; const sameLast = entry.lastChannel === normalized.lastChannel && entry.lastTo === normalized.lastTo && entry.lastAccountId === normalized.lastAccountId && entry.lastThreadId === normalized.lastThreadId; if (sameDelivery && sameLast) return entry; return { ...entry, deliveryContext: nextDelivery, lastChannel: normalized.lastChannel, lastTo: normalized.lastTo, lastAccountId: normalized.lastAccountId, lastThreadId: normalized.lastThreadId }; } function normalizeSessionStore(store) { for (const [key, entry] of Object.entries(store)) { if (!entry) continue; const normalized = normalizeSessionEntryDelivery(entry); if (normalized !== entry) store[key] = normalized; } } function loadSessionStore(storePath, opts = {}) { if (!opts.skipCache && isSessionStoreCacheEnabled()) { const cached = SESSION_STORE_CACHE.get(storePath); if (cached && isSessionStoreCacheValid(cached)) { if (getFileMtimeMs(storePath) === cached.mtimeMs) return structuredClone(cached.store); invalidateSessionStoreCache(storePath); } } let store = {}; let mtimeMs = getFileMtimeMs(storePath); try { const raw = fs.readFileSync(storePath, "utf-8"); const parsed = JSON5.parse(raw); if (isSessionStoreRecord(parsed)) store = parsed; mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs; } catch {} for (const entry of Object.values(store)) { if (!entry || typeof entry !== "object") continue; const rec = entry; if (typeof rec.channel !== "string" && typeof rec.provider === "string") { rec.channel = rec.provider; delete rec.provider; } if (typeof rec.lastChannel !== "string" && typeof rec.lastProvider === "string") { rec.lastChannel = rec.lastProvider; delete rec.lastProvider; } if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") { rec.groupChannel = rec.room; delete rec.room; } else if ("room" in rec) delete rec.room; } if (!opts.skipCache && isSessionStoreCacheEnabled()) SESSION_STORE_CACHE.set(storePath, { store: structuredClone(store), loadedAt: Date.now(), storePath, mtimeMs }); return structuredClone(store); } function readSessionUpdatedAt(params) { try { return loadSessionStore(params.storePath)[params.sessionKey]?.updatedAt; } catch { return; } } async function saveSessionStoreUnlocked(storePath, store) { invalidateSessionStoreCache(storePath); normalizeSessionStore(store); await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const json = JSON.stringify(store, null, 2); if (process.platform === "win32") { try { await fs.promises.writeFile(storePath, json, "utf-8"); } catch (err) { if ((err && typeof err === "object" && "code" in err ? String(err.code) : null) === "ENOENT") return; throw err; } return; } const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; try { await fs.promises.writeFile(tmp, json, { mode: 384, encoding: "utf-8" }); await fs.promises.rename(tmp, storePath); await fs.promises.chmod(storePath, 384); } catch (err) { if ((err && typeof err === "object" && "code" in err ? String(err.code) : null) === "ENOENT") { try { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); await fs.promises.writeFile(storePath, json, { mode: 384, encoding: "utf-8" }); await fs.promises.chmod(storePath, 384); } catch (err2) { if ((err2 && typeof err2 === "object" && "code" in err2 ? String(err2.code) : null) === "ENOENT") return; throw err2; } return; } throw err; } finally { await fs.promises.rm(tmp, { force: true }); } } async function saveSessionStore(storePath, store) { await withSessionStoreLock(storePath, async () => { await saveSessionStoreUnlocked(storePath, store); }); } async function updateSessionStore(storePath, mutator) { return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath, { skipCache: true }); const result = await mutator(store); await saveSessionStoreUnlocked(storePath, store); return result; }); } async function withSessionStoreLock(storePath, fn, opts = {}) { const timeoutMs = opts.timeoutMs ?? 1e4; const pollIntervalMs = opts.pollIntervalMs ?? 25; const staleMs = opts.staleMs ?? 3e4; const lockPath = `${storePath}.lock`; const startedAt = Date.now(); await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); while (true) try { const handle = await fs.promises.open(lockPath, "wx"); try { await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: Date.now() }), "utf-8"); } catch {} await handle.close(); break; } catch (err) { const code = err && typeof err === "object" && "code" in err ? String(err.code) : null; if (code === "ENOENT") { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }).catch(() => void 0); await new Promise((r) => setTimeout(r, pollIntervalMs)); continue; } if (code !== "EEXIST") throw err; const now = Date.now(); if (now - startedAt > timeoutMs) throw new Error(`timeout acquiring session store lock: ${lockPath}`, { cause: err }); try { if (now - (await fs.promises.stat(lockPath)).mtimeMs > staleMs) { await fs.promises.unlink(lockPath); continue; } } catch {} await new Promise((r) => setTimeout(r, pollIntervalMs)); } try { return await fn(); } finally { await fs.promises.unlink(lockPath).catch(() => void 0); } } async function updateSessionStoreEntry(params) { const { storePath, sessionKey, update } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); const existing = store[sessionKey]; if (!existing) return null; const patch = await update(existing); if (!patch) return existing; const next = mergeSessionEntry(existing, patch); store[sessionKey] = next; await saveSessionStoreUnlocked(storePath, store); return next; }); } async function recordSessionMetaFromInbound(params) { const { storePath, sessionKey, ctx } = params; const createIfMissing = params.createIfMissing ?? true; return await updateSessionStore(storePath, (store) => { const existing = store[sessionKey]; const patch = deriveSessionMetaPatch({ ctx, sessionKey, existing, groupResolution: params.groupResolution }); if (!patch) return existing ?? null; if (!existing && !createIfMissing) return null; const next = mergeSessionEntry(existing, patch); store[sessionKey] = next; return next; }); } async function updateLastRoute(params) { const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); const existing = store[sessionKey]; const now = Date.now(); const merged = mergeDeliveryContext(mergeDeliveryContext(normalizeDeliveryContext(params.deliveryContext), normalizeDeliveryContext({ channel, to, accountId, threadId })), deliveryContextFromSession(existing)); const normalized = normalizeSessionDeliveryFields({ deliveryContext: { channel: merged?.channel, to: merged?.to, accountId: merged?.accountId, threadId: merged?.threadId } }); const metaPatch = ctx ? deriveSessionMetaPatch({ ctx, sessionKey, existing, groupResolution: params.groupResolution }) : null; const basePatch = { updatedAt: Math.max(existing?.updatedAt ?? 0, now), deliveryContext: normalized.deliveryContext, lastChannel: normalized.lastChannel, lastTo: normalized.lastTo, lastAccountId: normalized.lastAccountId, lastThreadId: normalized.lastThreadId }; const next = mergeSessionEntry(existing, metaPatch ? { ...basePatch, ...metaPatch } : basePatch); store[sessionKey] = next; await saveSessionStoreUnlocked(storePath, store); return next; }); } //#endregion //#region src/config/sessions/transcript.ts function stripQuery(value) { const noHash = value.split("#")[0] ?? value; return noHash.split("?")[0] ?? noHash; } function extractFileNameFromMediaUrl(value) { const trimmed = value.trim(); if (!trimmed) return null; const cleaned = stripQuery(trimmed); try { const parsed = new URL(cleaned); const base = path.basename(parsed.pathname); if (!base) return null; try { return decodeURIComponent(base); } catch { return base; } } catch { const base = path.basename(cleaned); if (!base || base === "/" || base === ".") return null; return base; } } function resolveMirroredTranscriptText(params) { const mediaUrls = params.mediaUrls?.filter((url) => url && url.trim()) ?? []; if (mediaUrls.length > 0) { const names = mediaUrls.map((url) => extractFileNameFromMediaUrl(url)).filter((name) => Boolean(name && name.trim())); if (names.length > 0) return names.join(", "); return "media"; } const trimmed = (params.text ?? "").trim(); return trimmed ? trimmed : null; } async function ensureSessionHeader(params) { if (fs.existsSync(params.sessionFile)) return; await fs.promises.mkdir(path.dirname(params.sessionFile), { recursive: true }); const header = { type: "session", version: CURRENT_SESSION_VERSION, id: params.sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), cwd: process.cwd() }; await fs.promises.writeFile(params.sessionFile, `${JSON.stringify(header)}\n`, "utf-8"); } async function appendAssistantMessageToSessionTranscript(params) { const sessionKey = params.sessionKey.trim(); if (!sessionKey) return { ok: false, reason: "missing sessionKey" }; const mirrorText = resolveMirroredTranscriptText({ text: params.text, mediaUrls: params.mediaUrls }); if (!mirrorText) return { ok: false, reason: "empty text" }; const storePath = params.storePath ?? resolveDefaultSessionStorePath(params.agentId); const entry = loadSessionStore(storePath, { skipCache: true })[sessionKey]; if (!entry?.sessionId) return { ok: false, reason: `unknown sessionKey: ${sessionKey}` }; const sessionFile = entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId); await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId }); SessionManager.open(sessionFile).appendMessage({ role: "assistant", content: [{ type: "text", text: mirrorText }], api: "openai-responses", provider: "openclaw", model: "delivery-mirror", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() }); if (!entry.sessionFile || entry.sessionFile !== sessionFile) await updateSessionStore(storePath, (current) => { current[sessionKey] = { ...entry, sessionFile }; }); emitSessionTranscriptUpdate(sessionFile); return { ok: true, sessionFile }; } //#endregion //#region src/agents/sandbox/runtime-status.ts function shouldSandboxSession(cfg, sessionKey, mainSessionKey) { if (cfg.mode === "off") return false; if (cfg.mode === "all") return true; return sessionKey.trim() !== mainSessionKey.trim(); } function resolveMainSessionKeyForSandbox(params) { if (params.cfg?.session?.scope === "global") return "global"; return resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId }); } function resolveComparableSessionKeyForSandbox(params) { return canonicalizeMainSessionAlias({ cfg: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey }); } function resolveSandboxRuntimeStatus(params) { const sessionKey = params.sessionKey?.trim() ?? ""; const agentId = resolveSessionAgentId({ sessionKey, config: params.cfg }); const cfg = params.cfg; const sandboxCfg = resolveSandboxConfigForAgent(cfg, agentId); const mainSessionKey = resolveMainSessionKeyForSandbox({ cfg, agentId }); const sandboxed = sessionKey ? shouldSandboxSession(sandboxCfg, resolveComparableSessionKeyForSandbox({ cfg, agentId, sessionKey }), mainSessionKey) : false; return { agentId, sessionKey, mainSessionKey, mode: sandboxCfg.mode, sandboxed, toolPolicy: resolveSandboxToolPolicyForAgent(cfg, agentId) }; } function formatSandboxToolPolicyBlockedMessage(params) { const tool = params.toolName.trim().toLowerCase(); if (!tool) return; const runtime = resolveSandboxRuntimeStatus({ cfg: params.cfg, sessionKey: params.sessionKey }); if (!runtime.sandboxed) return; const deny = new Set(expandToolGroups(runtime.toolPolicy.deny)); const allow = expandToolGroups(runtime.toolPolicy.allow); const allowSet = allow.length > 0 ? new Set(allow) : null; const blockedByDeny = deny.has(tool); const blockedByAllow = allowSet ? !allowSet.has(tool) : false; if (!blockedByDeny && !blockedByAllow) return; const reasons = []; const fixes = []; if (blockedByDeny) { reasons.push("deny list"); fixes.push(`Remove "${tool}" from ${runtime.toolPolicy.sources.deny.key}.`); } if (blockedByAllow) { reasons.push("allow list"); fixes.push(`Add "${tool}" to ${runtime.toolPolicy.sources.allow.key} (or set it to [] to allow all).`); } const lines = []; lines.push(`Tool "${tool}" blocked by sandbox tool policy (mode=${runtime.mode}).`); lines.push(`Session: ${runtime.sessionKey || "(unknown)"}`); lines.push(`Reason: ${reasons.join(" + ")}`); lines.push("Fix:"); lines.push(`- agents.defaults.sandbox.mode=off (disable sandbox)`); for (const fix of fixes) lines.push(`- ${fix}`); if (runtime.mode === "non-main") lines.push(`- Use main session key (direct): ${runtime.mainSessionKey}`); lines.push(`- See: ${formatCliCommand(`openclaw sandbox explain --session ${runtime.sessionKey}`)}`); return lines.join("\n"); } //#endregion //#region src/agents/sandbox/workspace.ts async function ensureSandboxWorkspace(workspaceDir, seedFrom, skipBootstrap) { await fs$1.mkdir(workspaceDir, { recursive: true }); if (seedFrom) { const seed = resolveUserPath(seedFrom); const files = [ DEFAULT_AGENTS_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_IDENTITY_FILENAME, DEFAULT_USER_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_HEARTBEAT_FILENAME ]; for (const name of files) { const src = path.join(seed, name); const dest = path.join(workspaceDir, name); try { await fs$1.access(dest); } catch { try { const content = await fs$1.readFile(src, "utf-8"); await fs$1.writeFile(dest, content, { encoding: "utf-8", flag: "wx" }); } catch {} } } } await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); } //#endregion //#region src/agents/sandbox/context.ts async function resolveSandboxContext(params) { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) return null; const runtime = resolveSandboxRuntimeStatus({ cfg: params.config, sessionKey: rawSessionKey }); if (!runtime.sandboxed) return null; const cfg = resolveSandboxConfigForAgent(params.config, runtime.agentId); await maybePruneSandboxes(cfg); const agentWorkspaceDir = resolveUserPath(params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR); const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); const sandboxWorkspaceDir = cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); const workspaceDir = cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; if (workspaceDir === sandboxWorkspaceDir) { await ensureSandboxWorkspace(sandboxWorkspaceDir, agentWorkspaceDir, params.config?.agents?.defaults?.skipBootstrap); if (cfg.workspaceAccess !== "rw") try { await syncSkillsToWorkspace({ sourceWorkspaceDir: agentWorkspaceDir, targetWorkspaceDir: sandboxWorkspaceDir, config: params.config }); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`); } } else await fs$1.mkdir(workspaceDir, { recursive: true }); const containerName = await ensureSandboxContainer({ sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, cfg }); const browser = await ensureSandboxBrowser({ scopeKey, workspaceDir, agentWorkspaceDir, cfg, evaluateEnabled: params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED }); return { enabled: true, sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, workspaceAccess: cfg.workspaceAccess, containerName, containerWorkdir: cfg.docker.workdir, docker: cfg.docker, tools: cfg.tools, browserAllowHostControl: cfg.browser.allowHostControl, browser: browser ?? void 0 }; } async function ensureSandboxWorkspaceForSession(params) { const rawSessionKey = params.sessionKey?.trim(); if (!rawSessionKey) return null; const runtime = resolveSandboxRuntimeStatus({ cfg: params.config, sessionKey: rawSessionKey }); if (!runtime.sandboxed) return null; const cfg = resolveSandboxConfigForAgent(params.config, runtime.agentId); const agentWorkspaceDir = resolveUserPath(params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR); const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); const sandboxWorkspaceDir = cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); const workspaceDir = cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; if (workspaceDir === sandboxWorkspaceDir) { await ensureSandboxWorkspace(sandboxWorkspaceDir, agentWorkspaceDir, params.config?.agents?.defaults?.skipBootstrap); if (cfg.workspaceAccess !== "rw") try { await syncSkillsToWorkspace({ sourceWorkspaceDir: agentWorkspaceDir, targetWorkspaceDir: sandboxWorkspaceDir, config: params.config }); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`); } } else await fs$1.mkdir(workspaceDir, { recursive: true }); return { workspaceDir, containerWorkdir: cfg.docker.workdir }; } //#endregion //#region src/agents/pi-embedded-helpers/errors.ts function isContextOverflowError(errorMessage) { if (!errorMessage) return false; const lower = errorMessage.toLowerCase(); const hasRequestSizeExceeds = lower.includes("request size exceeds"); const hasContextWindow = lower.includes("context window") || lower.includes("context length") || lower.includes("maximum context length"); return lower.includes("request_too_large") || lower.includes("request exceeds the maximum size") || lower.includes("context length exceeded") || lower.includes("maximum context length") || lower.includes("prompt is too long") || lower.includes("exceeds model context window") || hasRequestSizeExceeds && hasContextWindow || lower.includes("context overflow") || lower.includes("413") && lower.includes("too large"); } const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i; const CONTEXT_OVERFLOW_HINT_RE = /context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|(?:prompt|request|input).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i; function isLikelyContextOverflowError(errorMessage) { if (!errorMessage) return false; if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) return false; if (isContextOverflowError(errorMessage)) return true; return CONTEXT_OVERFLOW_HINT_RE.test(errorMessage); } function isCompactionFailureError(errorMessage) { if (!errorMessage) return false; if (!isContextOverflowError(errorMessage)) return false; const lower = errorMessage.toLowerCase(); return lower.includes("summarization failed") || lower.includes("auto-compaction") || lower.includes("compaction failed") || lower.includes("compaction"); } const ERROR_PAYLOAD_PREFIX_RE = /^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; const ERROR_PREFIX_RE = /^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_ERROR_HINTS = [ "error", "bad request", "not found", "unauthorized", "forbidden", "internal server", "service unavailable", "gateway", "rate limit", "overloaded", "timeout", "timed out", "invalid", "too many requests", "permission" ]; function stripFinalTagsFromText(text) { if (!text) return text; return text.replace(FINAL_TAG_RE, ""); } function collapseConsecutiveDuplicateBlocks(text) { const trimmed = text.trim(); if (!trimmed) return text; const blocks = trimmed.split(/\n{2,}/); if (blocks.length < 2) return text; const normalizeBlock = (value) => value.trim().replace(/\s+/g, " "); const result = []; let lastNormalized = null; for (const block of blocks) { const normalized = normalizeBlock(block); if (lastNormalized && normalized === lastNormalized) continue; result.push(block.trim()); lastNormalized = normalized; } if (result.length === blocks.length) return text; return result.join("\n\n"); } function isLikelyHttpErrorText(raw) { const match = raw.match(HTTP_STATUS_PREFIX_RE); if (!match) return false; const code = Number(match[1]); if (!Number.isFinite(code) || code < 400) return false; const message = match[2].toLowerCase(); return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); } function isErrorPayloadObject(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; const record = payload; if (record.type === "error") return true; if (typeof record.request_id === "string" || typeof record.requestId === "string") return true; if ("error" in record) { const err = record.error; if (err && typeof err === "object" && !Array.isArray(err)) { const errRecord = err; if (typeof errRecord.message === "string" || typeof errRecord.type === "string" || typeof errRecord.code === "string") return true; } } return false; } function parseApiErrorPayload(raw) { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; const candidates = [trimmed]; if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); for (const candidate of candidates) { if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue; try { const parsed = JSON.parse(candidate); if (isErrorPayloadObject(parsed)) return parsed; } catch {} } return null; } function stableStringify(value) { if (!value || typeof value !== "object") return JSON.stringify(value) ?? "null"; if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; const record = value; return `{${Object.keys(record).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; } function getApiErrorPayloadFingerprint(raw) { if (!raw) return null; const payload = parseApiErrorPayload(raw); if (!payload) return null; return stableStringify(payload); } function isRawApiErrorPayload(raw) { return getApiErrorPayloadFingerprint(raw) !== null; } function parseApiErrorInfo(raw) { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; let httpCode; let candidate = trimmed; const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); if (httpPrefixMatch) { httpCode = httpPrefixMatch[1]; candidate = httpPrefixMatch[2].trim(); } const payload = parseApiErrorPayload(candidate); if (!payload) return null; const requestId = typeof payload.request_id === "string" ? payload.request_id : typeof payload.requestId === "string" ? payload.requestId : void 0; const topType = typeof payload.type === "string" ? payload.type : void 0; const topMessage = typeof payload.message === "string" ? payload.message : void 0; let errType; let errMessage; if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { const err = payload.error; if (typeof err.type === "string") errType = err.type; if (typeof err.code === "string" && !errType) errType = err.code; if (typeof err.message === "string") errMessage = err.message; } return { httpCode, type: errType ?? topType, message: errMessage ?? topMessage, requestId }; } function formatRawAssistantErrorForUi(raw) { const trimmed = (raw ?? "").trim(); if (!trimmed) return "LLM request failed with an unknown error."; const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE); if (httpMatch) { const rest = httpMatch[2].trim(); if (!rest.startsWith("{")) return `HTTP ${httpMatch[1]}: ${rest}`; } const info = parseApiErrorInfo(trimmed); if (info?.message) { const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; const type = info.type ? ` ${info.type}` : ""; const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; return `${prefix}${type}: ${info.message}${requestId}`; } return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; } function formatAssistantErrorText(msg, opts) { const raw = (msg.errorMessage ?? "").trim(); if (msg.stopReason !== "error" && !raw) return; if (!raw) return "LLM request failed with an unknown error."; const unknownTool = raw.match(/unknown tool[:\s]+["']?([a-z0-9_-]+)["']?/i) ?? raw.match(/tool\s+["']?([a-z0-9_-]+)["']?\s+(?:not found|is not available)/i); if (unknownTool?.[1]) { const rewritten = formatSandboxToolPolicyBlockedMessage({ cfg: opts?.cfg, sessionKey: opts?.sessionKey, toolName: unknownTool[1] }); if (rewritten) return rewritten; } if (isContextOverflowError(raw)) return "Context overflow: prompt too large for the model. Try again with less input or a larger-context model."; if (/incorrect role information|roles must alternate|400.*role|"message".*role.*information/i.test(raw)) return "Message ordering conflict - please try again. If this persists, use /new to start a fresh session."; if (isMissingToolCallInputError(raw)) return "Session history looks corrupted (tool call input missing). Use /new to start a fresh session. If this keeps happening, reset the session or delete the corrupted session transcript."; const invalidRequest = raw.match(/"type":"invalid_request_error".*?"message":"([^"]+)"/); if (invalidRequest?.[1]) return `LLM request rejected: ${invalidRequest[1]}`; if (isOverloadedErrorMessage(raw)) return "The AI service is temporarily overloaded. Please try again in a moment."; if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) return formatRawAssistantErrorForUi(raw); if (raw.length > 600) console.warn("[formatAssistantErrorText] Long error truncated:", raw.slice(0, 200)); return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; } function sanitizeUserFacingText(text) { if (!text) return text; const stripped = stripFinalTagsFromText(text); const trimmed = stripped.trim(); if (!trimmed) return stripped; if (/incorrect role information|roles must alternate/i.test(trimmed)) return "Message ordering conflict - please try again. If this persists, use /new to start a fresh session."; if (isContextOverflowError(trimmed)) return "Context overflow: prompt too large for the model. Try again with less input or a larger-context model."; if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) return formatRawAssistantErrorForUi(trimmed); if (ERROR_PREFIX_RE.test(trimmed)) { if (isOverloadedErrorMessage(trimmed) || isRateLimitErrorMessage(trimmed)) return "The AI service is temporarily overloaded. Please try again in a moment."; if (isTimeoutErrorMessage(trimmed)) return "LLM request timed out."; return formatRawAssistantErrorForUi(trimmed); } return collapseConsecutiveDuplicateBlocks(stripped); } function isRateLimitAssistantError(msg) { if (!msg || msg.stopReason !== "error") return false; return isRateLimitErrorMessage(msg.errorMessage ?? ""); } const ERROR_PATTERNS = { rateLimit: [ /rate[_ ]limit|too many requests|429/, "exceeded your current quota", "resource has been exhausted", "quota exceeded", "resource_exhausted", "usage limit" ], overloaded: [/overloaded_error|"type"\s*:\s*"overloaded_error"/i, "overloaded"], timeout: [ "timeout", "timed out", "deadline exceeded", "context deadline exceeded" ], billing: [ /\b402\b/, "payment required", "insufficient credits", "credit balance", "plans & billing" ], auth: [ /invalid[_ ]?api[_ ]?key/, "incorrect api key", "invalid token", "authentication", "re-authenticate", "oauth token refresh failed", "unauthorized", "forbidden", "access denied", "expired", "token has expired", /\b401\b/, /\b403\b/, "no credentials found", "no api key found" ], format: [ "string should match pattern", "tool_use.id", "tool_use_id", "messages.1.content.1.tool_use.id", "invalid request format" ] }; const TOOL_CALL_INPUT_MISSING_RE = /tool_(?:use|call)\.(?:input|arguments).*?(?:field required|required)/i; const TOOL_CALL_INPUT_PATH_RE = /messages\.\d+\.content\.\d+\.tool_(?:use|call)\.(?:input|arguments)/i; const IMAGE_DIMENSION_ERROR_RE = /image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i; const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i; const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i; function matchesErrorPatterns(raw, patterns) { if (!raw) return false; const value = raw.toLowerCase(); return patterns.some((pattern) => pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern)); } function isRateLimitErrorMessage(raw) { return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit); } function isTimeoutErrorMessage(raw) { return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout); } function isBillingErrorMessage(raw) { const value = raw.toLowerCase(); if (!value) return false; if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) return true; return value.includes("billing") && (value.includes("upgrade") || value.includes("credits") || value.includes("payment") || value.includes("plan")); } function isMissingToolCallInputError(raw) { if (!raw) return false; return TOOL_CALL_INPUT_MISSING_RE.test(raw) || TOOL_CALL_INPUT_PATH_RE.test(raw); } function isAuthErrorMessage(raw) { return matchesErrorPatterns(raw, ERROR_PATTERNS.auth); } function isOverloadedErrorMessage(raw) { return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded); } function parseImageDimensionError(raw) { if (!raw) return null; if (!raw.toLowerCase().includes("image dimensions exceed max allowed size")) return null; const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE); const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE); return { maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : void 0, messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : void 0, contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : void 0, raw }; } function isImageDimensionErrorMessage(raw) { return Boolean(parseImageDimensionError(raw)); } function parseImageSizeError(raw) { if (!raw) return null; const lower = raw.toLowerCase(); if (!lower.includes("image exceeds") || !lower.includes("mb")) return null; const match = raw.match(IMAGE_SIZE_ERROR_RE); return { maxMb: match?.[1] ? Number.parseFloat(match[1]) : void 0, raw }; } function isImageSizeError(errorMessage) { if (!errorMessage) return false; return Boolean(parseImageSizeError(errorMessage)); } function isCloudCodeAssistFormatError(raw) { return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format); } function isAuthAssistantError(msg) { if (!msg || msg.stopReason !== "error") return false; return isAuthErrorMessage(msg.errorMessage ?? ""); } function classifyFailoverReason(raw) { if (isImageDimensionErrorMessage(raw)) return null; if (isImageSizeError(raw)) return null; if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isOverloadedErrorMessage(raw)) return "rate_limit"; if (isCloudCodeAssistFormatError(raw)) return "format"; if (isBillingErrorMessage(raw)) return "billing"; if (isTimeoutErrorMessage(raw)) return "timeout"; if (isAuthErrorMessage(raw)) return "auth"; return null; } function isFailoverErrorMessage(raw) { return classifyFailoverReason(raw) !== null; } function isFailoverAssistantError(msg) { if (!msg || msg.stopReason !== "error") return false; return isFailoverErrorMessage(msg.errorMessage ?? ""); } //#endregion //#region src/agents/pi-embedded-helpers/google.ts function isGoogleModelApi(api) { return api === "google-gemini-cli" || api === "google-generative-ai" || api === "google-antigravity"; } function isAntigravityClaude(params) { const provider = params.provider?.toLowerCase(); const api = params.api?.toLowerCase(); if (provider !== "google-antigravity" && api !== "google-antigravity") return false; return params.modelId?.toLowerCase().includes("claude") ?? false; } //#endregion //#region src/agents/pi-embedded-helpers/openai.ts function parseOpenAIReasoningSignature(value) { if (!value) return null; let candidate = null; if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; try { candidate = JSON.parse(trimmed); } catch { return null; } } else if (typeof value === "object") candidate = value; if (!candidate) return null; const id = typeof candidate.id === "string" ? candidate.id : ""; const type = typeof candidate.type === "string" ? candidate.type : ""; if (!id.startsWith("rs_")) return null; if (type === "reasoning" || type.startsWith("reasoning.")) return { id, type }; return null; } function hasFollowingNonThinkingBlock(content, index) { for (let i = index + 1; i < content.length; i++) { const block = content[i]; if (!block || typeof block !== "object") return true; if (block.type !== "thinking") return true; } return false; } /** * OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id * without the required following item. * * OpenClaw persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata * is incomplete, drop the block to keep history usable. */ function downgradeOpenAIReasoningBlocks(messages) { const out = []; for (const msg of messages) { if (!msg || typeof msg !== "object") { out.push(msg); continue; } if (msg.role !== "assistant") { out.push(msg); continue; } const assistantMsg = msg; if (!Array.isArray(assistantMsg.content)) { out.push(msg); continue; } let changed = false; const nextContent = []; for (let i = 0; i < assistantMsg.content.length; i++) { const block = assistantMsg.content[i]; if (!block || typeof block !== "object") { nextContent.push(block); continue; } const record = block; if (record.type !== "thinking") { nextContent.push(block); continue; } if (!parseOpenAIReasoningSignature(record.thinkingSignature)) { nextContent.push(block); continue; } if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) { nextContent.push(block); continue; } changed = true; } if (!changed) { out.push(msg); continue; } if (nextContent.length === 0) continue; out.push({ ...assistantMsg, content: nextContent }); } return out; } //#endregion //#region src/agents/tool-call-id.ts const STRICT9_LEN = 9; /** * Sanitize a tool call ID to be compatible with various providers. * * - "strict" mode: only [a-zA-Z0-9] * - "strict9" mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement) */ function sanitizeToolCallId(id, mode = "strict") { if (!id || typeof id !== "string") { if (mode === "strict9") return "defaultid"; return "defaulttoolid"; } if (mode === "strict9") { const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, ""); if (alphanumericOnly.length >= STRICT9_LEN) return alphanumericOnly.slice(0, STRICT9_LEN); if (alphanumericOnly.length > 0) return shortHash(alphanumericOnly, STRICT9_LEN); return shortHash("sanitized", STRICT9_LEN); } const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, ""); return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid"; } function shortHash(text, length = 8) { return createHash("sha1").update(text).digest("hex").slice(0, length); } function makeUniqueToolId(params) { if (params.mode === "strict9") { const base = sanitizeToolCallId(params.id, params.mode); const candidate = base.length >= STRICT9_LEN ? base.slice(0, STRICT9_LEN) : ""; if (candidate && !params.used.has(candidate)) return candidate; for (let i = 0; i < 1e3; i += 1) { const hashed = shortHash(`${params.id}:${i}`, STRICT9_LEN); if (!params.used.has(hashed)) return hashed; } return shortHash(`${params.id}:${Date.now()}`, STRICT9_LEN); } const MAX_LEN = 40; const base = sanitizeToolCallId(params.id, params.mode).slice(0, MAX_LEN); if (!params.used.has(base)) return base; const hash = shortHash(params.id); const separator = params.mode === "strict" ? "" : "_"; const maxBaseLen = MAX_LEN - separator.length - hash.length; const candidate = `${base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base}${separator}${hash}`; if (!params.used.has(candidate)) return candidate; for (let i = 2; i < 1e3; i += 1) { const suffix = params.mode === "strict" ? `x${i}` : `_${i}`; const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`; if (!params.used.has(next)) return next; } const ts = params.mode === "strict" ? `t${Date.now()}` : `_${Date.now()}`; return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`; } function rewriteAssistantToolCallIds(params) { const content = params.message.content; if (!Array.isArray(content)) return params.message; let changed = false; const next = content.map((block) => { if (!block || typeof block !== "object") return block; const rec = block; const type = rec.type; const id = rec.id; if (type !== "functionCall" && type !== "toolUse" && type !== "toolCall" || typeof id !== "string" || !id) return block; const nextId = params.resolve(id); if (nextId === id) return block; changed = true; return { ...block, id: nextId }; }); if (!changed) return params.message; return { ...params.message, content: next }; } function rewriteToolResultIds(params) { const toolCallId = typeof params.message.toolCallId === "string" && params.message.toolCallId ? params.message.toolCallId : void 0; const toolUseId = params.message.toolUseId; const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : void 0; const nextToolCallId = toolCallId ? params.resolve(toolCallId) : void 0; const nextToolUseId = toolUseIdStr ? params.resolve(toolUseIdStr) : void 0; if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) return params.message; return { ...params.message, ...nextToolCallId && { toolCallId: nextToolCallId }, ...nextToolUseId && { toolUseId: nextToolUseId } }; } /** * Sanitize tool call IDs for provider compatibility. * * @param messages - The messages to sanitize * @param mode - "strict" (alphanumeric only) or "strict9" (alphanumeric length 9) */ function sanitizeToolCallIdsForCloudCodeAssist(messages, mode = "strict") { const map = /* @__PURE__ */ new Map(); const used = /* @__PURE__ */ new Set(); const resolve = (id) => { const existing = map.get(id); if (existing) return existing; const next = makeUniqueToolId({ id, used, mode }); map.set(id, next); used.add(next); return next; }; let changed = false; const out = messages.map((msg) => { if (!msg || typeof msg !== "object") return msg; const role = msg.role; if (role === "assistant") { const next = rewriteAssistantToolCallIds({ message: msg, resolve }); if (next !== msg) changed = true; return next; } if (role === "toolResult") { const next = rewriteToolResultIds({ message: msg, resolve }); if (next !== msg) changed = true; return next; } return msg; }); return changed ? out : messages; } //#endregion //#region src/agents/tool-images.ts const MAX_IMAGE_DIMENSION_PX = 2e3; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const log = createSubsystemLogger("agents/tool-images"); function isImageBlock(block) { if (!block || typeof block !== "object") return false; const rec = block; return rec.type === "image" && typeof rec.data === "string" && typeof rec.mimeType === "string"; } function isTextBlock(block) { if (!block || typeof block !== "object") return false; const rec = block; return rec.type === "text" && typeof rec.text === "string"; } function inferMimeTypeFromBase64(base64) { const trimmed = base64.trim(); if (!trimmed) return; if (trimmed.startsWith("/9j/")) return "image/jpeg"; if (trimmed.startsWith("iVBOR")) return "image/png"; if (trimmed.startsWith("R0lGOD")) return "image/gif"; } async function resizeImageBase64IfNeeded(params) { const buf = Buffer.from(params.base64, "base64"); const meta = await getImageMetadata(buf); const width = meta?.width; const height = meta?.height; const overBytes = buf.byteLength > params.maxBytes; const hasDimensions = typeof width === "number" && typeof height === "number"; if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) return { base64: params.base64, mimeType: params.mimeType, resized: false, width, height }; if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) log.warn("Image exceeds limits; resizing", { label: params.label, width, height, maxDimensionPx: params.maxDimensionPx, maxBytes: params.maxBytes }); const qualities = [ 85, 75, 65, 55, 45, 35 ]; const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx; const sideGrid = [ maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx, 1800, 1600, 1400, 1200, 1e3, 800 ].map((v) => Math.min(params.maxDimensionPx, 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: buf, maxSide: side, quality, withoutEnlargement: true }); if (!smallest || out.byteLength < smallest.size) smallest = { buffer: out, size: out.byteLength }; if (out.byteLength <= params.maxBytes) { log.info("Image resized", { label: params.label, width, height, maxDimensionPx: params.maxDimensionPx, maxBytes: params.maxBytes, originalBytes: buf.byteLength, resizedBytes: out.byteLength, quality, side }); return { base64: out.toString("base64"), mimeType: "image/jpeg", resized: true, width, height }; } } const best = smallest?.buffer ?? buf; const maxMb = (params.maxBytes / (1024 * 1024)).toFixed(0); const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2); throw new Error(`Image could not be reduced below ${maxMb}MB (got ${gotMb}MB)`); } async function sanitizeContentBlocksImages(blocks, label, opts = {}) { const maxDimensionPx = Math.max(opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX, 1); const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1); const out = []; for (const block of blocks) { if (!isImageBlock(block)) { out.push(block); continue; } const data = block.data.trim(); if (!data) { out.push({ type: "text", text: `[${label}] omitted empty image payload` }); continue; } try { const mimeType = inferMimeTypeFromBase64(data) ?? block.mimeType; const resized = await resizeImageBase64IfNeeded({ base64: data, mimeType, maxDimensionPx, maxBytes, label }); out.push({ ...block, data: resized.base64, mimeType: resized.resized ? resized.mimeType : mimeType }); } catch (err) { out.push({ type: "text", text: `[${label}] omitted image payload: ${String(err)}` }); } } return out; } async function sanitizeImageBlocks(images, label, opts = {}) { if (images.length === 0) return { images, dropped: 0 }; const next = (await sanitizeContentBlocksImages(images, label, opts)).filter(isImageBlock); return { images: next, dropped: Math.max(0, images.length - next.length) }; } async function sanitizeToolResultImages(result, label, opts = {}) { const content = Array.isArray(result.content) ? result.content : []; if (!content.some((b) => isImageBlock(b) || isTextBlock(b))) return result; const next = await sanitizeContentBlocksImages(content, label, opts); return { ...result, content: next }; } //#endregion //#region src/agents/pi-embedded-helpers/images.ts async function sanitizeSessionMessagesImages(messages, label, options) { const allowNonImageSanitization = (options?.sanitizeMode ?? "full") === "full"; const sanitizedIds = allowNonImageSanitization && options?.sanitizeToolCallIds ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode) : messages; const out = []; for (const msg of sanitizedIds) { if (!msg || typeof msg !== "object") { out.push(msg); continue; } const role = msg.role; if (role === "toolResult") { const toolMsg = msg; const nextContent = await sanitizeContentBlocksImages(Array.isArray(toolMsg.content) ? toolMsg.content : [], label); out.push({ ...toolMsg, content: nextContent }); continue; } if (role === "user") { const userMsg = msg; const content = userMsg.content; if (Array.isArray(content)) { const nextContent = await sanitizeContentBlocksImages(content, label); out.push({ ...userMsg, content: nextContent }); continue; } } if (role === "assistant") { const assistantMsg = msg; if (assistantMsg.stopReason === "error") { const content = assistantMsg.content; if (Array.isArray(content)) { const nextContent = await sanitizeContentBlocksImages(content, label); out.push({ ...assistantMsg, content: nextContent }); } else out.push(assistantMsg); continue; } const content = assistantMsg.content; if (Array.isArray(content)) { if (!allowNonImageSanitization) { const nextContent = await sanitizeContentBlocksImages(content, label); out.push({ ...assistantMsg, content: nextContent }); continue; } const finalContent = await sanitizeContentBlocksImages((options?.preserveSignatures ? content : stripThoughtSignatures(content, options?.sanitizeThoughtSignatures)).filter((block) => { if (!block || typeof block !== "object") return true; const rec = block; if (rec.type !== "text" || typeof rec.text !== "string") return true; return rec.text.trim().length > 0; }), label); if (finalContent.length === 0) continue; out.push({ ...assistantMsg, content: finalContent }); continue; } } out.push(msg); } return out; } //#endregion //#region src/agents/pi-embedded-helpers/messaging-dedupe.ts const MIN_DUPLICATE_TEXT_LENGTH = 10; /** * Normalize text for duplicate comparison. * - Trims whitespace * - Lowercases * - Strips emoji (Emoji_Presentation and Extended_Pictographic) * - Collapses multiple spaces to single space */ function normalizeTextForComparison(text) { return text.trim().toLowerCase().replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, "").replace(/\s+/g, " ").trim(); } function isMessagingToolDuplicateNormalized(normalized, normalizedSentTexts) { if (normalizedSentTexts.length === 0) return false; if (!normalized || normalized.length < MIN_DUPLICATE_TEXT_LENGTH) return false; return normalizedSentTexts.some((normalizedSent) => { if (!normalizedSent || normalizedSent.length < MIN_DUPLICATE_TEXT_LENGTH) return false; return normalized.includes(normalizedSent) || normalizedSent.includes(normalized); }); } function isMessagingToolDuplicate(text, sentTexts) { if (sentTexts.length === 0) return false; const normalized = normalizeTextForComparison(text); if (!normalized || normalized.length < MIN_DUPLICATE_TEXT_LENGTH) return false; return isMessagingToolDuplicateNormalized(normalized, sentTexts.map(normalizeTextForComparison)); } //#endregion //#region src/auto-reply/thinking.ts function normalizeProviderId(provider) { if (!provider) return ""; const normalized = provider.trim().toLowerCase(); if (normalized === "z.ai" || normalized === "z-ai") return "zai"; return normalized; } function isBinaryThinkingProvider(provider) { return normalizeProviderId(provider) === "zai"; } const XHIGH_MODEL_REFS = [ "openai/gpt-5.2", "openai-codex/gpt-5.2-codex", "openai-codex/gpt-5.1-codex" ]; const XHIGH_MODEL_SET = new Set(XHIGH_MODEL_REFS.map((entry) => entry.toLowerCase())); const XHIGH_MODEL_IDS = new Set(XHIGH_MODEL_REFS.map((entry) => entry.split("/")[1]?.toLowerCase()).filter((entry) => Boolean(entry))); function normalizeThinkLevel(raw) { if (!raw) return; const key = raw.toLowerCase(); if (["off"].includes(key)) return "off"; if ([ "on", "enable", "enabled" ].includes(key)) return "low"; if (["min", "minimal"].includes(key)) return "minimal"; if ([ "low", "thinkhard", "think-hard", "think_hard" ].includes(key)) return "low"; if ([ "mid", "med", "medium", "thinkharder", "think-harder", "harder" ].includes(key)) return "medium"; if ([ "high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max" ].includes(key)) return "high"; if ([ "xhigh", "x-high", "x_high" ].includes(key)) return "xhigh"; if (["think"].includes(key)) return "minimal"; } function supportsXHighThinking(provider, model) { const modelKey = model?.trim().toLowerCase(); if (!modelKey) return false; const providerKey = provider?.trim().toLowerCase(); if (providerKey) return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`); return XHIGH_MODEL_IDS.has(modelKey); } function listThinkingLevels(provider, model) { const levels = [ "off", "minimal", "low", "medium", "high" ]; if (supportsXHighThinking(provider, model)) levels.push("xhigh"); return levels; } function listThinkingLevelLabels(provider, model) { if (isBinaryThinkingProvider(provider)) return ["off", "on"]; return listThinkingLevels(provider, model); } function formatThinkingLevels(provider, model, separator = ", ") { return listThinkingLevelLabels(provider, model).join(separator); } function formatXHighModelHint() { const refs = [...XHIGH_MODEL_REFS]; if (refs.length === 0) return "unknown model"; if (refs.length === 1) return refs[0]; if (refs.length === 2) return `${refs[0]} or ${refs[1]}`; return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`; } function normalizeVerboseLevel(raw) { if (!raw) return; const key = raw.toLowerCase(); if ([ "off", "false", "no", "0" ].includes(key)) return "off"; if ([ "full", "all", "everything" ].includes(key)) return "full"; if ([ "on", "minimal", "true", "yes", "1" ].includes(key)) return "on"; } function normalizeUsageDisplay(raw) { if (!raw) return; const key = raw.toLowerCase(); if ([ "off", "false", "no", "0", "disable", "disabled" ].includes(key)) return "off"; if ([ "on", "true", "yes", "1", "enable", "enabled" ].includes(key)) return "tokens"; if ([ "tokens", "token", "tok", "minimal", "min" ].includes(key)) return "tokens"; if (["full", "session"].includes(key)) return "full"; } function resolveResponseUsageMode(raw) { return normalizeUsageDisplay(raw) ?? "off"; } function normalizeElevatedLevel(raw) { if (!raw) return; const key = raw.toLowerCase(); if ([ "off", "false", "no", "0" ].includes(key)) return "off"; if ([ "full", "auto", "auto-approve", "autoapprove" ].includes(key)) return "full"; if ([ "ask", "prompt", "approval", "approve" ].includes(key)) return "ask"; if ([ "on", "true", "yes", "1" ].includes(key)) return "on"; } function normalizeReasoningLevel(raw) { if (!raw) return; const key = raw.toLowerCase(); if ([ "off", "false", "no", "0", "hide", "hidden", "disable", "disabled" ].includes(key)) return "off"; if ([ "on", "true", "yes", "1", "show", "visible", "enable", "enabled" ].includes(key)) return "on"; if ([ "stream", "streaming", "draft", "live" ].includes(key)) return "stream"; } //#endregion //#region src/agents/pi-embedded-helpers/thinking.ts function extractSupportedValues(raw) { const match = raw.match(/supported values are:\s*([^\n.]+)/i) ?? raw.match(/supported values:\s*([^\n.]+)/i); if (!match?.[1]) return []; const fragment = match[1]; const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map((entry) => entry[1]?.trim()); if (quoted.length > 0) return quoted.filter((entry) => Boolean(entry)); return fragment.split(/,|\band\b/gi).map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim()).filter(Boolean); } function pickFallbackThinkingLevel(params) { const raw = params.message?.trim(); if (!raw) return; const supported = extractSupportedValues(raw); if (supported.length === 0) return; for (const entry of supported) { const normalized = normalizeThinkLevel(entry); if (!normalized) continue; if (params.attempted.has(normalized)) continue; return normalized; } } //#endregion //#region src/agents/pi-embedded-helpers/turns.ts /** * Validates and fixes conversation turn sequences for Gemini API. * Gemini requires strict alternating user→assistant→tool→user pattern. * Merges consecutive assistant messages together. */ function validateGeminiTurns(messages) { if (!Array.isArray(messages) || messages.length === 0) return messages; const result = []; let lastRole; for (const msg of messages) { if (!msg || typeof msg !== "object") { result.push(msg); continue; } const msgRole = msg.role; if (!msgRole) { result.push(msg); continue; } if (msgRole === lastRole && lastRole === "assistant") { const lastMsg = result[result.length - 1]; const currentMsg = msg; if (lastMsg && typeof lastMsg === "object") { const lastAsst = lastMsg; const mergedContent = [...Array.isArray(lastAsst.content) ? lastAsst.content : [], ...Array.isArray(currentMsg.content) ? currentMsg.content : []]; const merged = { ...lastAsst, content: mergedContent, ...currentMsg.usage && { usage: currentMsg.usage }, ...currentMsg.stopReason && { stopReason: currentMsg.stopReason }, ...currentMsg.errorMessage && { errorMessage: currentMsg.errorMessage } }; result[result.length - 1] = merged; continue; } } result.push(msg); lastRole = msgRole; } return result; } function mergeConsecutiveUserTurns(previous, current) { const mergedContent = [...Array.isArray(previous.content) ? previous.content : [], ...Array.isArray(current.content) ? current.content : []]; return { ...current, content: mergedContent, timestamp: current.timestamp ?? previous.timestamp }; } /** * Validates and fixes conversation turn sequences for Anthropic API. * Anthropic requires strict alternating user→assistant pattern. * Merges consecutive user messages together. */ function validateAnthropicTurns(messages) { if (!Array.isArray(messages) || messages.length === 0) return messages; const result = []; let lastRole; for (const msg of messages) { if (!msg || typeof msg !== "object") { result.push(msg); continue; } const msgRole = msg.role; if (!msgRole) { result.push(msg); continue; } if (msgRole === lastRole && lastRole === "user") { const lastMsg = result[result.length - 1]; const currentMsg = msg; if (lastMsg && typeof lastMsg === "object") { const merged = mergeConsecutiveUserTurns(lastMsg, currentMsg); result[result.length - 1] = merged; continue; } } result.push(msg); lastRole = msgRole; } return result; } //#endregion export { resolveCacheTtlMs as $, stripPluginOnlyAllowlist as $t, isContextOverflowError as A, isWhatsAppGroupJid as An, resizeToJpeg as At, ensureSandboxWorkspaceForSession as B, normalizeChatType as Bn, loadWorkspaceSkillEntries as Bt, classifyFailoverReason as C, maxBytesForKind as Cn, registerBrowserRoutes as Ct, isAuthAssistantError as D, getChannelPlugin as Dn, getImageMetadata as Dt, getApiErrorPayloadFingerprint as E, resolveSignalAccount as En, convertHeicToJpeg as Et, isRawApiErrorPayload as F, resolveTelegramToken as Fn, createPinnedDispatcher as Ft, loadSessionStore as G, resolveSandboxConfigForAgent as Gt, resolveSandboxRuntimeStatus as H, resolveDiscordAccount as Hn, resolvePluginSkillDirs as Ht, isTimeoutErrorMessage as I, listBindings as In, resolvePinnedHostname as It, saveSessionStore as J, collectExplicitAllowlist as Jt, readSessionUpdatedAt as K, applyOwnerOnlyToolPolicy as Kt, parseImageDimensionError as L, resolveSlackAccount as Ln, resolvePinnedHostnameWithPolicy as Lt, isFailoverErrorMessage as M, listEnabledTelegramAccounts as Mn, saveMediaBuffer as Mt, isLikelyContextOverflowError as N, listTelegramAccountIds as Nn, SsrFBlockedError as Nt, isCloudCodeAssistFormatError as O, listChannelPlugins as On, hasAlphaChannel as Ot, isRateLimitAssistantError as P, resolveTelegramAccount as Pn, closeDispatcher as Pt, isCacheEnabled as Q, resolveToolProfilePolicy as Qt, parseImageSizeError as R, resolveSlackAppToken as Rn, buildWorkspaceSkillCommandSpecs as Rt, isGoogleModelApi as S, MAX_IMAGE_BYTES$1 as Sn, createBrowserRouteContext as St, formatRawAssistantErrorForUi as T, listEnabledSignalAccounts as Tn, resolveProfile as Tt, appendAssistantMessageToSessionTranscript as U, normalizeDiscordToken as Un, applySkillEnvOverrides as Ut, resolveSandboxContext as V, listEnabledDiscordAccounts as Vn, resolveSkillsPromptForRun as Vt, resolveMirroredTranscriptText as W, applySkillEnvOverridesFromSnapshot as Wt, updateSessionStore as X, expandToolGroups as Xt, updateLastRoute as Y, expandPolicyWithPluginGroups as Yt, updateSessionStoreEntry as Z, normalizeToolName as Zt, sanitizeSessionMessagesImages as _, getFileExtension as _n, resolveChannelGroupRequireMention as _t, formatXHighModelHint as a, isDeliverableMessageChannel as an, resolveSessionKey as at, downgradeOpenAIReasoningBlocks as b, isGifMedia as bn, resolveConversationLabel as bt, normalizeReasoningLevel as c, listDeliverableMessageChannels as cn, resolveSessionResetPolicy as ct, normalizeVerboseLevel as d, resolveMessageChannel as dn, DEFAULT_RESET_TRIGGERS as dt, buildBootstrapContextFiles as en, deliveryContextFromSession as et, resolveResponseUsageMode as f, GATEWAY_CLIENT_IDS as fn, resolveMainSessionKey as ft, normalizeTextForComparison as g, extensionForMime as gn, resolveChannelGroupPolicy as gt, isMessagingToolDuplicateNormalized as h, detectMime as hn, listChannelDocks as ht, formatThinkingLevels as i, INTERNAL_MESSAGE_CHANNEL as in, normalizeSessionDeliveryFields as it, isFailoverAssistantError as j, normalizeWhatsAppTarget as jn, getMediaDir as jt, isCompactionFailureError as k, normalizeChannelId as kn, optimizeImageToPng as kt, normalizeThinkLevel as l, normalizeMessageChannel as ln, resolveSessionResetType as lt, isMessagingToolDuplicate as m, GATEWAY_CLIENT_NAMES as mn, getChannelDock as mt, validateGeminiTurns as n, resolveBootstrapMaxChars as nn, mergeDeliveryContext as nt, listThinkingLevels as o, isInternalMessageChannel as on, evaluateSessionFreshness as ot, supportsXHighThinking as p, GATEWAY_CLIENT_MODES as pn, deriveSessionMetaPatch as pt, recordSessionMetaFromInbound as q, buildPluginToolGroups as qt, pickFallbackThinkingLevel as r, sanitizeGoogleTurnOrdering as rn, normalizeDeliveryContext as rt, normalizeElevatedLevel as s, isMarkdownCapableMessageChannel as sn, resolveChannelResetConfig as st, validateAnthropicTurns as t, ensureSessionHeader$1 as tn, deliveryContextKey as tt, normalizeUsageDisplay as u, resolveGatewayMessageChannel as un, resolveThreadFlag as ut, sanitizeImageBlocks as v, imageMimeFromFormat as vn, resolveChannelGroupToolsPolicy as vt, formatAssistantErrorText as w, mediaKindFromMime as wn, resolveBrowserConfig as wt, isAntigravityClaude as x, kindFromMime as xn, resolveGroupSessionKey as xt, sanitizeToolResultImages as y, isAudioFileName as yn, resolveIMessageAccount as yt, sanitizeUserFacingText as z, resolveSlackBotToken as zn, buildWorkspaceSkillSnapshot as zt };