import { createConfigIO, resolveConfigPath, resolveGatewayPort, resolveStateDir, } from "../../config/config.js"; import type { OpenClawConfig, GatewayBindMode, GatewayControlUiConfig, } from "../../config/types.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; import { findExtraGatewayServices } from "../../daemon/inspect.js"; import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; import { formatPortDiagnostics, inspectPortUsage, type PortListener, type PortUsageStatus, } from "../../infra/ports.js"; import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; import { loadGatewayTlsRuntime } from "../../infra/tls/gateway.js"; import { probeGatewayStatus } from "./probe.js"; import { inspectGatewayRestart } from "./restart-health.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; import type { GatewayRpcOpts } from "./types.js"; type ConfigSummary = { path: string; exists: boolean; valid: boolean; issues?: Array<{ path: string; message: string }>; controlUi?: GatewayControlUiConfig; }; type GatewayStatusSummary = { bindMode: GatewayBindMode; bindHost: string; customBindHost?: string; port: number; portSource: "service args" | "env/config"; probeUrl: string; probeNote?: string; }; type PortStatusSummary = { port: number; status: PortUsageStatus; listeners: PortListener[]; hints: string[]; }; type DaemonConfigContext = { mergedDaemonEnv: Record; cliCfg: OpenClawConfig; daemonCfg: OpenClawConfig; cliConfigSummary: ConfigSummary; daemonConfigSummary: ConfigSummary; configMismatch: boolean; }; type ResolvedGatewayStatus = { gateway: GatewayStatusSummary; daemonPort: number; cliPort: number; probeUrlOverride: string | null; }; export type DaemonStatus = { service: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; command?: { programArguments: string[]; workingDirectory?: string; environment?: Record; sourcePath?: string; } | null; runtime?: GatewayServiceRuntime; configAudit?: ServiceConfigAudit; }; config?: { cli: ConfigSummary; daemon?: ConfigSummary; mismatch?: boolean; }; gateway?: GatewayStatusSummary; port?: { port: number; status: PortUsageStatus; listeners: PortListener[]; hints: string[]; }; portCli?: { port: number; status: PortUsageStatus; listeners: PortListener[]; hints: string[]; }; lastError?: string; rpc?: { ok: boolean; error?: string; url?: string; authWarning?: string; }; health?: { healthy: boolean; staleGatewayPids: number[]; }; extraServices: Array<{ label: string; detail: string; scope: string }>; }; function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: boolean) { if (status !== "busy") { return false; } if (rpcOk === true) { return false; } return true; } function parseGatewaySecretRefPathFromError(error: unknown): string | null { return isGatewaySecretRefUnavailableError(error) ? error.path : null; } async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { const mergedDaemonEnv = { ...(process.env as Record), ...(serviceEnv ?? undefined), } satisfies Record; const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env)); const daemonConfigPath = resolveConfigPath( mergedDaemonEnv as NodeJS.ProcessEnv, resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), ); const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath }); const daemonIO = createConfigIO({ env: mergedDaemonEnv, configPath: daemonConfigPath, }); const [cliSnapshot, daemonSnapshot] = await Promise.all([ cliIO.readConfigFileSnapshot().catch(() => null), daemonIO.readConfigFileSnapshot().catch(() => null), ]); const cliCfg = cliIO.loadConfig(); const daemonCfg = daemonIO.loadConfig(); const cliConfigSummary: ConfigSummary = { path: cliSnapshot?.path ?? cliConfigPath, exists: cliSnapshot?.exists ?? false, valid: cliSnapshot?.valid ?? true, ...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}), controlUi: cliCfg.gateway?.controlUi, }; const daemonConfigSummary: ConfigSummary = { path: daemonSnapshot?.path ?? daemonConfigPath, exists: daemonSnapshot?.exists ?? false, valid: daemonSnapshot?.valid ?? true, ...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), controlUi: daemonCfg.gateway?.controlUi, }; return { mergedDaemonEnv, cliCfg, daemonCfg, cliConfigSummary, daemonConfigSummary, configMismatch: cliConfigSummary.path !== daemonConfigSummary.path, }; } async function resolveGatewayStatusSummary(params: { daemonCfg: OpenClawConfig; cliCfg: OpenClawConfig; mergedDaemonEnv: Record; commandProgramArguments?: string[]; rpcUrlOverride?: string; }): Promise { const portFromArgs = parsePortFromArgs(params.commandProgramArguments); const daemonPort = portFromArgs ?? resolveGatewayPort(params.daemonCfg, params.mergedDaemonEnv); const portSource: GatewayStatusSummary["portSource"] = portFromArgs ? "service args" : "env/config"; const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback"; const customBindHost = params.daemonCfg.gateway?.customBindHost; const bindHost = await resolveGatewayBindHost(bindMode, customBindHost); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost); const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null; const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws"; const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`; const probeNote = !probeUrlOverride && bindMode === "lan" ? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.` : !probeUrlOverride && bindMode === "loopback" ? "Loopback-only gateway; only local clients can connect." : undefined; return { gateway: { bindMode, bindHost, customBindHost, port: daemonPort, portSource, probeUrl, ...(probeNote ? { probeNote } : {}), }, daemonPort, cliPort: resolveGatewayPort(params.cliCfg, process.env), probeUrlOverride, }; } function toPortStatusSummary( diagnostics: Awaited> | null, ): PortStatusSummary | undefined { if (!diagnostics) { return undefined; } return { port: diagnostics.port, status: diagnostics.status, listeners: diagnostics.listeners, hints: diagnostics.hints, }; } async function inspectDaemonPortStatuses(params: { daemonPort: number; cliPort: number; }): Promise<{ portStatus?: PortStatusSummary; portCliStatus?: PortStatusSummary }> { const [portDiagnostics, portCliDiagnostics] = await Promise.all([ inspectPortUsage(params.daemonPort).catch(() => null), params.cliPort !== params.daemonPort ? inspectPortUsage(params.cliPort).catch(() => null) : null, ]); return { portStatus: toPortStatusSummary(portDiagnostics), portCliStatus: toPortStatusSummary(portCliDiagnostics), }; } export async function gatherDaemonStatus( opts: { rpc: GatewayRpcOpts; probe: boolean; deep?: boolean; } & FindExtraGatewayServicesOptions, ): Promise { const service = resolveGatewayService(); const command = await service.readCommand(process.env).catch(() => null); const serviceEnv = command?.environment ? ({ ...process.env, ...command.environment, } satisfies NodeJS.ProcessEnv) : process.env; const [loaded, runtime] = await Promise.all([ service.isLoaded({ env: serviceEnv }).catch(() => false), service.readRuntime(serviceEnv).catch((err) => ({ status: "unknown", detail: String(err) })), ]); const configAudit = await auditGatewayServiceConfig({ env: process.env, command, }); const { mergedDaemonEnv, cliCfg, daemonCfg, cliConfigSummary, daemonConfigSummary, configMismatch, } = await loadDaemonConfigContext(command?.environment); const { gateway, daemonPort, cliPort, probeUrlOverride } = await resolveGatewayStatusSummary({ cliCfg, daemonCfg, mergedDaemonEnv, commandProgramArguments: command?.programArguments, rpcUrlOverride: opts.rpc.url, }); const { portStatus, portCliStatus } = await inspectDaemonPortStatuses({ daemonPort, cliPort, }); const extraServices = await findExtraGatewayServices( process.env as Record, { deep: Boolean(opts.deep) }, ).catch(() => []); const timeoutMs = parseStrictPositiveInteger(opts.rpc.timeout ?? "10000") ?? 10_000; const tlsEnabled = daemonCfg.gateway?.tls?.enabled === true; const shouldUseLocalTlsRuntime = opts.probe && !probeUrlOverride && tlsEnabled; const tlsRuntime = shouldUseLocalTlsRuntime ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) : undefined; let daemonProbeAuth: { token?: string; password?: string } | undefined; let rpcAuthWarning: string | undefined; if (opts.probe) { try { daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ cfg: daemonCfg, mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", env: mergedDaemonEnv as NodeJS.ProcessEnv, explicitAuth: { token: opts.rpc.token, password: opts.rpc.password, }, }); } catch (error) { const refPath = parseGatewaySecretRefPathFromError(error); if (!refPath) { throw error; } daemonProbeAuth = undefined; rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; } } const rpc = opts.probe ? await probeGatewayStatus({ url: gateway.probeUrl, token: daemonProbeAuth?.token, password: daemonProbeAuth?.password, tlsFingerprint: shouldUseLocalTlsRuntime && tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined, timeoutMs, json: opts.rpc.json, configPath: daemonConfigSummary.path, }) : undefined; if (rpc?.ok) { rpcAuthWarning = undefined; } const health = opts.probe && loaded ? await inspectGatewayRestart({ service, port: daemonPort, env: serviceEnv, }).catch(() => undefined) : undefined; let lastError: string | undefined; if (loaded && runtime?.status === "running" && portStatus && portStatus.status !== "busy") { lastError = (await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ?? undefined; } return { service: { label: service.label, loaded, loadedText: service.loadedText, notLoadedText: service.notLoadedText, command, runtime, configAudit, }, config: { cli: cliConfigSummary, daemon: daemonConfigSummary, ...(configMismatch ? { mismatch: true } : {}), }, gateway, port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl, ...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}), }, } : {}), ...(health ? { health: { healthy: health.healthy, staleGatewayPids: health.staleGatewayPids, }, } : {}), extraServices, }; } export function renderPortDiagnosticsForCli(status: DaemonStatus, rpcOk?: boolean): string[] { if (!status.port || !shouldReportPortUsage(status.port.status, rpcOk)) { return []; } return formatPortDiagnostics({ port: status.port.port, status: status.port.status, listeners: status.port.listeners, hints: status.port.hints, }); } export function resolvePortListeningAddresses(status: DaemonStatus): string[] { const addrs = Array.from( new Set( status.port?.listeners ?.map((l) => (l.address ? normalizeListenerAddress(l.address) : "")) .filter((v): v is string => Boolean(v)) ?? [], ), ); return addrs; }