import { createConfigIO, resolveConfigPath, resolveGatewayPort, resolveStateDir, } from "../../config/config.js"; import type { BridgeBindMode, 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 { findLegacyGatewayServices } from "../../daemon/legacy.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: BridgeBindMode; 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; 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; }; legacyServices: Array<{ label: string; detail: 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 { 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), ...(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, }; 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"; 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 legacyServices = await findLegacyGatewayServices( process.env as Record, ).catch(() => []); const extraServices = await findExtraGatewayServices( process.env as Record, { 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.CLAWDBOT_GATEWAY_TOKEN || daemonCfg.gateway?.auth?.token, password: opts.rpc.password || mergedDaemonEnv.CLAWDBOT_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 } } : {}), legacyServices, 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; }