UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

477 lines (476 loc) 21.2 kB
import { g as readStringValue, l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { a as writeRuntimeJson } from "./runtime-CF2WjnNZ.js"; import { t as createLazyImportLoader } from "./lazy-promise-DGqyc4Y4.js"; import { a as readBestEffortConfig } from "./io.runtime-B9iJRs3w.js"; import { n as isAbortError } from "./abort-signal-D2k14JsD.js"; import { _ as resolveGatewayPort } from "./paths-D2sRr1a_.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import "./config-Cs0XXL3x.js"; import { n as isRich, r as theme, t as colorize } from "./theme-vjDs9tao.js"; import { t as parseGatewayPortOption } from "./gateway-port-option-0NYr1eQR.js"; import { r as resolveExplicitGatewayAuth } from "./credentials-CXbS4PS0.js"; import { n as ensureExplicitGatewayAuth } from "./client-bootstrap-BE0hdooM.js"; import "./call-BWR5pPgw.js"; import { n as probeGateway } from "./probe-BllKXy3J.js"; import { n as parseTimeoutMsWithFallback } from "./parse-timeout-BhPKqfrV.js"; import { r as withProgress } from "./progress-DSeqxbrQ.js"; import { a as resolveWideAreaDiscoveryDomain } from "./widearea-dns-rDnHQdmm.js"; import { t as discoverGatewayBeacons } from "./bonjour-discovery-BY76Gpf8.js"; import { n as buildGatewayDiscoveryTarget, r as serializeGatewayDiscoveryBeacon } from "./gateway-discovery-targets-DNcma2ND.js"; import { a as isProbeReachable, c as renderTargetHeader, d as resolveTargets, f as sanitizeSshTarget, i as isPostConnectProbeFailure, l as resolveAuthForTarget, n as buildNetworkHints, o as isScopeLimitedProbeFailure, p as summarizeGatewayProbeCapability, r as extractConfigSummary, s as renderProbeSummaryLine, t as pickGatewaySelfPresence, u as resolveProbeBudgetMs } from "./gateway-presence-DlIaPPOS.js"; //#region src/commands/gateway-status/discovery.ts /** Discovery helpers for turning gateway remote URLs and Bonjour beacons into SSH targets. */ /** Infers a user@host SSH target from a configured remote websocket URL. */ function inferSshTargetFromRemoteUrl(rawUrl) { if (typeof rawUrl !== "string") return null; const trimmed = normalizeOptionalString(rawUrl) ?? ""; if (!trimmed) return null; let host; try { host = new URL(trimmed).hostname || null; } catch { return null; } if (!host) return null; const user = normalizeOptionalString(process.env.USER) ?? ""; return user ? `${user}@${host}` : host; } function buildSshTarget(input) { const host = normalizeOptionalString(input.host) ?? ""; if (!host) return null; const user = normalizeOptionalString(input.user) ?? ""; const base = user ? `${user}@${host}` : host; const port = input.port ?? 22; if (port && port !== 22) return `${base}:${port}`; return base; } /** Resolves an SSH target through ssh-config while preserving explicit identity choices. */ async function resolveSshTarget(params) { const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([params.loadSshConfigModule(), params.loadSshTunnelModule()]); const parsed = parseSshTarget(params.rawTarget); if (!parsed) return null; const config = await resolveSshConfig(parsed, { identity: params.identity ?? void 0, timeoutMs: Math.min(800, params.overallTimeoutMs) }); if (!config) return { target: params.rawTarget, identity: params.identity ?? void 0 }; const target = buildSshTarget({ user: config.user ?? parsed.user, host: config.host ?? parsed.host, port: config.port ?? parsed.port }); if (!target) return { target: params.rawTarget, identity: params.identity ?? void 0 }; return { target, identity: params.identity ?? config.identityFiles.find((entry) => normalizeOptionalString(entry)) ?? void 0 }; } /** Picks the first Bonjour-derived SSH target that parses as a valid tunnel target. */ function pickAutoSshTargetFromDiscovery(params) { for (const beacon of params.discovery) { const sshTarget = buildGatewayDiscoveryTarget(beacon, { sshUser: params.sshUser ?? void 0 }).sshTarget; if (!sshTarget) continue; if (params.parseSshTarget(sshTarget)) return sshTarget; } return null; } //#endregion //#region src/commands/gateway-status/output.ts /** Text and JSON rendering for the gateway status command. */ const noReachableGatewayDiagnostic = "No gateway answered any probe and Bonjour discovery returned no local gateways. Run `openclaw gateway status --deep --require-rpc` to inspect service state, config paths, listener owners, and logs; include `ss -ltnp` or `lsof -nP -iTCP:<port> -sTCP:LISTEN` for the configured port when filing a report."; function gatewaySelfIdentityKey(entry) { if (!entry.self) return null; const host = typeof entry.self.host === "string" ? entry.self.host.trim().toLowerCase() : ""; const ip = typeof entry.self.ip === "string" ? entry.self.ip.trim().toLowerCase() : ""; const discriminator = typeof entry.self.instanceId === "string" && entry.self.instanceId.trim() ? `instance:${entry.self.instanceId.trim().toLowerCase()}` : typeof entry.self.deviceId === "string" && entry.self.deviceId.trim() ? `device:${entry.self.deviceId.trim().toLowerCase()}` : ""; if (!host && !ip || !discriminator) return null; return `${host}\0${ip}\0${discriminator}`; } function hasMultipleReachableGatewayIdentities(reachable) { if (reachable.length <= 1) return false; const identityKeys = reachable.map((entry) => gatewaySelfIdentityKey(entry)); if (identityKeys.some((key) => key === null)) return true; return new Set(identityKeys).size > 1; } /** Chooses the reachable target that best represents the user's requested gateway. */ function pickPrimaryProbedTarget(probed) { const reachable = probed.filter((entry) => isProbeReachable(entry.probe)); return reachable.find((entry) => entry.target.kind === "explicit") ?? reachable.find((entry) => entry.target.kind === "sshTunnel") ?? reachable.find((entry) => entry.target.kind === "configRemote") ?? reachable.find((entry) => entry.target.kind === "localLoopback") ?? null; } /** Builds operator-facing warnings from probe, discovery, and SSH tunnel results. */ function buildGatewayStatusWarnings(params) { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const degradedScopeLimited = params.probed.filter((entry) => isScopeLimitedProbeFailure(entry.probe)); const degradedDetailFailed = params.probed.filter((entry) => isPostConnectProbeFailure(entry.probe) && !isScopeLimitedProbeFailure(entry.probe)); const warnings = []; if (params.sshTarget && !params.sshTunnelStarted) warnings.push({ code: "ssh_tunnel_failed", message: params.sshTunnelError ? `SSH tunnel failed: ${params.sshTunnelError}` : "SSH tunnel failed to start; falling back to direct probes." }); if (params.localTlsLoadError) warnings.push({ code: "local_tls_runtime_unavailable", message: `Local gateway TLS is enabled but OpenClaw could not load the local certificate fingerprint: ${params.localTlsLoadError}`, targetIds: ["localLoopback"] }); if (reachable.length === 0 && params.discoveryCount === 0) warnings.push({ code: "no_gateway_reachable", message: noReachableGatewayDiagnostic, targetIds: params.probed.map((entry) => entry.target.id) }); if (hasMultipleReachableGatewayIdentities(reachable)) warnings.push({ code: "multiple_gateways", message: "Unconventional setup: multiple reachable gateway identities detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).", targetIds: reachable.map((entry) => entry.target.id) }); for (const result of params.probed) { if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) continue; for (const diagnostic of result.authDiagnostics) warnings.push({ code: "auth_secretref_unresolved", message: diagnostic, targetIds: [result.target.id] }); } for (const result of degradedScopeLimited) warnings.push({ code: "probe_scope_limited", message: "Read-probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but read-only status calls are incomplete. Hint: pair device identity or use credentials with operator.read.", targetIds: [result.target.id] }); for (const result of degradedDetailFailed) { const detail = result.probe.error ? `: ${result.probe.error}` : "."; warnings.push({ code: "probe_detail_failed", message: `Gateway accepted the WebSocket connection, but follow-up read diagnostics failed${detail}`, targetIds: [result.target.id] }); } return warnings; } /** Writes the machine-readable gateway status payload and exits nonzero when unreachable. */ function writeGatewayStatusJson(params) { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const degraded = params.probed.some((entry) => isPostConnectProbeFailure(entry.probe)); const capability = summarizeGatewayProbeCapability(reachable.map((entry) => entry.probe)); writeRuntimeJson(params.runtime, { ok: reachable.length > 0, degraded, capability, ts: Date.now(), durationMs: Date.now() - params.startedAt, timeoutMs: params.overallTimeoutMs, primaryTargetId: params.primaryTargetId, warnings: params.warnings, network: params.network, discovery: { timeoutMs: params.discoveryTimeoutMs, count: params.discovery.length, beacons: params.discovery.map((beacon) => serializeGatewayDiscoveryBeacon(beacon)) }, targets: params.probed.map((entry) => ({ id: entry.target.id, kind: entry.target.kind, url: entry.target.url, active: entry.target.active, tunnel: entry.target.tunnel ?? null, connect: { ok: isProbeReachable(entry.probe), rpcOk: entry.probe.ok, scopeLimited: isScopeLimitedProbeFailure(entry.probe), latencyMs: entry.probe.connectLatencyMs, error: entry.probe.error, close: entry.probe.close }, auth: entry.probe.auth, server: entry.probe.server, self: entry.self, config: entry.configSummary, health: entry.probe.health, summary: entry.probe.status, presence: entry.probe.presence })) }); if (reachable.length === 0) params.runtime.exit(1); } /** Writes the human-readable gateway status report and exits nonzero when unreachable. */ function writeGatewayStatusText(params) { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const ok = reachable.length > 0; const capability = summarizeGatewayProbeCapability(reachable.map((entry) => entry.probe)); params.runtime.log(colorize(params.rich, theme.heading, "Gateway Status")); params.runtime.log(ok ? `${colorize(params.rich, theme.success, "Reachable")}: yes` : `${colorize(params.rich, theme.error, "Reachable")}: no`); params.runtime.log(`${colorize(params.rich, theme.info, "Capability")}: ${capability.replaceAll("_", "-")}`); params.runtime.log(colorize(params.rich, theme.muted, `Probe budget: ${params.overallTimeoutMs}ms`)); if (params.warnings.length > 0) { params.runtime.log(""); params.runtime.log(colorize(params.rich, theme.warn, "Warning:")); for (const warning of params.warnings) { params.runtime.log(`- ${warning.message}`); for (const detail of warning.details ?? []) params.runtime.log(` ${detail}`); } } params.runtime.log(""); params.runtime.log(colorize(params.rich, theme.heading, "Discovery (this machine)")); const discoveryDomains = params.wideAreaDomain ? `local. + ${params.wideAreaDomain}` : "local."; params.runtime.log(params.discovery.length > 0 ? `Found ${params.discovery.length} gateway(s) via Bonjour (${discoveryDomains})` : `Found 0 gateways via Bonjour (${discoveryDomains})`); if (params.discovery.length === 0) params.runtime.log(colorize(params.rich, theme.muted, "Tip: if the gateway is remote, mDNS won’t cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.")); params.runtime.log(""); params.runtime.log(colorize(params.rich, theme.heading, "Targets")); for (const result of params.probed) { params.runtime.log(renderTargetHeader(result.target, params.rich)); params.runtime.log(` ${renderProbeSummaryLine(result.probe, params.rich)}`); if (result.target.tunnel?.kind === "ssh") params.runtime.log(` ${colorize(params.rich, theme.muted, "ssh")}: ${colorize(params.rich, theme.command, result.target.tunnel.target)}`); if (result.probe.ok && result.self) { const host = result.self.host ?? "unknown"; const ip = result.self.ip ? ` (${result.self.ip})` : ""; const platform = result.self.platform ? ` · ${result.self.platform}` : ""; const version = result.self.version ? ` · app ${result.self.version}` : ""; params.runtime.log(` ${colorize(params.rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`); } if (result.configSummary) { const wideArea = result.configSummary.discovery.wideAreaEnabled === true ? "enabled" : result.configSummary.discovery.wideAreaEnabled === false ? "disabled" : "unknown"; params.runtime.log(` ${colorize(params.rich, theme.info, "Wide-area discovery")}: ${wideArea}`); } params.runtime.log(""); } if (!ok) params.runtime.exit(1); } //#endregion //#region src/commands/gateway-status/probe-run.ts /** Runs gateway discovery, optional SSH tunneling, and per-target probes. */ /** Probes configured, explicit, and optionally SSH-discovered gateway targets. */ async function runGatewayStatusProbePass(params) { const discoveryPromise = discoverGatewayBeacons({ timeoutMs: params.discoveryTimeoutMs, wideAreaDomain: params.wideAreaDomain }); let sshTarget = params.sshTarget; let sshTunnelError = null; let sshTunnelStarted = false; const tryStartTunnel = async () => { if (!sshTarget) return null; if (params.signal?.aborted) { sshTunnelError = "Aborted"; return null; } try { const { startSshPortForward } = await params.loadSshTunnelModule(); const tunnel = await startSshPortForward({ target: sshTarget, identity: params.sshIdentity ?? void 0, localPortPreferred: params.remotePort, remotePort: params.remotePort, timeoutMs: Math.min(1500, params.overallTimeoutMs), signal: params.signal }); sshTunnelStarted = true; return tunnel; } catch (err) { if (isAbortError(err)) { sshTunnelError = "Aborted"; return null; } sshTunnelError = formatErrorMessage(err); return null; } }; const discoveryTask = discoveryPromise.catch(() => []); const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null); const [discovery, tunnelFirst] = await Promise.all([discoveryTask, tunnelTask]); if (!sshTarget && params.opts.sshAuto) { const { parseSshTarget } = await params.loadSshTunnelModule(); sshTarget = pickAutoSshTargetFromDiscovery({ discovery, parseSshTarget, sshUser: normalizeOptionalString(process.env.USER) ?? "" }); } const tunnel = tunnelFirst || (sshTarget && !sshTunnelStarted && !sshTunnelError ? await tryStartTunnel() : null); const tunnelTarget = tunnel ? { id: "sshTunnel", kind: "sshTunnel", url: `ws://127.0.0.1:${tunnel.localPort}`, active: true, tunnel: { kind: "ssh", target: sshTarget ?? "", localPort: tunnel.localPort, remotePort: params.remotePort, pid: tunnel.pid } } : null; const targets = tunnelTarget ? [tunnelTarget, ...params.baseTargets.filter((target) => target.url !== tunnelTarget.url)] : params.baseTargets; try { return { discovery, probed: await Promise.all(targets.map(async (target) => { const authResolution = await resolveAuthForTarget(params.cfg, target, { token: readStringValue(params.opts.token), password: readStringValue(params.opts.password) }); const probe = await probeGateway({ url: target.url, config: params.cfg, ...target.kind === "sshTunnel" ? { suppressStoredDeviceAuth: true } : target.kind !== "localLoopback" ? { originScopedDeviceAuth: true } : {}, auth: { token: authResolution.token, password: authResolution.password }, tlsFingerprint: target.kind === "localLoopback" && target.url.startsWith("wss://") ? params.localTlsFingerprint : void 0, timeoutMs: resolveProbeBudgetMs(params.overallTimeoutMs, target), signal: params.signal }); return { target, probe, configSummary: probe.configSnapshot ? extractConfigSummary(probe.configSnapshot) : null, self: pickGatewaySelfPresence(probe.presence), authDiagnostics: authResolution.diagnostics ?? [] }; })), sshTarget, sshTunnelStarted, sshTunnelError }; } finally { if (tunnel) try { await tunnel.stop(); } catch {} } } //#endregion //#region src/commands/gateway-status.ts /** CLI entrypoint for `openclaw gateway probe`. */ const sshConfigModuleLoader = createLazyImportLoader(() => import("./ssh-config-BG7rUG-w.js")); const sshTunnelModuleLoader = createLazyImportLoader(() => import("./ssh-tunnel-Cb_TZ48Q.js")); const gatewayTlsModuleLoader = createLazyImportLoader(() => import("./gateway-DrcLSDwB.js")); function loadSshConfigModule() { return sshConfigModuleLoader.load(); } function loadSshTunnelModule() { return sshTunnelModuleLoader.load(); } function loadGatewayTlsModule() { return gatewayTlsModuleLoader.load(); } /** Resolves gateway status inputs, probes targets, then writes JSON or text output. */ async function gatewayStatusCommand(opts, runtime) { ensureExplicitGatewayAuth({ urlOverride: opts.url?.trim(), urlOverrideSource: "cli", explicitAuth: resolveExplicitGatewayAuth(opts), errorHint: "Fix: pass --token or --password with --url." }); const startedAt = Date.now(); const cfg = await readBestEffortConfig(); const rich = isRich() && opts.json !== true; const overallTimeoutMs = parseTimeoutMsWithFallback(opts.timeout, 3e3, { invalidType: "error" }); const portOverride = parseGatewayPortOption(opts.port); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain }); const baseTargets = resolveTargets(cfg, opts.url, portOverride); const network = buildNetworkHints(cfg, portOverride); const remotePort = portOverride ?? resolveGatewayPort(cfg); const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs); const hasExplicitUrl = typeof opts.url === "string" && opts.url.trim().length > 0; const useConfiguredRemoteTargets = portOverride === void 0 || hasExplicitUrl; let sshTarget = sanitizeSshTarget(opts.ssh) ?? (useConfiguredRemoteTargets ? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget) : null); let sshIdentity = sanitizeSshTarget(opts.sshIdentity) ?? (useConfiguredRemoteTargets ? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity) : null); if (!sshTarget && useConfiguredRemoteTargets) sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url); if (sshTarget) { const resolved = await resolveSshTarget({ rawTarget: sshTarget, identity: sshIdentity, overallTimeoutMs, loadSshConfigModule, loadSshTunnelModule }); if (resolved) { sshTarget = resolved.target; if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity; } } const localCertificate = cfg.gateway?.tls?.enabled === true ? await loadGatewayTlsModule().then(({ inspectGatewayTlsCertificate }) => inspectGatewayTlsCertificate(cfg.gateway?.tls)) : void 0; const controller = new AbortController(); let abortSignal; const onSigInt = () => { if (!abortSignal) { abortSignal = "SIGINT"; controller.abort(); } }; const onSigTerm = () => { if (!abortSignal) { abortSignal = "SIGTERM"; controller.abort(); } }; process.on("SIGINT", onSigInt); process.on("SIGTERM", onSigTerm); const probePass = await (async () => { try { return await withProgress({ label: "Inspecting gateways…", indeterminate: true, enabled: opts.json !== true }, async () => await runGatewayStatusProbePass({ cfg, opts, overallTimeoutMs, discoveryTimeoutMs, wideAreaDomain, baseTargets, remotePort, sshTarget, sshIdentity, loadSshTunnelModule, localTlsFingerprint: localCertificate?.ok ? localCertificate.value.fingerprintSha256 : void 0, signal: controller.signal })); } finally { process.off("SIGINT", onSigInt); process.off("SIGTERM", onSigTerm); } })(); if (abortSignal) { runtime.exit(abortSignal === "SIGINT" ? 130 : 143); return; } const warnings = buildGatewayStatusWarnings({ probed: probePass.probed, sshTarget: probePass.sshTarget, sshTunnelStarted: probePass.sshTunnelStarted, sshTunnelError: probePass.sshTunnelError, discoveryCount: probePass.discovery.length, localTlsLoadError: localCertificate && !localCertificate.ok ? localCertificate.error : null }); const primary = pickPrimaryProbedTarget(probePass.probed); if (opts.json) { writeGatewayStatusJson({ runtime, startedAt, overallTimeoutMs, discoveryTimeoutMs, network, discovery: probePass.discovery, probed: probePass.probed, warnings, primaryTargetId: primary?.target.id ?? null }); return; } writeGatewayStatusText({ runtime, rich, overallTimeoutMs, wideAreaDomain, discovery: probePass.discovery, probed: probePass.probed, warnings }); } //#endregion export { gatewayStatusCommand };