import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { registerSkillsChangeListener } from "../agents/skills/refresh.js"; import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { createDefaultDeps } from "../cli/deps.js"; import { formatCliCommand } from "../cli/command-format.js"; import { CONFIG_PATH_CLAWDBOT, isNixMode, loadConfig, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { primeRemoteSkillsCache, refreshRemoteBinsForConnectedNodes, setSkillsRemoteRegistry, } from "../infra/skills-remote.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import type { RuntimeEnv } from "../runtime.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; import { startGatewayConfigReloader } from "./config-reload.js"; import { getHealthCache, getHealthVersion, getPresenceVersion, incrementPresenceVersion, refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { startGatewayDiscovery } from "./server-discovery-runtime.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; import { createExecApprovalHandlers } from "./server-methods/exec-approval.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; import { createAgentEventHandler } from "./server-chat.js"; import { createGatewayCloseHandler } from "./server-close.js"; import { buildGatewayCronService } from "./server-cron.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; import { coreGatewayHandlers } from "./server-methods.js"; import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { NodeRegistry } from "./node-registry.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { safeParseJson } from "./server-methods/nodes.helpers.js"; import { loadGatewayPlugins } from "./server-plugins.js"; import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; import { hasConnectedMobileNode } from "./server-mobile-nodes.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { startGatewaySidecars } from "./server-startup.js"; import { logGatewayStartup } from "./server-startup-log.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; import { attachGatewayWsHandlers } from "./server-ws-runtime.js"; export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; ensureClawdbotCliOnPath(); const log = createSubsystemLogger("gateway"); const logCanvas = log.child("canvas"); const logDiscovery = log.child("discovery"); const logTailscale = log.child("tailscale"); const logChannels = log.child("channels"); const logBrowser = log.child("browser"); const logHealth = log.child("health"); const logCron = log.child("cron"); const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logPlugins = log.child("plugins"); const logWsControl = log.child("ws"); const canvasRuntime = runtimeForLogger(logCanvas); export type GatewayServer = { close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise; }; export type GatewayServerOptions = { /** * Bind address policy for the Gateway WebSocket/HTTP server. * - loopback: 127.0.0.1 * - lan: 0.0.0.0 * - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10) * - auto: prefer tailnet, else LAN */ bind?: import("../config/config.js").GatewayBindMode; /** * Advanced override for the bind host, bypassing bind resolution. * Prefer `bind` unless you really need a specific address. */ host?: string; /** * If false, do not serve the browser Control UI. * Default: config `gateway.controlUi.enabled` (or true when absent). */ controlUiEnabled?: boolean; /** * If false, do not serve `POST /v1/chat/completions`. * Default: config `gateway.http.endpoints.chatCompletions.enabled` (or false when absent). */ openAiChatCompletionsEnabled?: boolean; /** * If false, do not serve `POST /v1/responses` (OpenResponses API). * Default: config `gateway.http.endpoints.responses.enabled` (or false when absent). */ openResponsesEnabled?: boolean; /** * Override gateway auth configuration (merges with config). */ auth?: import("../config/config.js").GatewayAuthConfig; /** * Override gateway Tailscale exposure configuration (merges with config). */ tailscale?: import("../config/config.js").GatewayTailscaleConfig; /** * Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it. */ allowCanvasHostInTests?: boolean; /** * Test-only: override the onboarding wizard runner. */ wizardRunner?: ( opts: import("../commands/onboard-types.js").OnboardOptions, runtime: import("../runtime.js").RuntimeEnv, prompter: import("../wizard/prompts.js").WizardPrompter, ) => Promise; }; export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { // Ensure all default port derivations (browser/canvas) see the actual runtime port. process.env.CLAWDBOT_GATEWAY_PORT = String(port); let configSnapshot = await readConfigFileSnapshot(); if (configSnapshot.legacyIssues.length > 0) { if (isNixMode) { throw new Error( "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", ); } const { config: migrated, changes } = migrateLegacyConfig(configSnapshot.parsed); if (!migrated) { throw new Error( `Legacy config entries detected but auto-migration failed. Run "${formatCliCommand("clawdbot doctor")}" to migrate.`, ); } await writeConfigFile(migrated); if (changes.length > 0) { log.info( `gateway: migrated legacy config entries:\n${changes .map((entry) => `- ${entry}`) .join("\n")}`, ); } } configSnapshot = await readConfigFileSnapshot(); if (configSnapshot.exists && !configSnapshot.valid) { const issues = configSnapshot.issues.length > 0 ? configSnapshot.issues .map((issue) => `${issue.path || ""}: ${issue.message}`) .join("\n") : "Unknown validation issue."; throw new Error( `Invalid config at ${configSnapshot.path}.\n${issues}\nRun "${formatCliCommand("clawdbot doctor")}" to repair, then retry.`, ); } const autoEnable = applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); if (autoEnable.changes.length > 0) { try { await writeConfigFile(autoEnable.config); log.info( `gateway: auto-enabled plugins:\n${autoEnable.changes .map((entry) => `- ${entry}`) .join("\n")}`, ); } catch (err) { log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`); } } const cfgAtStart = loadConfig(); const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); } setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const baseMethods = listGatewayMethods(); const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({ cfg: cfgAtStart, workspaceDir: defaultWorkspaceDir, log, coreGatewayHandlers, baseMethods, }); const channelLogs = Object.fromEntries( listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), ) as Record>; const channelRuntimeEnvs = Object.fromEntries( Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]), ) as Record; const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods])); let pluginServices: PluginServicesHandle | null = null; const runtimeConfig = await resolveGatewayRuntimeConfig({ cfg: cfgAtStart, port, bind: opts.bind, host: opts.host, controlUiEnabled: opts.controlUiEnabled, openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled, openResponsesEnabled: opts.openResponsesEnabled, auth: opts.auth, tailscale: opts.tailscale, }); const { bindHost, controlUiEnabled, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, controlUiBasePath, resolvedAuth, tailscaleConfig, tailscaleMode, } = runtimeConfig; let hooksConfig = runtimeConfig.hooksConfig; const canvasHostEnabled = runtimeConfig.canvasHostEnabled; const wizardRunner = opts.wizardRunner ?? runOnboardingWizard; const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker(); const deps = createDefaultDeps(); let canvasHostServer: CanvasHostServer | null = null; const gatewayTls = await loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls")); if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) { throw new Error(gatewayTls.error ?? "gateway tls: failed to enable"); } const { canvasHost, httpServer, wss, clients, broadcast, agentRunSeq, dedupe, chatRunState, chatRunBuffers, chatDeltaSentAt, addChatRun, removeChatRun, chatAbortControllers, } = await createGatewayRuntimeState({ cfg: cfgAtStart, bindHost, port, controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, resolvedAuth, gatewayTls, hooksConfig: () => hooksConfig, pluginRegistry, deps, canvasRuntime, canvasHostEnabled, allowCanvasHostInTests: opts.allowCanvasHostInTests, logCanvas, logHooks, logPlugins, }); let bonjourStop: (() => Promise) | null = null; const nodeRegistry = new NodeRegistry(); const nodePresenceTimers = new Map>(); const nodeSubscriptions = createNodeSubscriptionManager(); const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => { const payload = safeParseJson(opts.payloadJSON ?? null); nodeRegistry.sendEvent(opts.nodeId, opts.event, payload); }; const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) => nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent); const nodeSendToAllSubscribed = (event: string, payload: unknown) => nodeSubscriptions.sendToAllSubscribed(event, payload, nodeSendEvent); const nodeSubscribe = nodeSubscriptions.subscribe; const nodeUnsubscribe = nodeSubscriptions.unsubscribe; const nodeUnsubscribeAll = nodeSubscriptions.unsubscribeAll; const broadcastVoiceWakeChanged = (triggers: string[]) => { broadcast("voicewake.changed", { triggers }, { dropIfSlow: true }); }; const hasMobileNodeConnected = () => hasConnectedMobileNode(nodeRegistry); applyGatewayLaneConcurrency(cfgAtStart); let cronState = buildGatewayCronService({ cfg: cfgAtStart, deps, broadcast, }); let { cron, storePath: cronStorePath } = cronState; const channelManager = createChannelManager({ loadConfig, channelLogs, channelRuntimeEnvs, }); const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } = channelManager; const machineDisplayName = await getMachineDisplayName(); const discovery = await startGatewayDiscovery({ machineDisplayName, port, gatewayTls: gatewayTls.enabled ? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 } : undefined, wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true, logDiscovery, }); bonjourStop = discovery.bonjourStop; setSkillsRemoteRegistry(nodeRegistry); void primeRemoteSkillsCache(); registerSkillsChangeListener(() => { const latest = loadConfig(); void refreshRemoteBinsForConnectedNodes(latest); }); const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ broadcast, nodeSendToAllSubscribed, getPresenceVersion, getHealthVersion, refreshGatewayHealthSnapshot, logHealth, dedupe, chatAbortControllers, chatRunState, chatRunBuffers, chatDeltaSentAt, removeChatRun, agentRunSeq, nodeSendToSession, }); const agentUnsub = onAgentEvent( createAgentEventHandler({ broadcast, nodeSendToSession, agentRunSeq, chatRunState, resolveSessionKeyForRun, clearAgentRunContext, }), ); const heartbeatUnsub = onHeartbeatEvent((evt) => { broadcast("heartbeat", evt, { dropIfSlow: true }); }); let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart }); void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); const execApprovalManager = new ExecApprovalManager(); const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager); const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; attachGatewayWsHandlers({ wss, clients, port, gatewayHost: bindHost ?? undefined, canvasHostEnabled: Boolean(canvasHost), canvasHostServerPort, resolvedAuth, gatewayMethods, events: GATEWAY_EVENTS, logGateway: log, logHealth, logWsControl, extraHandlers: { ...pluginRegistry.gatewayHandlers, ...execApprovalHandlers, }, broadcast, context: { deps, cron, cronStorePath, loadGatewayModelCatalog, getHealthCache, refreshHealthSnapshot: refreshGatewayHealthSnapshot, logHealth, logGateway: log, incrementPresenceVersion, getHealthVersion, broadcast, nodeSendToSession, nodeSendToAllSubscribed, nodeSubscribe, nodeUnsubscribe, nodeUnsubscribeAll, hasConnectedMobileNode: hasMobileNodeConnected, nodeRegistry, agentRunSeq, chatAbortControllers, chatAbortedRuns: chatRunState.abortedRuns, chatRunBuffers: chatRunState.buffers, chatDeltaSentAt: chatRunState.deltaSentAt, addChatRun, removeChatRun, dedupe, wizardSessions, findRunningWizard, purgeWizardSession, getRuntimeSnapshot, startChannel, stopChannel, markChannelLoggedOut, wizardRunner, broadcastVoiceWakeChanged, }, }); logGatewayStartup({ cfg: cfgAtStart, bindHost, port, tlsEnabled: gatewayTls.enabled, log, isNixMode, }); scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); const tailscaleCleanup = await startGatewayTailscaleExposure({ tailscaleMode, resetOnExit: tailscaleConfig.resetOnExit, port, controlUiBasePath, logTailscale, }); let browserControl: Awaited> = null; ({ browserControl, pluginServices } = await startGatewaySidecars({ cfg: cfgAtStart, pluginRegistry, defaultWorkspaceDir, deps, startChannels, log, logHooks, logChannels, logBrowser, })); const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({ deps, broadcast, getState: () => ({ hooksConfig, heartbeatRunner, cronState, browserControl, }), setState: (nextState) => { hooksConfig = nextState.hooksConfig; heartbeatRunner = nextState.heartbeatRunner; cronState = nextState.cronState; cron = cronState.cron; cronStorePath = cronState.storePath; browserControl = nextState.browserControl; }, startChannel, stopChannel, logHooks, logBrowser, logChannels, logCron, logReload, }); const configReloader = startGatewayConfigReloader({ initialConfig: cfgAtStart, readSnapshot: readConfigFileSnapshot, onHotReload: applyHotReload, onRestart: requestGatewayRestart, log: { info: (msg) => logReload.info(msg), warn: (msg) => logReload.warn(msg), error: (msg) => logReload.error(msg), }, watchPath: CONFIG_PATH_CLAWDBOT, }); const close = createGatewayCloseHandler({ bonjourStop, tailscaleCleanup, canvasHost, canvasHostServer, stopChannel, pluginServices, cron, heartbeatRunner, nodePresenceTimers, broadcast, tickInterval, healthInterval, dedupeCleanup, agentUnsub, heartbeatUnsub, chatRunState, clients, configReloader, browserControl, wss, httpServer, }); return { close: async (opts) => { if (diagnosticsEnabled) { stopDiagnosticHeartbeat(); } await close(opts); }, }; }