Files
clawdbot/src/gateway/server.impl.ts
2026-01-14 09:11:21 +00:00

500 lines
14 KiB
TypeScript

import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagent-registry.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 {
CONFIG_PATH_CLAWDBOT,
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.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 { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging.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 { startGatewayBridgeRuntime } from "./server-bridge-runtime.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, GATEWAY_METHODS } from "./server-methods-list.js";
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.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 { 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 { 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 logBridge = log.child("bridge");
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 logWsControl = log.child("ws");
const canvasRuntime = runtimeForLogger(logCanvas);
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [
plugin.id,
logChannels.child(plugin.id),
]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [
id,
runtimeForLogger(logger),
]),
) as Record<ChannelId, RuntimeEnv>;
const METHODS = GATEWAY_METHODS;
export type GatewayServer = {
close: (opts?: {
reason?: string;
restartExpectedMs?: number | null;
}) => Promise<void>;
};
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").BridgeBindMode;
/**
* 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;
/**
* 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<void>;
};
export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
// Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port.
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const 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 "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")}`,
);
}
}
const cfgAtStart = loadConfig();
initSubagentRegistry();
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(
cfgAtStart,
defaultAgentId,
);
const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({
cfg: cfgAtStart,
workspaceDir: defaultWorkspaceDir,
log,
coreGatewayHandlers,
baseMethods: METHODS,
});
let pluginServices: PluginServicesHandle | null = null;
const runtimeConfig = await resolveGatewayRuntimeConfig({
cfg: cfgAtStart,
port,
bind: opts.bind,
host: opts.host,
controlUiEnabled: opts.controlUiEnabled,
openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled,
auth: opts.auth,
tailscale: opts.tailscale,
});
const {
bindHost,
controlUiEnabled,
openAiChatCompletionsEnabled,
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 {
canvasHost,
httpServer,
wss,
clients,
broadcast,
agentRunSeq,
dedupe,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
addChatRun,
removeChatRun,
chatAbortControllers,
} = await createGatewayRuntimeState({
cfg: cfgAtStart,
bindHost,
port,
controlUiEnabled,
controlUiBasePath,
openAiChatCompletionsEnabled,
resolvedAuth,
hooksConfig: () => hooksConfig,
deps,
canvasRuntime,
canvasHostEnabled,
allowCanvasHostInTests: opts.allowCanvasHostInTests,
logCanvas,
logHooks,
});
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null =
null;
const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge);
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 bridgeRuntime = await startGatewayBridgeRuntime({
cfg: cfgAtStart,
port,
canvasHostEnabled,
canvasHost,
canvasRuntime,
allowCanvasHostInTests: opts.allowCanvasHostInTests,
machineDisplayName,
deps,
broadcast,
dedupe,
agentRunSeq,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
addChatRun,
removeChatRun,
chatAbortControllers,
getHealthCache,
refreshGatewayHealthSnapshot,
loadGatewayModelCatalog,
logBridge,
logCanvas,
logDiscovery,
});
bridge = bridgeRuntime.bridge;
const bridgeHost = bridgeRuntime.bridgeHost;
canvasHostServer = bridgeRuntime.canvasHostServer;
const nodePresenceTimers = bridgeRuntime.nodePresenceTimers;
bonjourStop = bridgeRuntime.bonjourStop;
const bridgeSendToSession = bridgeRuntime.bridgeSendToSession;
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
const { tickInterval, healthInterval, dedupeCleanup } =
startGatewayMaintenanceTimers({
broadcast,
bridgeSendToAllSubscribed,
getPresenceVersion,
getHealthVersion,
refreshGatewayHealthSnapshot,
logHealth,
dedupe,
chatAbortControllers,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
removeChatRun,
agentRunSeq,
bridgeSendToSession,
});
const agentUnsub = onAgentEvent(
createAgentEventHandler({
broadcast,
bridgeSendToSession,
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)}`));
attachGatewayWsHandlers({
wss,
clients,
port,
bridgeHost: bridgeHost ?? undefined,
canvasHostEnabled: Boolean(canvasHost),
canvasHostServerPort: canvasHostServer?.port ?? undefined,
resolvedAuth,
gatewayMethods,
events: GATEWAY_EVENTS,
logGateway: log,
logHealth,
logWsControl,
extraHandlers: pluginRegistry.gatewayHandlers,
broadcast,
context: {
deps,
cron,
cronStorePath,
loadGatewayModelCatalog,
getHealthCache,
refreshHealthSnapshot: refreshGatewayHealthSnapshot,
logHealth,
logGateway: log,
incrementPresenceVersion,
getHealthVersion,
broadcast,
bridge,
bridgeSendToSession,
hasConnectedMobileNode,
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,
log,
isNixMode,
});
const tailscaleCleanup = await startGatewayTailscaleExposure({
tailscaleMode,
resetOnExit: tailscaleConfig.resetOnExit,
port,
controlUiBasePath,
logTailscale,
});
let browserControl: Awaited<
ReturnType<typeof startBrowserControlServerIfEnabled>
> = 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,
bridge,
stopChannel,
pluginServices,
cron,
heartbeatRunner,
nodePresenceTimers,
broadcast,
tickInterval,
healthInterval,
dedupeCleanup,
agentUnsub,
heartbeatUnsub,
chatRunState,
clients,
configReloader,
browserControl,
wss,
httpServer,
});
return { close };
}