2026-02-03 13:56:20 -05:00
import path from "node:path" ;
2026-01-14 14:31:43 +00:00
import { resolveAgentWorkspaceDir , resolveDefaultAgentId } from "../agents/agent-scope.js" ;
2026-02-13 15:29:29 -08:00
import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/runs.js" ;
2026-01-16 03:45:03 +00:00
import { registerSkillsChangeListener } from "../agents/skills/refresh.js" ;
2026-02-01 10:03:47 +09:00
import { initSubagentRegistry } from "../agents/subagent-registry.js" ;
2026-02-13 15:29:29 -08:00
import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js" ;
2026-02-18 01:34:35 +00:00
import type { CanvasHostServer } from "../canvas-host/server.js" ;
2026-01-14 14:31:43 +00:00
import { type ChannelId , listChannelPlugins } from "../channels/plugins/index.js" ;
2026-01-20 07:42:21 +00:00
import { formatCliCommand } from "../cli/command-format.js" ;
2026-02-01 10:03:47 +09:00
import { createDefaultDeps } from "../cli/deps.js" ;
2026-02-19 10:00:27 +01:00
import { isRestartEnabled } from "../config/commands.js" ;
2026-01-14 01:08:15 +00:00
import {
2026-01-27 12:19:58 +00:00
CONFIG_PATH ,
2026-01-14 01:08:15 +00:00
isNixMode ,
loadConfig ,
migrateLegacyConfig ,
readConfigFileSnapshot ,
writeConfigFile ,
} from "../config/config.js" ;
2026-01-20 16:37:34 +00:00
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js" ;
2026-01-14 09:11:21 +00:00
import { clearAgentRunContext , onAgentEvent } from "../infra/agent-events.js" ;
2026-02-03 13:56:20 -05:00
import {
ensureControlUiAssetsBuilt ,
resolveControlUiRootOverrideSync ,
resolveControlUiRootSync ,
} from "../infra/control-ui-assets.js" ;
2026-02-01 10:03:47 +09:00
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js" ;
import { logAcceptedEnvOption } from "../infra/env.js" ;
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js" ;
2026-01-14 01:08:15 +00:00
import { onHeartbeatEvent } from "../infra/heartbeat-events.js" ;
2026-02-14 05:09:07 +00:00
import { startHeartbeatRunner , type HeartbeatRunner } from "../infra/heartbeat-runner.js" ;
2026-01-14 01:08:15 +00:00
import { getMachineDisplayName } from "../infra/machine-name.js" ;
2026-01-30 03:15:10 +01:00
import { ensureOpenClawCliOnPath } from "../infra/path-env.js" ;
2026-02-13 15:29:29 -08:00
import { setGatewaySigusr1RestartPolicy , setPreRestartDeferralCheck } from "../infra/restart.js" ;
2026-01-16 03:45:03 +00:00
import {
primeRemoteSkillsCache ,
refreshRemoteBinsForConnectedNodes ,
2026-01-19 04:50:07 +00:00
setSkillsRemoteRegistry ,
2026-01-16 03:45:03 +00:00
} from "../infra/skills-remote.js" ;
2026-01-17 12:07:14 +00:00
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js" ;
2026-01-21 00:29:42 +00:00
import { startDiagnosticHeartbeat , stopDiagnosticHeartbeat } from "../logging/diagnostic.js" ;
2026-01-18 23:25:04 +00:00
import { createSubsystemLogger , runtimeForLogger } from "../logging/subsystem.js" ;
2026-02-14 17:33:08 -05:00
import { getGlobalHookRunner , runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js" ;
2026-02-15 19:05:00 +00:00
import { createEmptyPluginRegistry } from "../plugins/registry.js" ;
2026-02-18 01:34:35 +00:00
import type { PluginServicesHandle } from "../plugins/services.js" ;
2026-02-13 15:29:29 -08:00
import { getTotalQueueSize } from "../process/command-queue.js" ;
2026-02-18 01:34:35 +00:00
import type { RuntimeEnv } from "../runtime.js" ;
2026-01-14 01:08:15 +00:00
import { runOnboardingWizard } from "../wizard/onboarding.js" ;
2026-02-13 15:32:38 +01:00
import { createAuthRateLimiter , type AuthRateLimiter } from "./auth-rate-limit.js" ;
2026-02-12 11:47:26 +07:00
import { startChannelHealthMonitor } from "./channel-health-monitor.js" ;
2026-01-14 09:11:21 +00:00
import { startGatewayConfigReloader } from "./config-reload.js" ;
2026-02-18 01:34:35 +00:00
import type { ControlUiRootState } from "./control-ui.js" ;
2026-02-19 10:00:27 +01:00
import {
GATEWAY_EVENT_UPDATE_AVAILABLE ,
type GatewayUpdateAvailableEventPayload ,
} from "./events.js" ;
2026-01-19 02:31:18 +00:00
import { ExecApprovalManager } from "./exec-approval-manager.js" ;
2026-02-01 10:03:47 +09:00
import { NodeRegistry } from "./node-registry.js" ;
2026-02-18 01:34:35 +00:00
import type { startBrowserControlServerIfEnabled } from "./server-browser.js" ;
2026-01-14 01:08:15 +00:00
import { createChannelManager } from "./server-channels.js" ;
2026-01-14 09:11:21 +00:00
import { createAgentEventHandler } from "./server-chat.js" ;
import { createGatewayCloseHandler } from "./server-close.js" ;
import { buildGatewayCronService } from "./server-cron.js" ;
2026-02-01 10:03:47 +09:00
import { startGatewayDiscovery } from "./server-discovery-runtime.js" ;
2026-01-14 09:11:21 +00:00
import { applyGatewayLaneConcurrency } from "./server-lanes.js" ;
import { startGatewayMaintenanceTimers } from "./server-maintenance.js" ;
2026-01-15 02:42:41 +00:00
import { GATEWAY_EVENTS , listGatewayMethods } from "./server-methods-list.js" ;
2026-02-01 10:03:47 +09:00
import { coreGatewayHandlers } from "./server-methods.js" ;
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js" ;
import { safeParseJson } from "./server-methods/nodes.helpers.js" ;
import { hasConnectedMobileNode } from "./server-mobile-nodes.js" ;
2026-01-14 09:11:21 +00:00
import { loadGatewayModelCatalog } from "./server-model-catalog.js" ;
2026-01-19 04:50:07 +00:00
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js" ;
2026-01-14 09:11:21 +00:00
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 { logGatewayStartup } from "./server-startup-log.js" ;
2026-02-01 10:03:47 +09:00
import { startGatewaySidecars } from "./server-startup.js" ;
2026-01-14 09:11:21 +00:00
import { startGatewayTailscaleExposure } from "./server-tailscale.js" ;
import { createWizardSessionTracker } from "./server-wizard-sessions.js" ;
import { attachGatewayWsHandlers } from "./server-ws-runtime.js" ;
2026-02-01 10:03:47 +09:00
import {
getHealthCache ,
getHealthVersion ,
getPresenceVersion ,
incrementPresenceVersion ,
refreshGatewayHealthSnapshot ,
} from "./server/health-state.js" ;
import { loadGatewayTlsRuntime } from "./server/tls.js" ;
2026-02-19 02:35:50 -05:00
import { ensureGatewayStartupAuth } from "./startup-auth.js" ;
2026-01-14 09:11:21 +00:00
export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js" ;
2026-01-14 01:08:15 +00:00
2026-01-30 03:15:10 +01:00
ensureOpenClawCliOnPath ( ) ;
2026-01-14 01:08:15 +00:00
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" ) ;
2026-01-15 05:03:50 +00:00
const logPlugins = log . child ( "plugins" ) ;
2026-01-14 01:08:15 +00:00
const logWsControl = log . child ( "ws" ) ;
2026-02-03 13:56:20 -05:00
const gatewayRuntime = runtimeForLogger ( log ) ;
2026-01-14 01:08:15 +00:00
const canvasRuntime = runtimeForLogger ( logCanvas ) ;
export type GatewayServer = {
2026-01-14 14:31:43 +00:00
close : ( opts ? : { reason? : string ; restartExpectedMs? : number | null } ) = > Promise < void > ;
2026-01-14 01:08:15 +00:00
} ;
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 )
2026-01-21 20:35:39 +00:00
* - auto : prefer loopback , else LAN
2026-01-14 01:08:15 +00:00
* /
2026-01-19 04:50:07 +00:00
bind? : import ( "../config/config.js" ) . GatewayBindMode ;
2026-01-14 01:08:15 +00:00
/ * *
* 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 ;
2026-01-19 10:44:48 +01:00
/ * *
* If false , do not serve ` POST /v1/responses ` ( OpenResponses API ) .
* Default : config ` gateway.http.endpoints.responses.enabled ` ( or false when absent ) .
* /
openResponsesEnabled? : boolean ;
2026-01-14 01:08:15 +00:00
/ * *
* 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 > {
2026-02-14 05:09:07 +00:00
const minimalTestGateway =
process . env . VITEST === "1" && process . env . OPENCLAW_TEST_MINIMAL_GATEWAY === "1" ;
2026-01-19 04:50:07 +00:00
// Ensure all default port derivations (browser/canvas) see the actual runtime port.
2026-01-30 03:15:10 +01:00
process . env . OPENCLAW_GATEWAY_PORT = String ( port ) ;
2026-01-25 10:22:47 +00:00
logAcceptedEnvOption ( {
2026-01-30 03:15:10 +01:00
key : "OPENCLAW_RAW_STREAM" ,
2026-01-25 10:22:47 +00:00
description : "raw stream logging enabled" ,
} ) ;
logAcceptedEnvOption ( {
2026-01-30 03:15:10 +01:00
key : "OPENCLAW_RAW_STREAM_PATH" ,
2026-01-25 10:22:47 +00:00
description : "raw stream log path override" ,
} ) ;
2026-01-14 01:08:15 +00:00
2026-01-17 10:25:24 +00:00
let configSnapshot = await readConfigFileSnapshot ( ) ;
2026-01-14 01:08:15 +00:00
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." ,
) ;
}
2026-01-14 14:31:43 +00:00
const { config : migrated , changes } = migrateLegacyConfig ( configSnapshot . parsed ) ;
2026-01-14 01:08:15 +00:00
if ( ! migrated ) {
throw new Error (
2026-01-30 03:15:10 +01:00
` Legacy config entries detected but auto-migration failed. Run " ${ formatCliCommand ( "openclaw doctor" ) } " to migrate. ` ,
2026-01-14 01:08:15 +00:00
) ;
}
await writeConfigFile ( migrated ) ;
if ( changes . length > 0 ) {
log . info (
` gateway: migrated legacy config entries: \ n ${ changes
. map ( ( entry ) = > ` - ${ entry } ` )
. join ( "\n" ) } ` ,
) ;
}
}
2026-01-17 10:25:24 +00:00
configSnapshot = await readConfigFileSnapshot ( ) ;
if ( configSnapshot . exists && ! configSnapshot . valid ) {
const issues =
configSnapshot . issues . length > 0
? configSnapshot . issues
. map ( ( issue ) = > ` ${ issue . path || "<root>" } : ${ issue . message } ` )
. join ( "\n" )
: "Unknown validation issue." ;
throw new Error (
2026-01-30 03:15:10 +01:00
` Invalid config at ${ configSnapshot . path } . \ n ${ issues } \ nRun " ${ formatCliCommand ( "openclaw doctor" ) } " to repair, then retry. ` ,
2026-01-17 10:25:24 +00:00
) ;
}
2026-01-20 16:37:34 +00:00
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 ) } ` ) ;
}
}
2026-02-19 02:35:50 -05:00
let cfgAtStart = loadConfig ( ) ;
const authBootstrap = await ensureGatewayStartupAuth ( {
cfg : cfgAtStart ,
env : process.env ,
authOverride : opts.auth ,
tailscaleOverride : opts.tailscale ,
persist : true ,
} ) ;
cfgAtStart = authBootstrap . cfg ;
if ( authBootstrap . generatedToken ) {
if ( authBootstrap . persistedGeneratedToken ) {
log . info (
"Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token)." ,
) ;
} else {
log . warn (
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token <token>`." ,
) ;
}
}
2026-01-21 00:29:42 +00:00
const diagnosticsEnabled = isDiagnosticsEnabled ( cfgAtStart ) ;
if ( diagnosticsEnabled ) {
startDiagnosticHeartbeat ( ) ;
}
2026-02-19 10:00:27 +01:00
setGatewaySigusr1RestartPolicy ( { allowExternal : isRestartEnabled ( cfgAtStart ) } ) ;
2026-02-13 15:29:29 -08:00
setPreRestartDeferralCheck (
( ) = > getTotalQueueSize ( ) + getTotalPendingReplies ( ) + getActiveEmbeddedRunCount ( ) ,
) ;
2026-01-14 01:08:15 +00:00
initSubagentRegistry ( ) ;
const defaultAgentId = resolveDefaultAgentId ( cfgAtStart ) ;
2026-01-14 14:31:43 +00:00
const defaultWorkspaceDir = resolveAgentWorkspaceDir ( cfgAtStart , defaultAgentId ) ;
2026-01-15 02:42:41 +00:00
const baseMethods = listGatewayMethods ( ) ;
2026-02-15 19:05:00 +00:00
const emptyPluginRegistry = createEmptyPluginRegistry ( ) ;
2026-02-14 05:09:07 +00:00
const { pluginRegistry , gatewayMethods : baseGatewayMethods } = minimalTestGateway
? { pluginRegistry : emptyPluginRegistry , gatewayMethods : baseMethods }
: loadGatewayPlugins ( {
cfg : cfgAtStart ,
workspaceDir : defaultWorkspaceDir ,
log ,
coreGatewayHandlers ,
baseMethods ,
} ) ;
2026-01-15 02:42:41 +00:00
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 channelMethods = listChannelPlugins ( ) . flatMap ( ( plugin ) = > plugin . gatewayMethods ? ? [ ] ) ;
const gatewayMethods = Array . from ( new Set ( [ . . . baseGatewayMethods , . . . channelMethods ] ) ) ;
2026-01-14 01:08:15 +00:00
let pluginServices : PluginServicesHandle | null = null ;
2026-01-14 09:11:21 +00:00
const runtimeConfig = await resolveGatewayRuntimeConfig ( {
cfg : cfgAtStart ,
port ,
bind : opts.bind ,
host : opts.host ,
controlUiEnabled : opts.controlUiEnabled ,
openAiChatCompletionsEnabled : opts.openAiChatCompletionsEnabled ,
2026-01-19 10:44:48 +01:00
openResponsesEnabled : opts.openResponsesEnabled ,
2026-01-14 09:11:21 +00:00
auth : opts.auth ,
tailscale : opts.tailscale ,
2026-01-14 01:08:15 +00:00
} ) ;
2026-01-14 09:11:21 +00:00
const {
bindHost ,
controlUiEnabled ,
openAiChatCompletionsEnabled ,
2026-01-19 10:44:48 +01:00
openResponsesEnabled ,
2026-01-20 07:35:29 +00:00
openResponsesConfig ,
2026-02-23 19:47:09 +00:00
strictTransportSecurityHeader ,
2026-01-14 09:11:21 +00:00
controlUiBasePath ,
2026-02-03 13:56:20 -05:00
controlUiRoot : controlUiRootOverride ,
2026-01-14 09:11:21 +00:00
resolvedAuth ,
tailscaleConfig ,
tailscaleMode ,
} = runtimeConfig ;
let hooksConfig = runtimeConfig . hooksConfig ;
const canvasHostEnabled = runtimeConfig . canvasHostEnabled ;
2026-01-14 01:08:15 +00:00
2026-02-13 15:32:38 +01:00
// Create auth rate limiter only when explicitly configured.
const rateLimitConfig = cfgAtStart . gateway ? . auth ? . rateLimit ;
const authRateLimiter : AuthRateLimiter | undefined = rateLimitConfig
? createAuthRateLimiter ( rateLimitConfig )
: undefined ;
2026-02-26 01:22:28 +01:00
// Always keep a browser-origin fallback limiter for WS auth attempts.
const browserAuthRateLimiter : AuthRateLimiter = createAuthRateLimiter ( {
. . . rateLimitConfig ,
exemptLoopback : false ,
} ) ;
2026-02-13 15:32:38 +01:00
2026-02-03 13:56:20 -05:00
let controlUiRootState : ControlUiRootState | undefined ;
if ( controlUiRootOverride ) {
const resolvedOverride = resolveControlUiRootOverrideSync ( controlUiRootOverride ) ;
const resolvedOverridePath = path . resolve ( controlUiRootOverride ) ;
controlUiRootState = resolvedOverride
? { kind : "resolved" , path : resolvedOverride }
: { kind : "invalid" , path : resolvedOverridePath } ;
if ( ! resolvedOverride ) {
log . warn ( ` gateway: controlUi.root not found at ${ resolvedOverridePath } ` ) ;
}
} else if ( controlUiEnabled ) {
let resolvedRoot = resolveControlUiRootSync ( {
moduleUrl : import.meta.url ,
argv1 : process.argv [ 1 ] ,
cwd : process.cwd ( ) ,
} ) ;
if ( ! resolvedRoot ) {
const ensureResult = await ensureControlUiAssetsBuilt ( gatewayRuntime ) ;
if ( ! ensureResult . ok && ensureResult . message ) {
log . warn ( ` gateway: ${ ensureResult . message } ` ) ;
}
resolvedRoot = resolveControlUiRootSync ( {
moduleUrl : import.meta.url ,
argv1 : process.argv [ 1 ] ,
cwd : process.cwd ( ) ,
} ) ;
}
controlUiRootState = resolvedRoot
? { kind : "resolved" , path : resolvedRoot }
: { kind : "missing" } ;
}
2026-01-14 01:08:15 +00:00
const wizardRunner = opts . wizardRunner ? ? runOnboardingWizard ;
2026-01-14 14:31:43 +00:00
const { wizardSessions , findRunningWizard , purgeWizardSession } = createWizardSessionTracker ( ) ;
2026-01-14 01:08:15 +00:00
const deps = createDefaultDeps ( ) ;
let canvasHostServer : CanvasHostServer | null = null ;
2026-01-19 02:46:07 +00:00
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" ) ;
}
2026-01-14 09:11:21 +00:00
const {
canvasHost ,
httpServer ,
2026-01-25 05:48:40 +00:00
httpServers ,
httpBindHosts ,
2026-01-14 09:11:21 +00:00
wss ,
clients ,
broadcast ,
2026-02-04 17:12:16 -05:00
broadcastToConnIds ,
2026-01-14 09:11:21 +00:00
agentRunSeq ,
dedupe ,
chatRunState ,
chatRunBuffers ,
chatDeltaSentAt ,
addChatRun ,
removeChatRun ,
chatAbortControllers ,
2026-02-04 17:12:16 -05:00
toolEventRecipients ,
2026-01-14 09:11:21 +00:00
} = await createGatewayRuntimeState ( {
cfg : cfgAtStart ,
2026-01-14 01:08:15 +00:00
bindHost ,
port ,
controlUiEnabled ,
controlUiBasePath ,
2026-02-03 13:56:20 -05:00
controlUiRoot : controlUiRootState ,
2026-01-14 01:08:15 +00:00
openAiChatCompletionsEnabled ,
2026-01-19 10:44:48 +01:00
openResponsesEnabled ,
2026-01-20 07:35:29 +00:00
openResponsesConfig ,
2026-02-23 19:47:09 +00:00
strictTransportSecurityHeader ,
2026-01-14 01:08:15 +00:00
resolvedAuth ,
2026-02-13 15:32:38 +01:00
rateLimiter : authRateLimiter ,
2026-01-19 02:46:07 +00:00
gatewayTls ,
2026-01-14 09:11:21 +00:00
hooksConfig : ( ) = > hooksConfig ,
2026-01-15 05:03:50 +00:00
pluginRegistry ,
2026-01-14 09:11:21 +00:00
deps ,
canvasRuntime ,
canvasHostEnabled ,
allowCanvasHostInTests : opts.allowCanvasHostInTests ,
logCanvas ,
2026-01-25 05:48:40 +00:00
log ,
2026-01-14 09:11:21 +00:00
logHooks ,
2026-01-15 05:03:50 +00:00
logPlugins ,
2026-01-14 01:08:15 +00:00
} ) ;
let bonjourStop : ( ( ) = > Promise < void > ) | null = null ;
2026-01-19 04:50:07 +00:00
const nodeRegistry = new NodeRegistry ( ) ;
const nodePresenceTimers = new Map < string , ReturnType < typeof setInterval > > ( ) ;
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 ) ;
2026-01-14 09:11:21 +00:00
applyGatewayLaneConcurrency ( cfgAtStart ) ;
2026-01-14 01:08:15 +00:00
2026-01-14 09:11:21 +00:00
let cronState = buildGatewayCronService ( {
cfg : cfgAtStart ,
deps ,
broadcast ,
2026-01-14 01:08:15 +00:00
} ) ;
2026-01-14 09:11:21 +00:00
let { cron , storePath : cronStorePath } = cronState ;
2026-01-14 01:08:15 +00:00
const channelManager = createChannelManager ( {
loadConfig ,
channelLogs ,
channelRuntimeEnvs ,
} ) ;
2026-01-14 14:31:43 +00:00
const { getRuntimeSnapshot , startChannels , startChannel , stopChannel , markChannelLoggedOut } =
channelManager ;
2026-01-14 01:08:15 +00:00
2026-02-14 05:09:07 +00:00
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 ;
}
2026-01-14 09:11:21 +00:00
2026-02-14 05:09:07 +00:00
if ( ! minimalTestGateway ) {
setSkillsRemoteRegistry ( nodeRegistry ) ;
void primeRemoteSkillsCache ( ) ;
}
2026-01-24 21:05:41 +01:00
// 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 < typeof setTimeout > | null = null ;
const skillsRefreshDelayMs = 30 _000 ;
2026-02-14 05:09:07 +00:00
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 ) ;
} ) ;
2026-01-14 01:08:15 +00:00
2026-02-14 05:09:07 +00:00
const noopInterval = ( ) = > setInterval ( ( ) = > { } , 1 << 30 ) ;
let tickInterval = noopInterval ( ) ;
let healthInterval = noopInterval ( ) ;
let dedupeCleanup = noopInterval ( ) ;
if ( ! minimalTestGateway ) {
( { tickInterval , healthInterval , dedupeCleanup } = startGatewayMaintenanceTimers ( {
2026-01-14 01:08:15 +00:00
broadcast ,
2026-02-14 05:09:07 +00:00
nodeSendToAllSubscribed ,
getPresenceVersion ,
getHealthVersion ,
refreshGatewayHealthSnapshot ,
logHealth ,
dedupe ,
chatAbortControllers ,
2026-01-14 01:08:15 +00:00
chatRunState ,
2026-02-14 05:09:07 +00:00
chatRunBuffers ,
chatDeltaSentAt ,
removeChatRun ,
agentRunSeq ,
nodeSendToSession ,
} ) ) ;
}
2026-01-14 01:08:15 +00:00
2026-02-14 05:09:07 +00:00
const agentUnsub = minimalTestGateway
? null
: onAgentEvent (
createAgentEventHandler ( {
broadcast ,
broadcastToConnIds ,
nodeSendToSession ,
agentRunSeq ,
chatRunState ,
resolveSessionKeyForRun ,
clearAgentRunContext ,
toolEventRecipients ,
} ) ,
) ;
2026-01-14 01:08:15 +00:00
2026-02-14 05:09:07 +00:00
const heartbeatUnsub = minimalTestGateway
? null
: onHeartbeatEvent ( ( evt ) = > {
broadcast ( "heartbeat" , evt , { dropIfSlow : true } ) ;
} ) ;
let heartbeatRunner : HeartbeatRunner = minimalTestGateway
? {
stop : ( ) = > { } ,
updateConfig : ( ) = > { } ,
}
: startHeartbeatRunner ( { cfg : cfgAtStart } ) ;
2026-01-14 01:08:15 +00:00
2026-02-12 11:47:26 +07:00
const healthCheckMinutes = cfgAtStart . gateway ? . channelHealthCheckMinutes ;
const healthCheckDisabled = healthCheckMinutes === 0 ;
const channelHealthMonitor = healthCheckDisabled
? null
: startChannelHealthMonitor ( {
channelManager ,
checkIntervalMs : ( healthCheckMinutes ? ? 5 ) * 60 _000 ,
} ) ;
2026-02-14 05:09:07 +00:00
if ( ! minimalTestGateway ) {
void cron . start ( ) . catch ( ( err ) = > logCron . error ( ` failed to start: ${ String ( err ) } ` ) ) ;
}
2026-01-14 01:08:15 +00:00
2026-02-13 15:54:07 -06:00
// Recover pending outbound deliveries from previous crash/restart.
2026-02-14 05:09:07 +00:00
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 ) } ` ) ) ;
}
2026-02-13 15:54:07 -06:00
2026-01-19 02:31:18 +00:00
const execApprovalManager = new ExecApprovalManager ( ) ;
2026-01-24 12:56:40 -08:00
const execApprovalForwarder = createExecApprovalForwarder ( ) ;
const execApprovalHandlers = createExecApprovalHandlers ( execApprovalManager , {
forwarder : execApprovalForwarder ,
} ) ;
2026-01-19 02:31:18 +00:00
2026-01-19 06:22:01 +00:00
const canvasHostServerPort = ( canvasHostServer as CanvasHostServer | null ) ? . port ;
2026-01-14 09:11:21 +00:00
attachGatewayWsHandlers ( {
2026-01-14 01:08:15 +00:00
wss ,
clients ,
port ,
2026-01-19 04:50:07 +00:00
gatewayHost : bindHost ? ? undefined ,
2026-01-14 01:08:15 +00:00
canvasHostEnabled : Boolean ( canvasHost ) ,
2026-01-19 06:22:01 +00:00
canvasHostServerPort ,
2026-01-14 01:08:15 +00:00
resolvedAuth ,
2026-02-13 15:32:38 +01:00
rateLimiter : authRateLimiter ,
2026-02-26 01:22:28 +01:00
browserRateLimiter : browserAuthRateLimiter ,
2026-01-14 01:08:15 +00:00
gatewayMethods ,
2026-01-14 09:11:21 +00:00
events : GATEWAY_EVENTS ,
2026-01-14 01:08:15 +00:00
logGateway : log ,
logHealth ,
logWsControl ,
2026-01-19 02:31:18 +00:00
extraHandlers : {
. . . pluginRegistry . gatewayHandlers ,
. . . execApprovalHandlers ,
} ,
2026-01-14 01:08:15 +00:00
broadcast ,
2026-01-14 09:11:21 +00:00
context : {
2026-01-14 01:08:15 +00:00
deps ,
cron ,
cronStorePath ,
2026-02-14 13:02:48 +01:00
execApprovalManager ,
2026-01-14 01:08:15 +00:00
loadGatewayModelCatalog ,
getHealthCache ,
refreshHealthSnapshot : refreshGatewayHealthSnapshot ,
logHealth ,
logGateway : log ,
incrementPresenceVersion ,
getHealthVersion ,
broadcast ,
2026-02-04 17:12:16 -05:00
broadcastToConnIds ,
2026-01-19 04:50:07 +00:00
nodeSendToSession ,
nodeSendToAllSubscribed ,
nodeSubscribe ,
nodeUnsubscribe ,
nodeUnsubscribeAll ,
hasConnectedMobileNode : hasMobileNodeConnected ,
2026-02-22 22:13:40 +01:00
hasExecApprovalClients : ( ) = > {
for ( const gatewayClient of clients ) {
const scopes = Array . isArray ( gatewayClient . connect . scopes )
? gatewayClient . connect . scopes
: [ ] ;
if ( scopes . includes ( "operator.admin" ) || scopes . includes ( "operator.approvals" ) ) {
return true ;
}
}
return false ;
} ,
2026-01-19 04:50:07 +00:00
nodeRegistry ,
2026-01-14 01:08:15 +00:00
agentRunSeq ,
chatAbortControllers ,
chatAbortedRuns : chatRunState.abortedRuns ,
2026-01-14 09:11:21 +00:00
chatRunBuffers : chatRunState.buffers ,
chatDeltaSentAt : chatRunState.deltaSentAt ,
2026-01-14 01:08:15 +00:00
addChatRun ,
removeChatRun ,
2026-02-04 17:12:16 -05:00
registerToolEventRecipient : toolEventRecipients.add ,
2026-01-14 01:08:15 +00:00
dedupe ,
wizardSessions ,
findRunningWizard ,
purgeWizardSession ,
getRuntimeSnapshot ,
startChannel ,
stopChannel ,
markChannelLoggedOut ,
wizardRunner ,
broadcastVoiceWakeChanged ,
2026-01-14 09:11:21 +00:00
} ,
2026-01-14 01:08:15 +00:00
} ) ;
2026-01-14 09:11:21 +00:00
logGatewayStartup ( {
cfg : cfgAtStart ,
bindHost ,
2026-01-25 05:48:40 +00:00
bindHosts : httpBindHosts ,
2026-01-14 09:11:21 +00:00
port ,
2026-01-19 02:46:07 +00:00
tlsEnabled : gatewayTls.enabled ,
2026-01-14 09:11:21 +00:00
log ,
isNixMode ,
} ) ;
2026-02-22 17:11:24 +01:00
const stopGatewayUpdateCheck = minimalTestGateway
? ( ) = > { }
: scheduleGatewayUpdateCheck ( {
cfg : cfgAtStart ,
log ,
isNixMode ,
onUpdateAvailableChange : ( updateAvailable ) = > {
const payload : GatewayUpdateAvailableEventPayload = { updateAvailable } ;
broadcast ( GATEWAY_EVENT_UPDATE_AVAILABLE , payload , { dropIfSlow : true } ) ;
} ,
} ) ;
2026-02-14 05:09:07 +00:00
const tailscaleCleanup = minimalTestGateway
? null
: await startGatewayTailscaleExposure ( {
tailscaleMode ,
resetOnExit : tailscaleConfig.resetOnExit ,
port ,
controlUiBasePath ,
logTailscale ,
} ) ;
2026-01-14 01:08:15 +00:00
2026-01-14 14:31:43 +00:00
let browserControl : Awaited < ReturnType < typeof startBrowserControlServerIfEnabled > > = null ;
2026-02-14 05:09:07 +00:00
if ( ! minimalTestGateway ) {
( { browserControl , pluginServices } = await startGatewaySidecars ( {
cfg : cfgAtStart ,
pluginRegistry ,
defaultWorkspaceDir ,
deps ,
startChannels ,
log ,
logHooks ,
logChannels ,
logBrowser ,
} ) ) ;
}
2026-01-14 01:08:15 +00:00
2026-02-13 00:14:14 +00:00
// Run gateway_start plugin hook (fire-and-forget)
2026-02-14 05:09:07 +00:00
if ( ! minimalTestGateway ) {
2026-02-13 00:14:14 +00:00
const hookRunner = getGlobalHookRunner ( ) ;
if ( hookRunner ? . hasHooks ( "gateway_start" ) ) {
void hookRunner . runGatewayStart ( { port } , { port } ) . catch ( ( err ) = > {
log . warn ( ` gateway_start hook failed: ${ String ( err ) } ` ) ;
} ) ;
}
}
2026-02-14 05:09:07 +00:00
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 ,
} ) ;
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 ,
} ) ;
} ) ( ) ;
2026-01-14 01:08:15 +00:00
2026-01-14 09:11:21 +00:00
const close = createGatewayCloseHandler ( {
bonjourStop ,
tailscaleCleanup ,
canvasHost ,
canvasHostServer ,
stopChannel ,
pluginServices ,
cron ,
heartbeatRunner ,
2026-02-22 17:11:24 +01:00
updateCheckStop : stopGatewayUpdateCheck ,
2026-01-14 09:11:21 +00:00
nodePresenceTimers ,
broadcast ,
tickInterval ,
healthInterval ,
dedupeCleanup ,
agentUnsub ,
heartbeatUnsub ,
chatRunState ,
clients ,
configReloader ,
browserControl ,
wss ,
httpServer ,
2026-01-25 05:48:40 +00:00
httpServers ,
2026-01-14 09:11:21 +00:00
} ) ;
2026-01-21 00:29:42 +00:00
return {
close : async ( opts ) = > {
2026-02-13 00:14:14 +00:00
// Run gateway_stop plugin hook before shutdown
2026-02-14 17:33:08 -05:00
await runGlobalGatewayStopSafely ( {
event : { reason : opts?.reason ? ? "gateway stopping" } ,
ctx : { port } ,
onError : ( err ) = > log . warn ( ` gateway_stop hook failed: ${ String ( err ) } ` ) ,
} ) ;
2026-01-21 00:29:42 +00:00
if ( diagnosticsEnabled ) {
stopDiagnosticHeartbeat ( ) ;
}
2026-01-24 21:05:41 +01:00
if ( skillsRefreshTimer ) {
clearTimeout ( skillsRefreshTimer ) ;
skillsRefreshTimer = null ;
}
skillsChangeUnsub ( ) ;
2026-02-13 15:32:38 +01:00
authRateLimiter ? . dispose ( ) ;
2026-02-26 01:22:28 +01:00
browserAuthRateLimiter . dispose ( ) ;
2026-02-12 11:47:26 +07:00
channelHealthMonitor ? . stop ( ) ;
2026-01-21 00:29:42 +00:00
await close ( opts ) ;
} ,
} ;
2026-01-14 01:08:15 +00:00
}