UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

321 lines (320 loc) • 12.9 kB
import { s as toErrorObject } from "./error-coercion-CwIUUFnu.mjs"; import { N as normalizeLowercaseStringOrEmpty, w as parseStrictPositiveInteger } from "./utils-Dor5uhSv.mjs"; import { C as normalizeProfileName, M as isErrno, st as expectDefined } from "./redact-BwKH5fIe.mjs"; import "./errors-C_Op1bVg.mjs"; import { t as classifyOpenClawArgv } from "./gateway-process-argv-DHt7guiS.mjs"; import net from "node:net"; //#region src/cli/command-format.ts const CLI_PREFIX_RE = /^(?:pnpm|npm|bunx|npx)\s+openclaw\b|^openclaw\b/; const CONTAINER_FLAG_RE = /(?:^|\s)--container(?:\s|=|$)/; const PROFILE_FLAG_RE = /(?:^|\s)--profile(?:\s|=|$)/; const DEV_FLAG_RE = /(?:^|\s)--dev(?:\s|$)/; const UPDATE_RE = /^(?:\s+--(?:dev|no-color|(?:profile|log-level)[=\s]+\S+))*\s+update(?:\s|$)/; const CONTAINER_HINT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/; /** Add active root options to a displayed command without duplicating explicit flags. */ function formatCliCommand(command, env = process.env) { const rawContainer = env.OPENCLAW_CONTAINER_HINT?.trim(); const container = rawContainer && CONTAINER_HINT_RE.test(rawContainer) ? rawContainer : void 0; const profile = normalizeProfileName(env.OPENCLAW_PROFILE); if (!container && !profile) return command; if (!CLI_PREFIX_RE.test(command)) return command; const additions = []; if (container && !CONTAINER_FLAG_RE.test(command) && !UPDATE_RE.test(command.replace(CLI_PREFIX_RE, ""))) additions.push(`--container ${container}`); if (!container && profile && !PROFILE_FLAG_RE.test(command) && !DEV_FLAG_RE.test(command)) additions.push(`--profile ${profile}`); if (additions.length === 0) return command; return command.replace(CLI_PREFIX_RE, (match) => `${match} ${additions.join(" ")}`); } //#endregion //#region src/daemon/arg-split.ts /** Splits service command strings while preserving quoted arguments across platform parsers. */ function splitArgsPreservingQuotes(value, options) { const args = []; let current = ""; let quoteChar = null; const escapeMode = options?.escapeMode ?? "none"; const quoteChars = new Set(options?.quoteChars ?? ["\""]); const quoteStart = options?.quoteStart ?? "anywhere"; for (let i = 0; i < value.length; i++) { const char = value.charAt(i); if (escapeMode === "backslash" && char === "\\") { if (i + 1 < value.length) { current += value[i + 1]; i++; } continue; } if (escapeMode === "backslash-quote-only" && char === "\\" && i + 1 < value.length && value[i + 1] === "\"") { current += "\""; i++; continue; } if (quoteChars.has(char)) { if (quoteChar === char) { quoteChar = null; continue; } const canOpenQuote = quoteStart === "anywhere" || current.length === 0; if (!quoteChar && canOpenQuote) { quoteChar = char; continue; } } if (!quoteChar && /\s/.test(char)) { if (current) { args.push(current); current = ""; } continue; } current += char; } if (current) args.push(current); return args; } //#endregion //#region src/infra/ports-netstat.ts function normalizeTcpHost(host) { const normalized = host.toLowerCase(); return normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized; } function parseTcpPort(raw) { if (!raw || !/^\d+$/.test(raw)) return null; const port = Number(raw); return Number.isSafeInteger(port) && port >= 0 && port <= 65535 ? port : null; } function parseTcpEndpoint(raw) { const endpoint = raw.trim(); const bracketMatch = endpoint.match(/^\[([^\]]+)\]:(\d+)$/); if (bracketMatch) { const port = parseTcpPort(bracketMatch[2]); return port === null ? null : { host: normalizeTcpHost(expectDefined(bracketMatch[1], "bracket match capture group 1")), port }; } const lastColon = endpoint.lastIndexOf(":"); if (lastColon <= 0 || lastColon >= endpoint.length - 1) return null; const port = parseTcpPort(endpoint.slice(lastColon + 1)); if (port === null) return null; return { host: normalizeTcpHost(endpoint.slice(0, lastColon)), port }; } /** Parses the address field emitted for a TCP listener by lsof or netstat. */ function parseTcpListenerEndpoint(raw) { const normalized = raw?.trim().replace(/^tcp6?\s+/i, "").replace(/\s*\(listen\)\s*$/i, ""); return normalized ? parseTcpEndpoint(normalized) : null; } function isWildcardEndpoint(raw) { const endpoint = raw?.trim(); if (!endpoint || endpoint === "*:*") return true; const parsed = parseTcpEndpoint(endpoint); if (!parsed) return false; return parsed.port === 0 && [ "0.0.0.0", "::", "*" ].includes(parsed.host); } function parseWindowsNetstatListeners(output, port) { const listeners = []; for (const rawLine of output.split(/\r?\n/)) { const parts = rawLine.trim().split(/\s+/); if (parts.length < 5 || parts[0]?.toUpperCase() !== "TCP") continue; const localAddress = parts[1]; const remoteAddress = parts[2]; if (!localAddress || parseTcpEndpoint(localAddress)?.port !== port) continue; if (!isWildcardEndpoint(remoteAddress)) continue; const pid = parseStrictPositiveInteger(parts.at(-1)); if (pid === void 0) continue; listeners.push({ pid, address: localAddress }); } return listeners; } //#endregion //#region src/infra/ports-format.ts /** Classifies a listener as OpenClaw Gateway, SSH tunnel, known non-gateway, or unknown. */ function classifyPortListener(listener, _port) { const command = normalizeLowercaseStringOrEmpty(listener.command ?? ""); const commandLine = normalizeLowercaseStringOrEmpty(listener.commandLine ?? ""); if (command === "socat" || command === "socat1" || command === "socat.exe") return "non_gateway"; const argv = listener.commandLine ? splitArgsPreservingQuotes(listener.commandLine, { escapeMode: "backslash-quote-only" }) : [listener.command ?? ""]; if (classifyOpenClawArgv(argv, { command: "gateway", pid: listener.pid }).kind === "openclaw") return "gateway"; if (/(?:^|[/\\])ssh(?:\.exe)?$/.test(command) || /(?:^|[\s"'])(?:(?:"[^"]*[/\\])|(?:'[^']*[/\\])|(?:\S*[/\\]))?ssh(?:\.exe)?(?:[\s"']|$)/.test(commandLine)) return "ssh"; if (command === "sshd" || /(?:^|[/\\])sshd(?:\.exe)?$/.test(command) || /(?:^|[/\\])[^/\\\s]*ssh[^/\\\s]*(?:\.exe)?$/.test(command)) return "non_gateway"; if (/(?:^|[/\\\s])[^/\\\s]*ssh[^/\\\s]*(?:\.exe)?(?:[/\\\s"']|$)/.test(commandLine)) return "non_gateway"; return "unknown"; } function classifyLoopbackAddressFamily(host) { if (host === "127.0.0.1" || host === "localhost") return "ipv4"; if (host === "::1" || host === "::ffff:127.0.0.1") return "ipv6"; return null; } function isWildcardAddress(host) { return host === "0.0.0.0" || host === "::" || host === "*"; } function isExpectedGatewayBindAddress(host) { return classifyLoopbackAddressFamily(host) !== null || isWildcardAddress(host); } function parsePortListeners(listeners, port) { const parsedListeners = []; for (const listener of listeners) { const pid = listener.pid; if (typeof pid !== "number" || !Number.isFinite(pid) || typeof listener.address !== "string") return null; const address = parseTcpListenerEndpoint(listener.address); if (!address || address.port !== port) return null; parsedListeners.push({ pid, host: address.host }); } return parsedListeners; } function parseGatewayListeners(listeners, port) { if (listeners.some((listener) => classifyPortListener(listener, port) !== "gateway")) return null; return parsePortListeners(listeners, port); } function parsedListenersAreDualStackLoopback(parsed) { const pids = new Set(parsed.map(({ pid }) => pid)); const families = new Set(parsed.map(({ host }) => classifyLoopbackAddressFamily(host))); return pids.size === 1 && !families.has(null) && families.has("ipv4") && families.has("ipv6"); } function parsedListenersOwnSpecificIpv4WithLoopback(parsed) { return parsed.every(({ pid }) => pid === parsed[0]?.pid) && parsed.some(({ host }) => host === "127.0.0.1") && parsed.some(({ host }) => host !== "127.0.0.1" && net.isIP(host) === 4 && !isWildcardAddress(host)); } /** Returns true when listener rows describe a benign Gateway bind pattern. */ function isExpectedGatewayListeners(listeners, port) { const parsed = parseGatewayListeners(listeners, port); if (!parsed) return false; if (parsed.length === 1) return Boolean(parsed[0] && isExpectedGatewayBindAddress(parsed[0].host)); return parsedListenersAreDualStackLoopback(parsed) || parsed.length === 2 && parsedListenersOwnSpecificIpv4WithLoopback(parsed); } /** Builds user-facing remediation hints for processes occupying a port. */ function buildPortHints(listeners, port) { if (listeners.length === 0) return []; const kinds = new Set(listeners.map((listener) => classifyPortListener(listener, port))); const hints = []; const expectedGatewayListeners = isExpectedGatewayListeners(listeners, port); if (kinds.has("gateway") && !expectedGatewayListeners) hints.push(`Gateway already running locally. Stop it (${formatCliCommand("openclaw gateway stop")}) or use a different port.`); if (kinds.has("ssh")) hints.push("SSH tunnel already bound to this port. Close the tunnel or use a different local port in -L."); if (kinds.has("unknown") || kinds.has("non_gateway")) hints.push("Another process is listening on this port."); if (listeners.length > 1 && !expectedGatewayListeners) hints.push("Multiple listeners detected; ensure only one gateway/tunnel per port unless intentionally running isolated profiles."); return hints; } //#endregion //#region src/infra/ports-probe.ts const PORT_PROBE_HOSTS = [ "127.0.0.1", "0.0.0.0", "::1", "::" ]; async function tryListenOnPort(params) { if (params.signal?.aborted) throw params.signal.reason; const listenOptions = { port: params.port }; if (params.host) listenOptions.host = params.host; if (typeof params.exclusive === "boolean") listenOptions.exclusive = params.exclusive; if (params.signal) listenOptions.signal = params.signal; return await new Promise((resolve, reject) => { const clearAbort = () => params.signal?.removeEventListener("abort", onAbort); const onAbort = () => { clearAbort(); reject(toErrorObject(params.signal?.reason, "Port probe aborted")); }; params.signal?.addEventListener("abort", onAbort, { once: true }); const tester = net.createServer().once("error", (error) => { clearAbort(); reject(error); }).once("listening", () => { const address = tester.address(); if (!address || typeof address === "string") { tester.close(() => { clearAbort(); reject(/* @__PURE__ */ new Error("expected TCP listener address")); }); return; } tester.close(() => { clearAbort(); resolve(params.port === 0 ? address.port : void 0); }); }).listen(listenOptions); }); } /** Observe a listener without retaining its socket or cancellation hook. */ async function probeTcpListener(port, host, signal) { signal?.throwIfAborted(); return await new Promise((resolve) => { const socket = net.connect({ host, port }); let result = "unknown"; const destroy = () => socket.destroy(); signal?.addEventListener("abort", destroy, { once: true }); socket.once("connect", () => { result = "busy"; destroy(); }); socket.once("error", (error) => { result = isErrno(error) && error.code === "ECONNREFUSED" ? "free" : "unknown"; destroy(); }); socket.setTimeout(250, destroy); socket.once("close", () => { signal?.removeEventListener("abort", destroy); resolve(result); }); }); } async function isIpv6LoopbackUnavailable(signal) { try { await tryListenOnPort({ port: 0, host: "::1", ...signal ? { signal } : {} }); return false; } catch (err) { return isErrno(err) && err.code === "EADDRNOTAVAIL"; } } async function probePortOnHost(port, host, signal) { try { await tryListenOnPort({ port, host, exclusive: true, ...signal ? { signal } : {} }); const confirmed = await probeTcpListener(port, host, signal); if (confirmed === "unknown" && host === "::" && await isIpv6LoopbackUnavailable(signal)) return "free"; return confirmed; } catch (err) { signal?.throwIfAborted(); if (isErrno(err) && err.code === "EADDRINUSE") return "busy"; if (isErrno(err) && (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")) return "skip"; return "unknown"; } } /** Checks selected local addresses without resolving listener diagnostics. */ async function probePortUsage(port, probeHosts = PORT_PROBE_HOSTS, signal) { signal?.throwIfAborted(); let sawUnknown = false; for (const host of probeHosts) { const result = await probePortOnHost(port, host, signal); signal?.throwIfAborted(); if (result === "busy") return "busy"; if (result === "unknown") sawUnknown = true; } return sawUnknown ? "unknown" : "free"; } //#endregion export { parseTcpEndpoint as a, buildPortHints as i, probeTcpListener as n, parseTcpListenerEndpoint as o, tryListenOnPort as r, parseWindowsNetstatListeners as s, probePortUsage as t };