| import { |
| createConfigIO, |
| resolveConfigPath, |
| resolveGatewayPort, |
| resolveStateDir, |
| } from "../../config/config.js"; |
| import type { 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 { resolveGatewayService } from "../../daemon/service.js"; |
| import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; |
| import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; |
| import { resolveGatewayBindHost } from "../../gateway/net.js"; |
| import { |
| formatPortDiagnostics, |
| inspectPortUsage, |
| type PortListener, |
| type PortUsageStatus, |
| } from "../../infra/ports.js"; |
| import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; |
| import { probeGatewayStatus } from "./probe.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; |
| }; |
|
|
| export type DaemonStatus = { |
| service: { |
| label: string; |
| loaded: boolean; |
| loadedText: string; |
| notLoadedText: string; |
| command?: { |
| programArguments: string[]; |
| workingDirectory?: string; |
| environment?: Record<string, string>; |
| sourcePath?: string; |
| } | null; |
| runtime?: { |
| status?: string; |
| state?: string; |
| subState?: string; |
| pid?: number; |
| lastExitStatus?: number; |
| lastExitReason?: string; |
| lastRunResult?: string; |
| lastRunTime?: string; |
| detail?: string; |
| cachedLabel?: boolean; |
| missingUnit?: boolean; |
| }; |
| 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; |
| }; |
| 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; |
| } |
|
|
| export async function gatherDaemonStatus( |
| opts: { |
| rpc: GatewayRpcOpts; |
| probe: boolean; |
| deep?: boolean; |
| } & FindExtraGatewayServicesOptions, |
| ): Promise<DaemonStatus> { |
| const service = resolveGatewayService(); |
| const [loaded, command, runtime] = await Promise.all([ |
| service.isLoaded({ env: process.env }).catch(() => false), |
| service.readCommand(process.env).catch(() => null), |
| service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })), |
| ]); |
| const configAudit = await auditGatewayServiceConfig({ |
| env: process.env, |
| command, |
| }); |
|
|
| const serviceEnv = command?.environment ?? undefined; |
| const mergedDaemonEnv = { |
| ...(process.env as Record<string, string | undefined>), |
| ...(serviceEnv ?? undefined), |
| } satisfies Record<string, string | undefined>; |
|
|
| 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, |
| }; |
| const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; |
|
|
| const portFromArgs = parsePortFromArgs(command?.programArguments); |
| const daemonPort = portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv); |
| const portSource: GatewayStatusSummary["portSource"] = portFromArgs |
| ? "service args" |
| : "env/config"; |
|
|
| const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as |
| | "auto" |
| | "lan" |
| | "loopback" |
| | "custom" |
| | "tailnet"; |
| const customBindHost = daemonCfg.gateway?.customBindHost; |
| const bindHost = await resolveGatewayBindHost(bindMode, customBindHost); |
| const tailnetIPv4 = pickPrimaryTailnetIPv4(); |
| const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost); |
| const probeUrlOverride = |
| typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0 ? opts.rpc.url.trim() : null; |
| const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`; |
| const probeNote = |
| !probeUrlOverride && bindMode === "lan" |
| ? "Local probe uses loopback (127.0.0.1). bind=lan listens on 0.0.0.0 (all interfaces); use a LAN IP for remote clients." |
| : !probeUrlOverride && bindMode === "loopback" |
| ? "Loopback-only gateway; only local clients can connect." |
| : undefined; |
|
|
| const cliPort = resolveGatewayPort(cliCfg, process.env); |
| const [portDiagnostics, portCliDiagnostics] = await Promise.all([ |
| inspectPortUsage(daemonPort).catch(() => null), |
| cliPort !== daemonPort ? inspectPortUsage(cliPort).catch(() => null) : null, |
| ]); |
| const portStatus: DaemonStatus["port"] | undefined = portDiagnostics |
| ? { |
| port: portDiagnostics.port, |
| status: portDiagnostics.status, |
| listeners: portDiagnostics.listeners, |
| hints: portDiagnostics.hints, |
| } |
| : undefined; |
| const portCliStatus: DaemonStatus["portCli"] | undefined = portCliDiagnostics |
| ? { |
| port: portCliDiagnostics.port, |
| status: portCliDiagnostics.status, |
| listeners: portCliDiagnostics.listeners, |
| hints: portCliDiagnostics.hints, |
| } |
| : undefined; |
|
|
| const extraServices = await findExtraGatewayServices( |
| process.env as Record<string, string | undefined>, |
| { deep: Boolean(opts.deep) }, |
| ).catch(() => []); |
|
|
| const timeoutMsRaw = Number.parseInt(String(opts.rpc.timeout ?? "10000"), 10); |
| const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 10_000; |
|
|
| const rpc = opts.probe |
| ? await probeGatewayStatus({ |
| url: probeUrl, |
| token: |
| opts.rpc.token || |
| mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN || |
| daemonCfg.gateway?.auth?.token, |
| password: |
| opts.rpc.password || |
| mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD || |
| daemonCfg.gateway?.auth?.password, |
| timeoutMs, |
| json: opts.rpc.json, |
| configPath: daemonConfigSummary.path, |
| }) |
| : 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: { |
| bindMode, |
| bindHost, |
| customBindHost, |
| port: daemonPort, |
| portSource, |
| probeUrl, |
| ...(probeNote ? { probeNote } : {}), |
| }, |
| port: portStatus, |
| ...(portCliStatus ? { portCli: portCliStatus } : {}), |
| lastError, |
| ...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}), |
| 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; |
| } |
|
|