diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 7cc895df499..b44ec5468f9 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { CanvasHostServer } from "../canvas-host/server.js"; +import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import type { RuntimeEnv } from "../runtime.js"; import type { ControlUiRootState } from "./control-ui.js"; @@ -31,7 +32,7 @@ import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { logAcceptedEnvOption } from "../infra/env.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; -import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; +import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; @@ -161,6 +162,9 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + const minimalTestGateway = + process.env.VITEST === "1" && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1"; + // Ensure all default port derivations (browser/canvas) see the actual runtime port. process.env.OPENCLAW_GATEWAY_PORT = String(port); logAcceptedEnvOption({ @@ -235,13 +239,30 @@ export async function startGatewayServer( 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 emptyPluginRegistry: PluginRegistry = { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; + const { pluginRegistry, gatewayMethods: baseGatewayMethods } = minimalTestGateway + ? { pluginRegistry: emptyPluginRegistry, gatewayMethods: baseMethods } + : loadGatewayPlugins({ + cfg: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayHandlers, + baseMethods, + }); const channelLogs = Object.fromEntries( listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), ) as Record>; @@ -402,91 +423,116 @@ export async function startGatewayServer( 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, - wideAreaDiscoveryDomain: cfgAtStart.discovery?.wideArea?.domain, - tailscaleMode, - mdnsMode: cfgAtStart.discovery?.mdns?.mode, - logDiscovery, - }); - bonjourStop = discovery.bonjourStop; + if (!minimalTestGateway) { + 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, + wideAreaDiscoveryDomain: cfgAtStart.discovery?.wideArea?.domain, + tailscaleMode, + mdnsMode: cfgAtStart.discovery?.mdns?.mode, + logDiscovery, + }); + bonjourStop = discovery.bonjourStop; + } - setSkillsRemoteRegistry(nodeRegistry); - void primeRemoteSkillsCache(); + if (!minimalTestGateway) { + setSkillsRemoteRegistry(nodeRegistry); + void primeRemoteSkillsCache(); + } // Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes. // Skills changes can happen in bursts (e.g., file watcher events), and each probe // takes time to complete. A 30-second delay ensures we batch changes together. let skillsRefreshTimer: ReturnType | null = null; const skillsRefreshDelayMs = 30_000; - const skillsChangeUnsub = registerSkillsChangeListener((event) => { - if (event.reason === "remote-node") { - return; - } - if (skillsRefreshTimer) { - clearTimeout(skillsRefreshTimer); - } - skillsRefreshTimer = setTimeout(() => { - skillsRefreshTimer = null; - const latest = loadConfig(); - void refreshRemoteBinsForConnectedNodes(latest); - }, skillsRefreshDelayMs); - }); + const skillsChangeUnsub = minimalTestGateway + ? () => {} + : registerSkillsChangeListener((event) => { + if (event.reason === "remote-node") { + return; + } + if (skillsRefreshTimer) { + clearTimeout(skillsRefreshTimer); + } + skillsRefreshTimer = setTimeout(() => { + skillsRefreshTimer = null; + const latest = loadConfig(); + void refreshRemoteBinsForConnectedNodes(latest); + }, skillsRefreshDelayMs); + }); - const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ - broadcast, - nodeSendToAllSubscribed, - getPresenceVersion, - getHealthVersion, - refreshGatewayHealthSnapshot, - logHealth, - dedupe, - chatAbortControllers, - chatRunState, - chatRunBuffers, - chatDeltaSentAt, - removeChatRun, - agentRunSeq, - nodeSendToSession, - }); - - const agentUnsub = onAgentEvent( - createAgentEventHandler({ + const noopInterval = () => setInterval(() => {}, 1 << 30); + let tickInterval = noopInterval(); + let healthInterval = noopInterval(); + let dedupeCleanup = noopInterval(); + if (!minimalTestGateway) { + ({ tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ broadcast, - broadcastToConnIds, - nodeSendToSession, - agentRunSeq, + nodeSendToAllSubscribed, + getPresenceVersion, + getHealthVersion, + refreshGatewayHealthSnapshot, + logHealth, + dedupe, + chatAbortControllers, chatRunState, - resolveSessionKeyForRun, - clearAgentRunContext, - toolEventRecipients, - }), - ); + chatRunBuffers, + chatDeltaSentAt, + removeChatRun, + agentRunSeq, + nodeSendToSession, + })); + } - const heartbeatUnsub = onHeartbeatEvent((evt) => { - broadcast("heartbeat", evt, { dropIfSlow: true }); - }); + const agentUnsub = minimalTestGateway + ? null + : onAgentEvent( + createAgentEventHandler({ + broadcast, + broadcastToConnIds, + nodeSendToSession, + agentRunSeq, + chatRunState, + resolveSessionKeyForRun, + clearAgentRunContext, + toolEventRecipients, + }), + ); - let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart }); + const heartbeatUnsub = minimalTestGateway + ? null + : onHeartbeatEvent((evt) => { + broadcast("heartbeat", evt, { dropIfSlow: true }); + }); - void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); + let heartbeatRunner: HeartbeatRunner = minimalTestGateway + ? { + stop: () => {}, + updateConfig: () => {}, + } + : startHeartbeatRunner({ cfg: cfgAtStart }); + + if (!minimalTestGateway) { + void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); + } // Recover pending outbound deliveries from previous crash/restart. - void (async () => { - const { recoverPendingDeliveries } = await import("../infra/outbound/delivery-queue.js"); - const { deliverOutboundPayloads } = await import("../infra/outbound/deliver.js"); - const logRecovery = log.child("delivery-recovery"); - await recoverPendingDeliveries({ - deliver: deliverOutboundPayloads, - log: logRecovery, - cfg: cfgAtStart, - }); - })().catch((err) => log.error(`Delivery recovery failed: ${String(err)}`)); + if (!minimalTestGateway) { + void (async () => { + const { recoverPendingDeliveries } = await import("../infra/outbound/delivery-queue.js"); + const { deliverOutboundPayloads } = await import("../infra/outbound/deliver.js"); + const logRecovery = log.child("delivery-recovery"); + await recoverPendingDeliveries({ + deliver: deliverOutboundPayloads, + log: logRecovery, + cfg: cfgAtStart, + }); + })().catch((err) => log.error(`Delivery recovery failed: ${String(err)}`)); + } const execApprovalManager = new ExecApprovalManager(); const execApprovalForwarder = createExecApprovalForwarder(); @@ -564,30 +610,36 @@ export async function startGatewayServer( log, isNixMode, }); - scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); - const tailscaleCleanup = await startGatewayTailscaleExposure({ - tailscaleMode, - resetOnExit: tailscaleConfig.resetOnExit, - port, - controlUiBasePath, - logTailscale, - }); + if (!minimalTestGateway) { + scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); + } + const tailscaleCleanup = minimalTestGateway + ? null + : 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, - })); + if (!minimalTestGateway) { + ({ browserControl, pluginServices } = await startGatewaySidecars({ + cfg: cfgAtStart, + pluginRegistry, + defaultWorkspaceDir, + deps, + startChannels, + log, + logHooks, + logChannels, + logBrowser, + })); + } // Run gateway_start plugin hook (fire-and-forget) - { + if (!minimalTestGateway) { const hookRunner = getGlobalHookRunner(); if (hookRunner?.hasHooks("gateway_start")) { void hookRunner.runGatewayStart({ port }, { port }).catch((err) => { @@ -596,44 +648,48 @@ export async function startGatewayServer( } } - 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 = minimalTestGateway + ? { stop: async () => {} } + : (() => { + 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, - }); + return 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, + }); + })(); const close = createGatewayCloseHandler({ bonjourStop, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 849e4243555..29683bcafac 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -50,6 +50,10 @@ let previousSkipBrowserControl: string | undefined; let previousSkipGmailWatcher: string | undefined; let previousSkipCanvasHost: string | undefined; let previousBundledPluginsDir: string | undefined; +let previousSkipChannels: string | undefined; +let previousSkipProviders: string | undefined; +let previousSkipCron: string | undefined; +let previousMinimalGateway: string | undefined; let tempHome: string | undefined; let tempConfigRoot: string | undefined; @@ -90,6 +94,10 @@ async function setupGatewayTestHome() { previousSkipGmailWatcher = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; previousSkipCanvasHost = process.env.OPENCLAW_SKIP_CANVAS_HOST; previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; + previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; + previousSkipCron = process.env.OPENCLAW_SKIP_CRON; + previousMinimalGateway = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-home-")); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -101,6 +109,10 @@ function applyGatewaySkipEnv() { process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_PROVIDERS = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1"; process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempHome ? path.join(tempHome, "openclaw-test-no-bundled-extensions") : "openclaw-test-no-bundled-extensions"; @@ -203,6 +215,26 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { } else { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; } + if (previousSkipChannels === undefined) { + delete process.env.OPENCLAW_SKIP_CHANNELS; + } else { + process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels; + } + if (previousSkipProviders === undefined) { + delete process.env.OPENCLAW_SKIP_PROVIDERS; + } else { + process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders; + } + if (previousSkipCron === undefined) { + delete process.env.OPENCLAW_SKIP_CRON; + } else { + process.env.OPENCLAW_SKIP_CRON = previousSkipCron; + } + if (previousMinimalGateway === undefined) { + delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + } else { + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimalGateway; + } } if (options.restoreEnv && tempHome) { await fs.rm(tempHome, {