UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

512 lines (511 loc) 20.3 kB
import { t as isTruthyEnvValue } from "./env-Di49gJwo.js"; import { t as collectErrorGraphCandidates } from "./errors-BXgSefBE.js"; import "./error-runtime-C8vbtAJt.js"; import "./runtime-env-D_2q8-VK.js"; import { createRequire } from "node:module"; import fs from "node:fs"; import os from "node:os"; //#region extensions/bonjour/src/errors.ts /** * Bonjour error formatting helper. It normalizes Error and non-Error values * into concise messages for gateway discovery logs. */ /** Format an unknown Bonjour/ciao error value for logs. */ function formatBonjourError(err) { if (err instanceof Error) { const msg = err.message.trim() || err.name || String(err).trim(); if (err.name && err.name !== "Error") return msg === err.name ? err.name : `${err.name}: ${msg}`; return msg; } return String(err); } //#endregion //#region extensions/bonjour/src/ciao.ts /** * Ciao process-error classifier. It recognizes known noisy ciao failures so * the Bonjour plugin can suppress or repair expected mDNS lifecycle issues. */ const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u; const CIAO_INTERFACE_ASSERTION_MESSAGE_RE = /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGED? FROM (?:DEFINED TO UNDEFINED|UNDEFINED TO DEFINED)!?/u; const CIAO_NETMASK_ASSERTION_MESSAGE_RE = /IP ADDRESS VERSION MUST MATCH\.\s+NETMASK CANNOT HAVE A VERSION DIFFERENT FROM THE ADDRESS!?/u; const CIAO_SELF_PROBE_MESSAGE_RE = /CAN'T PROBE FOR A SERVICE WHICH IS ANNOUNCED ALREADY\.\s+RECEIVED (?:PROBING|ANNOUNCING|ANNOUNCED) FOR SERVICE\b/u; const CIAO_INTERFACE_ENUMERATION_FAILURE_RE = /\bUV_INTERFACE_ADDRESSES\b/u; /** Classify a ciao error/rejection chain into a known category. */ function classifyCiaoProcessError(reason) { for (const candidate of collectErrorGraphCandidates(reason, (current) => [ current.cause, current.reason, current.original, current.error, current.data, ...Array.isArray(current.errors) ? current.errors : [] ])) { const formatted = formatBonjourError(candidate); const message = formatted.toUpperCase(); if (CIAO_CANCELLATION_MESSAGE_RE.test(message)) return { kind: "cancellation", formatted }; if (CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) return { kind: "interface-assertion", formatted }; if (CIAO_NETMASK_ASSERTION_MESSAGE_RE.test(message)) return { kind: "netmask-assertion", formatted }; if (CIAO_SELF_PROBE_MESSAGE_RE.test(message)) return { kind: "self-probe", formatted }; if (CIAO_INTERFACE_ENUMERATION_FAILURE_RE.test(message)) return { kind: "interface-enumeration-failure", formatted }; } return null; } //#endregion //#region extensions/bonjour/src/advertiser.ts const childProcessModule = createRequire(import.meta.url)("node:child_process"); const WATCHDOG_INTERVAL_MS = 5e3; const REPAIR_DEBOUNCE_MS = 3e4; const CONFLICT_SETTLE_MS = 3e4; const STUCK_ANNOUNCING_MS = 2e4; const MAX_CONSECUTIVE_RESTARTS = 3; const MAX_CONSECUTIVE_STUCK_STATE_RESTARTS = 1; const RESTART_WINDOW_MS = 30 * 6e4; const MAX_RESTARTS_IN_WINDOW = 5; const BONJOUR_ANNOUNCED_STATE = "announced"; const CIAO_SELF_PROBE_RETRY_FRAGMENT = "failed probing with reason: Error: Can't probe for a service which is announced already."; const defaultLogger = { info: (_msg) => {}, warn: (_msg) => {}, debug: (_msg) => {} }; const CIAO_MODULE_ID = "@homebridge/ciao"; const CIAO_WINDOWS_SHELL_COMMANDS = new Set(["arp -a | findstr /C:\"---\""]); let ciaoModulePromise = null; let ciaoExecHidePatchDepth = 0; let restoreCiaoExecHidePatchOnce = null; async function loadCiaoModule() { ciaoModulePromise ??= import(CIAO_MODULE_ID); return ciaoModulePromise; } function readBonjourDisableOverride() { const raw = process.env.OPENCLAW_DISABLE_BONJOUR; const normalized = raw?.trim().toLowerCase(); if (!normalized) return null; if (isTruthyEnvValue(raw)) return true; switch (normalized) { case "0": case "false": case "no": case "off": return false; default: return null; } } function isContainerEnvironment() { if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) return true; for (const sentinelPath of [ "/.dockerenv", "/run/.containerenv", "/var/run/.containerenv" ]) try { if (fs.existsSync(sentinelPath)) return true; } catch {} try { const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8"); return /\/docker\/|cri-containerd-[0-9a-f]|containerd\/[0-9a-f]{64}|\/kubepods[/.]|\blxc\b/u.test(cgroup); } catch { return false; } } function isDisabledByEnv() { if (process.env.VITEST) return true; const envOverride = readBonjourDisableOverride(); if (envOverride !== null) return envOverride; if (isContainerEnvironment()) return true; return false; } function resolveSystemMdnsHostname() { let raw; try { raw = os.hostname(); } catch { return null; } const trimmed = raw.trim(); if (!trimmed) return null; const firstLabel = trimmed.replace(/\.local$/i, "").split(".")[0]?.trim() ?? ""; if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(firstLabel)) return null; return firstLabel; } const MAX_DNS_LABEL_BYTES = 63; const utf8Encoder = new TextEncoder(); function truncateToDnsLabel(name, fallback = "OpenClaw") { const encoded = utf8Encoder.encode(name); if (encoded.byteLength <= MAX_DNS_LABEL_BYTES) return name; for (let end = MAX_DNS_LABEL_BYTES; end > 0; end -= 1) try { return new TextDecoder("utf-8", { fatal: true }).decode(encoded.subarray(0, end)).replace(/-+$/, "").trim() || fallback; } catch {} return fallback; } function safeServiceName(name) { const trimmed = name.trim(); return trimmed.length > 0 ? truncateToDnsLabel(trimmed) : "OpenClaw"; } function prettifyInstanceName(name) { const normalized = name.trim().replace(/\s+/g, " "); return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized; } function serviceSummary(label, svc) { let fqdn = "unknown"; let hostname = "unknown"; let port = -1; try { fqdn = svc.getFQDN(); } catch {} try { hostname = svc.getHostname(); } catch {} try { port = svc.getPort(); } catch {} const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`; } function isAnnouncedState(state) { return state === BONJOUR_ANNOUNCED_STATE; } function isAdvertisingInProgressState(state) { return state === "probing" || state === "announcing"; } function shouldSuppressCiaoConsoleLog(args) { return args.some((arg) => typeof arg === "string" && arg.includes(CIAO_SELF_PROBE_RETRY_FRAGMENT)); } function installCiaoConsoleNoiseFilter() { const previousConsoleLog = console.log; const wrapper = ((...args) => { if (shouldSuppressCiaoConsoleLog(args)) return; previousConsoleLog(...args); }); console.log = wrapper; return () => { if (console.log === wrapper) console.log = previousConsoleLog; }; } function isExecOptionsRecord(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function shouldHideCiaoWindowsShell(command) { return process.platform === "win32" && CIAO_WINDOWS_SHELL_COMMANDS.has(command.trim()); } function installCiaoWindowsExecHidePatch() { if (process.platform !== "win32") return () => {}; ciaoExecHidePatchDepth += 1; if (!restoreCiaoExecHidePatchOnce) { const previousExec = childProcessModule.exec; const wrapper = ((command, options, callback) => { if (shouldHideCiaoWindowsShell(command)) { if (typeof options === "function") return previousExec.call(childProcessModule, command, { windowsHide: true }, options); if (options == null) return previousExec.call(childProcessModule, command, { windowsHide: true }, callback); if (isExecOptionsRecord(options) && options.windowsHide === void 0) return previousExec.call(childProcessModule, command, { ...options, windowsHide: true }, callback); } return previousExec.call(childProcessModule, command, options, callback); }); childProcessModule.exec = wrapper; restoreCiaoExecHidePatchOnce = () => { if (childProcessModule.exec === wrapper) childProcessModule.exec = previousExec; }; } let active = true; return () => { if (!active) return; active = false; ciaoExecHidePatchDepth = Math.max(0, ciaoExecHidePatchDepth - 1); if (ciaoExecHidePatchDepth > 0) return; restoreCiaoExecHidePatchOnce?.(); restoreCiaoExecHidePatchOnce = null; }; } function installCiaoUnhandledRejectionListener(handler) { const hadOtherListeners = process.listenerCount("unhandledRejection") > 0; const listener = (reason) => { if (handler(reason)) return; if (hadOtherListeners) return; queueMicrotask(() => { throw reason instanceof Error ? reason : new Error(String(reason)); }); }; process.on("unhandledRejection", listener); return () => { process.off("unhandledRejection", listener); }; } /** Start Bonjour advertisements for the local gateway services. */ async function startGatewayBonjourAdvertiser(opts, deps = {}) { if (isDisabledByEnv()) return { stop: async () => {} }; const logger = { info: deps.logger?.info ?? defaultLogger.info, warn: deps.logger?.warn ?? defaultLogger.warn, debug: deps.logger?.debug ?? defaultLogger.debug }; const restoreCiaoExecHidePatch = installCiaoWindowsExecHidePatch(); let restoreConsoleLog = () => {}; let requestCiaoRecovery; let cleanupUnhandledRejection; let cleanupDirectUnhandledRejection; let cleanupUncaughtException; let processHandlersCleaned = false; function cleanupProcessHandlers() { if (processHandlersCleaned) return; processHandlersCleaned = true; cleanupDirectUnhandledRejection?.(); cleanupUncaughtException?.(); cleanupUnhandledRejection?.(); } try { const { getResponder, Protocol } = await loadCiaoModule(); restoreConsoleLog = installCiaoConsoleNoiseFilter(); const handleCiaoProcessError = (reason) => { const classification = classifyCiaoProcessError(reason); if (!classification) return false; if (classification.kind === "cancellation") { logger.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`); requestCiaoRecovery?.(classification); } else if (classification.kind === "interface-enumeration-failure") logger.warn(`bonjour: disabling mDNS — networkInterfaces() unavailable in this environment: ${classification.formatted}`); else { const label = classification.kind === "netmask-assertion" ? "netmask assertion" : classification.kind === "self-probe" ? "self-probe race" : "interface assertion"; logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`); requestCiaoRecovery?.(classification); } return true; }; cleanupDirectUnhandledRejection = installCiaoUnhandledRejectionListener(handleCiaoProcessError); cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError); cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError); const hostname = truncateToDnsLabel((process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || resolveSystemMdnsHostname() || "openclaw").replace(/\.local$/i, "").split(".")[0].trim() || "openclaw", "openclaw"); const instanceName = typeof opts.instanceName === "string" && opts.instanceName.trim() ? opts.instanceName.trim() : `${hostname} (OpenClaw)`; const displayName = prettifyInstanceName(instanceName); const txtBase = { role: "gateway", gatewayPort: String(opts.gatewayPort), lanHost: `${hostname}.local`, displayName }; if (opts.gatewayTlsEnabled) { txtBase.gatewayTls = "1"; if (opts.gatewayTlsFingerprintSha256) txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256; } if (opts.gatewayDirectReachable) txtBase.gatewayDirectReachable = "1"; if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) txtBase.canvasPort = String(opts.canvasPort); if (!opts.minimal && typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) txtBase.tailnetDns = opts.tailnetDns.trim(); if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) txtBase.cliPath = opts.cliPath.trim(); const gatewayTxt = { ...txtBase, transport: "gateway" }; if (!opts.minimal) gatewayTxt.sshPort = String(opts.sshPort ?? 22); const responder = getResponder(); function createCycle() { const services = []; const gateway = responder.createService({ name: safeServiceName(instanceName), type: "openclaw-gw", protocol: Protocol.TCP, port: opts.gatewayPort, domain: "local", hostname, txt: gatewayTxt }); services.push({ label: "gateway", svc: gateway }); return { responder, services }; } async function stopCycle(cycle, optsValue) { if (!cycle) return; for (const { svc } of cycle.services) try { await svc.destroy(); } catch {} try { if (optsValue?.shutdownResponder) await cycle.responder.shutdown(); } catch {} } function attachConflictListeners(services) { for (const { label, svc } of services) try { svc.on("name-change", (name) => { markConflictObserved(label, svc); const next = typeof name === "string" ? name : String(name); logger.warn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`); }); svc.on("hostname-change", (nextHostname) => { markConflictObserved(label, svc); const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname); logger.warn(`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`); }); } catch (err) { logger.debug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`); } } function handleAdvertiseFailure(label, svc, err, action) { const classification = classifyCiaoProcessError(err); if (classification) { logger.warn(`bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary(label, svc)}): ${classification.formatted}`); requestCiaoRecovery?.(classification); return; } logger.warn(`bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); } function startAdvertising(services) { for (const { label, svc } of services) try { svc.advertise().then(() => { logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`); }).catch((err) => { handleAdvertiseFailure(label, svc, err, "failed"); }); } catch (err) { handleAdvertiseFailure(label, svc, err, "threw"); } } logger.debug(`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(safeServiceName(instanceName))}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`); let stopped = false; let recreatePromise = null; let disabled = false; let consecutiveRestarts = 0; let consecutiveStuckStateRestarts = 0; const restartTimestamps = []; let cycle = createCycle(); const stateTracker = /* @__PURE__ */ new Map(); const conflictTracker = /* @__PURE__ */ new Map(); const markConflictObserved = (label, svc) => { const now = Date.now(); conflictTracker.set(label, now); const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; stateTracker.set(label, { state: nextState, sinceMs: now }); }; const updateStateTrackers = (services) => { const now = Date.now(); for (const { label, svc } of services) { const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; const current = stateTracker.get(label); const nextEnteredAt = current && !isAnnouncedState(current.state) && !isAnnouncedState(nextState) ? current.sinceMs : now; if (!current || current.state !== nextState || current.sinceMs !== nextEnteredAt) stateTracker.set(label, { state: nextState, sinceMs: nextEnteredAt }); } }; const recreateAdvertiser = async (reason, optsLocal) => { if (stopped || disabled) return; if (recreatePromise) return recreatePromise; recreatePromise = (async () => { consecutiveRestarts += 1; consecutiveStuckStateRestarts = optsLocal?.stuckState ? consecutiveStuckStateRestarts + 1 : 0; const now = Date.now(); while (restartTimestamps.length > 0 && now - (restartTimestamps[0] ?? 0) > RESTART_WINDOW_MS) restartTimestamps.shift(); restartTimestamps.push(now); const tooManyConsecutive = consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS; const tooManyStuckStates = consecutiveStuckStateRestarts > MAX_CONSECUTIVE_STUCK_STATE_RESTARTS; const tooManyInWindow = restartTimestamps.length >= MAX_RESTARTS_IN_WINDOW; if (tooManyConsecutive || tooManyStuckStates || tooManyInWindow) { disabled = true; const detail = tooManyConsecutive ? `${MAX_CONSECUTIVE_RESTARTS} failed restarts` : tooManyStuckStates ? `${MAX_CONSECUTIVE_STUCK_STATE_RESTARTS} stuck-state restart` : `${MAX_RESTARTS_IN_WINDOW} restarts within ${Math.round(RESTART_WINDOW_MS / 6e4)} minutes`; logger.warn(`bonjour: disabling advertiser after ${detail} (${reason}); set discovery.mdns.mode="off" or OPENCLAW_DISABLE_BONJOUR=1 to disable mDNS discovery`); const previous = cycle; cycle = null; stateTracker.clear(); conflictTracker.clear(); await stopCycle(previous, { shutdownResponder: true }); restoreConsoleLog(); restoreCiaoExecHidePatch(); return; } logger.warn(`bonjour: restarting advertiser (${reason})`); await stopCycle(cycle); cycle = createCycle(); stateTracker.clear(); conflictTracker.clear(); attachConflictListeners(cycle.services); startAdvertising(cycle.services); })().finally(() => { recreatePromise = null; }); return recreatePromise; }; requestCiaoRecovery = (classification) => { recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`); }; attachConflictListeners(cycle.services); startAdvertising(cycle.services); const lastRepairAttempt = /* @__PURE__ */ new Map(); const watchdog = setInterval(() => { if (stopped || recreatePromise) return; if (disabled || !cycle) return; updateStateTrackers(cycle.services); for (const { label, svc } of cycle.services) { const now = Date.now(); const stateUnknown = svc.serviceState; if (typeof stateUnknown !== "string") continue; if (stateUnknown === "announced") { consecutiveRestarts = 0; consecutiveStuckStateRestarts = 0; conflictTracker.delete(label); } const lastConflictAt = conflictTracker.get(label); if (lastConflictAt !== void 0 && now - lastConflictAt >= CONFLICT_SETTLE_MS) conflictTracker.delete(label); if (lastConflictAt !== void 0 && now - lastConflictAt < CONFLICT_SETTLE_MS) continue; const tracked = stateTracker.get(label); if (stateUnknown !== "announced" && tracked && now - tracked.sinceMs >= STUCK_ANNOUNCING_MS) { recreateAdvertiser(`service stuck in ${stateUnknown} for ${now - tracked.sinceMs}ms (${serviceSummary(label, svc)})`, { stuckState: true }); return; } if (stateUnknown === "announced" || isAdvertisingInProgressState(stateUnknown)) continue; let key = label; try { key = `${label}:${svc.getFQDN()}`; } catch {} if (now - (lastRepairAttempt.get(key) ?? 0) < REPAIR_DEBOUNCE_MS) continue; lastRepairAttempt.set(key, now); logger.warn(`bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary(label, svc)})`); try { svc.advertise().catch((err) => { logger.warn(`bonjour: watchdog re-advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); }); } catch (err) { logger.warn(`bonjour: watchdog re-advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); } } }, WATCHDOG_INTERVAL_MS); watchdog.unref?.(); return { stop: async () => { stopped = true; clearInterval(watchdog); try { await recreatePromise; } catch {} await stopCycle(cycle, { shutdownResponder: true }); restoreConsoleLog(); restoreCiaoExecHidePatch(); cleanupProcessHandlers(); } }; } catch (err) { restoreConsoleLog(); restoreCiaoExecHidePatch(); cleanupProcessHandlers(); throw err; } } //#endregion export { startGatewayBonjourAdvertiser };