diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 6220b25e348..2129e98bef7 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -8,7 +8,7 @@ title: "Hooks" # Hooks -Hooks are small scripts that run when something happens inside the Gateway. They are automatically discovered from directories and can be inspected with `openclaw hooks`. +Hooks are small scripts that run when something happens inside the Gateway. They can be discovered from directories and inspected with `openclaw hooks`. The Gateway loads internal hooks only after you enable hooks or configure at least one hook entry, hook pack, legacy handler, or extra hook directory. There are two kinds of hooks in OpenClaw: @@ -139,6 +139,8 @@ Hooks are discovered from these directories, in order of increasing override pre Workspace hooks can add new hook names but cannot override bundled, managed, or plugin-provided hooks with the same name. +The Gateway skips internal hook discovery on startup until internal hooks are configured. Enable a bundled or managed hook with `openclaw hooks enable `, install a hook pack, or set `hooks.internal.enabled=true` to opt in. + ### Hook packs Hook packs are npm packages that export hooks via `openclaw.hooks` in `package.json`. Install with: diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index e4302268eeb..d167f66aa7d 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -24,6 +24,7 @@ openclaw hooks list ``` List all discovered hooks from workspace, managed, extra, and bundled directories. +Gateway startup does not load internal hook handlers until at least one internal hook is configured. **Options:** diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 9a567ae812d..602cf99419a 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -70,10 +70,6 @@ export const BUILD_ALL_STEPS = [ label: "copy-hook-metadata", kind: "node", args: ["--import", "tsx", "scripts/copy-hook-metadata.ts"], - cache: { - inputs: ["scripts/copy-hook-metadata.ts", "scripts/lib/copy-assets.ts", "src/hooks/bundled"], - outputs: ["dist/bundled"], - }, }, { label: "copy-export-html-templates", diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 1745e058ffe..620a45b1826 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -596,15 +596,25 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage }; const startChannels = async () => { - for (const plugin of listChannelPlugins()) { - try { - await startChannel(plugin.id); - } catch (err) { - channelLogs[plugin.id]?.error?.( - `[${plugin.id}] channel startup failed: ${formatErrorMessage(err)}`, - ); - } - } + const pending = [...listChannelPlugins()]; + const workerCount = Math.min(8, pending.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + for (;;) { + const plugin = pending.shift(); + if (!plugin) { + return; + } + try { + await startChannel(plugin.id); + } catch (err) { + channelLogs[plugin.id]?.error?.( + `[${plugin.id}] channel startup failed: ${formatErrorMessage(err)}`, + ); + } + } + }), + ); }; const markChannelLoggedOut = (channelId: ChannelId, cleared: boolean, accountId?: string) => { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 6a018366073..b7729ad9af3 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -138,7 +138,8 @@ describe("startGatewayPostAttachRuntime", () => { expect([...unavailableGatewayMethods]).toEqual([]); expect(hoisted.startPluginServices).toHaveBeenCalledTimes(1); - expect(hoisted.setInternalHooksEnabled).toHaveBeenCalledWith(false); + expect(hoisted.loadInternalHooks).not.toHaveBeenCalled(); + expect(hoisted.setInternalHooksEnabled).not.toHaveBeenCalled(); expect(hoisted.logGatewayStartup).toHaveBeenCalledWith( expect.objectContaining({ loadedPluginIds: ["beta", "alpha"] }), ); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 8a54893d220..0020531ce5b 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -1,50 +1,19 @@ -import { getAcpSessionManager } from "../acp/control-plane/manager.js"; -import { ACP_SESSION_IDENTITY_RENDERER_VERSION } from "../acp/runtime/session-identifiers.js"; -import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { selectAgentHarness } from "../agents/harness/selection.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; -import { - getModelRefStatus, - isCliProvider, - resolveConfiguredModelRef, - resolveHooksGmailModel, -} from "../agents/model-selection.js"; -import { ensureOpenClawModelsJson } from "../agents/models-config.js"; -import { resolveModel } from "../agents/pi-embedded-runner/model.js"; -import { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; -import { resolveAgentSessionDirs } from "../agents/session-dirs.js"; -import { cleanStaleLockFiles } from "../agents/session-write-lock.js"; -import { scheduleSubagentOrphanRecovery } from "../agents/subagent-registry.js"; import type { CliDeps } from "../cli/deps.types.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { resolveStateDir } from "../config/paths.js"; import type { GatewayTailscaleMode } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { startGmailWatcherWithLogs } from "../hooks/gmail-watcher-lifecycle.js"; -import { - createInternalHookEvent, - setInternalHooksEnabled, - triggerInternalHook, -} from "../hooks/internal-hooks.js"; -import { loadInternalHooks } from "../hooks/loader.js"; +import { hasConfiguredInternalHooks } from "../hooks/configured.js"; import { isTruthyEnvValue } from "../infra/env.js"; -import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; -import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; +import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; -import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; +import type { PluginServicesHandle } from "../plugins/services.js"; import { GATEWAY_EVENT_UPDATE_AVAILABLE, type GatewayUpdateAvailableEventPayload, } from "./events.js"; -import { - scheduleRestartSentinelWake, - shouldWakeFromRestartSentinel, -} from "./server-restart-sentinel.js"; import { logGatewayStartup } from "./server-startup-log.js"; -import { startGatewayMemoryBackend } from "./server-startup-memory.js"; import { STARTUP_UNAVAILABLE_GATEWAY_METHODS } from "./server-startup-unavailable-methods.js"; -import { startGatewayTailscaleExposure } from "./server-tailscale.js"; +import type { startGatewayTailscaleExposure } from "./server-tailscale.js"; const SESSION_LOCK_STALE_MS = 30 * 60 * 1000; @@ -52,10 +21,28 @@ async function prewarmConfiguredPrimaryModel(params: { cfg: OpenClawConfig; log: { warn: (msg: string) => void }; }): Promise { + const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js"); const explicitPrimary = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)?.trim(); if (!explicitPrimary) { return; } + const [ + { resolveOpenClawAgentDir }, + { DEFAULT_MODEL, DEFAULT_PROVIDER }, + { selectAgentHarness }, + { isCliProvider, resolveConfiguredModelRef }, + { ensureOpenClawModelsJson }, + { resolveModel }, + { resolveEmbeddedAgentRuntime }, + ] = await Promise.all([ + import("../agents/agent-paths.js"), + import("../agents/defaults.js"), + import("../agents/harness/selection.js"), + import("../agents/model-selection.js"), + import("../agents/models-config.js"), + import("../agents/pi-embedded-runner/model.js"), + import("../agents/pi-embedded-runner/runtime.js"), + ]); const { provider, model } = resolveConfiguredModelRef({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER, @@ -103,6 +90,12 @@ export async function startGatewaySidecars(params: { logChannels: { info: (msg: string) => void; error: (msg: string) => void }; }) { try { + const [{ resolveStateDir }, { resolveAgentSessionDirs }, { cleanStaleLockFiles }] = + await Promise.all([ + import("../config/paths.js"), + import("../agents/session-dirs.js"), + import("../agents/session-write-lock.js"), + ]); const stateDir = resolveStateDir(process.env); const sessionDirs = await resolveAgentSessionDirs(stateDir); for (const sessionsDir of sessionDirs) { @@ -117,12 +110,24 @@ export async function startGatewaySidecars(params: { params.log.warn(`session lock cleanup failed on startup: ${String(err)}`); } - await startGmailWatcherWithLogs({ - cfg: params.cfg, - log: params.logHooks, - }); + if (params.cfg.hooks?.enabled && params.cfg.hooks.gmail?.account) { + const { startGmailWatcherWithLogs } = await import("../hooks/gmail-watcher-lifecycle.js"); + await startGmailWatcherWithLogs({ + cfg: params.cfg, + log: params.logHooks, + }); + } if (params.cfg.hooks?.gmail?.model) { + const [ + { DEFAULT_MODEL, DEFAULT_PROVIDER }, + { loadModelCatalog }, + { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel }, + ] = await Promise.all([ + import("../agents/defaults.js"), + import("../agents/model-catalog.js"), + import("../agents/model-selection.js"), + ]); const hooksModelRef = resolveHooksGmailModel({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER, @@ -154,13 +159,20 @@ export async function startGatewaySidecars(params: { } } + const internalHooksConfigured = hasConfiguredInternalHooks(params.cfg); try { - setInternalHooksEnabled(params.cfg.hooks?.internal?.enabled !== false); - const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir); - if (loadedCount > 0) { - params.logHooks.info( - `loaded ${loadedCount} internal hook handler${loadedCount > 1 ? "s" : ""}`, - ); + if (internalHooksConfigured) { + const [{ setInternalHooksEnabled }, { loadInternalHooks }] = await Promise.all([ + import("../hooks/internal-hooks.js"), + import("../hooks/loader.js"), + ]); + setInternalHooksEnabled(params.cfg.hooks?.internal?.enabled !== false); + const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir); + if (loadedCount > 0) { + params.logHooks.info( + `loaded ${loadedCount} internal hook handler${loadedCount > 1 ? "s" : ""}`, + ); + } } } catch (err) { params.logHooks.error(`failed to load hooks: ${String(err)}`); @@ -185,19 +197,24 @@ export async function startGatewaySidecars(params: { ); } - if (params.cfg.hooks?.internal?.enabled !== false) { + if (internalHooksConfigured) { setTimeout(() => { - const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", { - cfg: params.cfg, - deps: params.deps, - workspaceDir: params.defaultWorkspaceDir, - }); - void triggerInternalHook(hookEvent); + void import("../hooks/internal-hooks.js").then( + ({ createInternalHookEvent, triggerInternalHook }) => { + const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", { + cfg: params.cfg, + deps: params.deps, + workspaceDir: params.defaultWorkspaceDir, + }); + void triggerInternalHook(hookEvent); + }, + ); }, 250); } let pluginServices: PluginServicesHandle | null = null; try { + const { startPluginServices } = await import("../plugins/services.js"); pluginServices = await startPluginServices({ registry: params.pluginRegistry, config: params.cfg, @@ -208,6 +225,9 @@ export async function startGatewaySidecars(params: { } if (params.cfg.acp?.enabled) { + const [{ getAcpSessionManager }, { ACP_SESSION_IDENTITY_RENDERER_VERSION }] = await Promise.all( + [import("../acp/control-plane/manager.js"), import("../acp/runtime/session-identifiers.js")], + ); void getAcpSessionManager() .reconcilePendingSessionIdentities({ cfg: params.cfg }) .then((result) => { @@ -223,35 +243,51 @@ export async function startGatewaySidecars(params: { }); } - void startGatewayMemoryBackend({ cfg: params.cfg, log: params.log }).catch((err) => { - params.log.warn(`qmd memory startup initialization failed: ${String(err)}`); - }); + void import("./server-startup-memory.js") + .then(({ startGatewayMemoryBackend }) => + startGatewayMemoryBackend({ cfg: params.cfg, log: params.log }), + ) + .catch((err) => { + params.log.warn(`qmd memory startup initialization failed: ${String(err)}`); + }); + const { shouldWakeFromRestartSentinel, scheduleRestartSentinelWake } = + await import("./server-restart-sentinel.js"); if (shouldWakeFromRestartSentinel()) { setTimeout(() => { void scheduleRestartSentinelWake({ deps: params.deps }); }, 750); } + const { scheduleSubagentOrphanRecovery } = await import("../agents/subagent-registry.js"); scheduleSubagentOrphanRecovery(); return { pluginServices }; } +type Awaitable = T | Promise; + type GatewayPostAttachRuntimeDeps = { - getGlobalHookRunner: typeof getGlobalHookRunner; + getGlobalHookRunner: () => Awaitable>; logGatewayStartup: typeof logGatewayStartup; - scheduleGatewayUpdateCheck: typeof scheduleGatewayUpdateCheck; + scheduleGatewayUpdateCheck: ( + ...args: Parameters + ) => Awaitable>; startGatewaySidecars: typeof startGatewaySidecars; - startGatewayTailscaleExposure: typeof startGatewayTailscaleExposure; + startGatewayTailscaleExposure: ( + ...args: Parameters + ) => ReturnType; }; const defaultGatewayPostAttachRuntimeDeps: GatewayPostAttachRuntimeDeps = { - getGlobalHookRunner, + getGlobalHookRunner: async () => + (await import("../plugins/hook-runner-global.js")).getGlobalHookRunner(), logGatewayStartup, - scheduleGatewayUpdateCheck, + scheduleGatewayUpdateCheck: async (...args) => + (await import("../infra/update-startup.js")).scheduleGatewayUpdateCheck(...args), startGatewaySidecars, - startGatewayTailscaleExposure, + startGatewayTailscaleExposure: async (...args) => + (await import("./server-tailscale.js")).startGatewayTailscaleExposure(...args), }; export async function startGatewayPostAttachRuntime( @@ -307,48 +343,60 @@ export async function startGatewayPostAttachRuntime( startupStartedAt: params.startupStartedAt, }); - const stopGatewayUpdateCheck = params.minimalTestGateway - ? () => {} - : runtimeDeps.scheduleGatewayUpdateCheck({ - cfg: params.cfgAtStart, - log: params.log, - isNixMode: params.isNixMode, - onUpdateAvailableChange: (updateAvailable) => { - const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; - params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); - }, - }); + const stopGatewayUpdateCheckPromise = params.minimalTestGateway + ? Promise.resolve(() => {}) + : Promise.resolve( + runtimeDeps.scheduleGatewayUpdateCheck({ + cfg: params.cfgAtStart, + log: params.log, + isNixMode: params.isNixMode, + onUpdateAvailableChange: (updateAvailable) => { + const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; + params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); + }, + }), + ); - const tailscaleCleanup = params.minimalTestGateway - ? null - : await runtimeDeps.startGatewayTailscaleExposure({ - tailscaleMode: params.tailscaleMode, - resetOnExit: params.resetOnExit, - port: params.port, - controlUiBasePath: params.controlUiBasePath, - logTailscale: params.logTailscale, - }); + const tailscaleCleanupPromise = params.minimalTestGateway + ? Promise.resolve(null) + : Promise.resolve( + runtimeDeps.startGatewayTailscaleExposure({ + tailscaleMode: params.tailscaleMode, + resetOnExit: params.resetOnExit, + port: params.port, + controlUiBasePath: params.controlUiBasePath, + logTailscale: params.logTailscale, + }), + ); - let pluginServices: PluginServicesHandle | null = null; - if (!params.minimalTestGateway) { - params.log.info("starting channels and sidecars..."); - ({ pluginServices } = await runtimeDeps.startGatewaySidecars({ - cfg: params.gatewayPluginConfigAtStart, - pluginRegistry: params.pluginRegistry, - defaultWorkspaceDir: params.defaultWorkspaceDir, - deps: params.deps, - startChannels: params.startChannels, - log: params.log, - logHooks: params.logHooks, - logChannels: params.logChannels, - })); - for (const method of STARTUP_UNAVAILABLE_GATEWAY_METHODS) { - params.unavailableGatewayMethods.delete(method); - } - } + const sidecarsPromise = params.minimalTestGateway + ? Promise.resolve({ pluginServices: null }) + : (async () => { + params.log.info("starting channels and sidecars..."); + const result = await runtimeDeps.startGatewaySidecars({ + cfg: params.gatewayPluginConfigAtStart, + pluginRegistry: params.pluginRegistry, + defaultWorkspaceDir: params.defaultWorkspaceDir, + deps: params.deps, + startChannels: params.startChannels, + log: params.log, + logHooks: params.logHooks, + logChannels: params.logChannels, + }); + for (const method of STARTUP_UNAVAILABLE_GATEWAY_METHODS) { + params.unavailableGatewayMethods.delete(method); + } + return result; + })(); + + const [stopGatewayUpdateCheck, tailscaleCleanup, { pluginServices }] = await Promise.all([ + stopGatewayUpdateCheckPromise, + tailscaleCleanupPromise, + sidecarsPromise, + ]); if (!params.minimalTestGateway) { - const hookRunner = runtimeDeps.getGlobalHookRunner(); + const hookRunner = await runtimeDeps.getGlobalHookRunner(); if (hookRunner?.hasHooks("gateway_start")) { void hookRunner.runGatewayStart({ port: params.port }, { port: params.port }).catch((err) => { params.log.warn(`gateway_start hook failed: ${String(err)}`); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 1d805aab841..4e3cc59eb7b 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -20,7 +20,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { clearAgentRunContext } from "../infra/agent-events.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; -import { isVitestRuntimeEnv, logAcceptedEnvOption } from "../infra/env.js"; +import { isTruthyEnvValue, isVitestRuntimeEnv, logAcceptedEnvOption } from "../infra/env.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -131,6 +131,34 @@ const logSecrets = log.child("secrets"); const gatewayRuntime = runtimeForLogger(log); const canvasRuntime = runtimeForLogger(logCanvas); +function createGatewayStartupTrace() { + const enabled = isTruthyEnvValue(process.env.OPENCLAW_GATEWAY_STARTUP_TRACE); + const started = performance.now(); + let last = started; + const emit = (name: string, durationMs: number, totalMs: number) => { + if (enabled) { + log.info(`startup trace: ${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms`); + } + }; + return { + mark(name: string) { + const now = performance.now(); + emit(name, now - last, now - started); + last = now; + }, + async measure(name: string, run: () => Promise | T): Promise { + const before = performance.now(); + try { + return await run(); + } finally { + const now = performance.now(); + emit(name, now - before, now - started); + last = now; + } + }, + }; +} + type AuthRateLimitConfig = Parameters[0]; function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): { @@ -222,11 +250,14 @@ export async function startGatewayServer( key: "OPENCLAW_RAW_STREAM_PATH", description: "raw stream log path override", }); + const startupTrace = createGatewayStartupTrace(); - const configSnapshot = await loadGatewayStartupConfigSnapshot({ - minimalTestGateway, - log, - }); + const configSnapshot = await startupTrace.measure("config.snapshot", () => + loadGatewayStartupConfigSnapshot({ + minimalTestGateway, + log, + }), + ); const emitSecretsStateEvent = ( code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED", @@ -247,12 +278,14 @@ export async function startGatewayServer( let startupInternalWriteHash: string | null = null; let startupLastGoodSnapshot = configSnapshot; const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config); - const authBootstrap = await prepareGatewayStartupConfig({ - configSnapshot, - authOverride: opts.auth, - tailscaleOverride: opts.tailscale, - activateRuntimeSecrets, - }); + const authBootstrap = await startupTrace.measure("config.auth", () => + prepareGatewayStartupConfig({ + configSnapshot, + authOverride: opts.auth, + tailscaleOverride: opts.tailscale, + activateRuntimeSecrets, + }), + ); cfgAtStart = authBootstrap.cfg; if (authBootstrap.generatedToken) { if (authBootstrap.persistedGeneratedToken) { @@ -281,11 +314,13 @@ export async function startGatewayServer( // non-loopback installs that upgraded to v2026.2.26+ without required origins. const controlUiSeed = minimalTestGateway ? { config: cfgAtStart, persistedAllowedOriginsSeed: false } - : await maybeSeedControlUiAllowedOriginsAtStartup({ - config: cfgAtStart, - writeConfig: writeConfigFile, - log, - }); + : await startupTrace.measure("control-ui.seed", () => + maybeSeedControlUiAllowedOriginsAtStartup({ + config: cfgAtStart, + writeConfig: writeConfigFile, + log, + }), + ); cfgAtStart = controlUiSeed.config; // Always capture the final config hash after all startup writes (plugin // auto-enable, auth token generation, control-UI origin seeding) so the @@ -295,16 +330,20 @@ export async function startGatewayServer( // changes, missing the plugin auto-enable write performed earlier inside // loadGatewayStartupConfigSnapshot(). See #67436. { - const startupSnapshot = await readConfigFileSnapshot(); + const startupSnapshot = await startupTrace.measure("config.final-snapshot", () => + readConfigFileSnapshot(), + ); startupInternalWriteHash = startupSnapshot.hash ?? null; startupLastGoodSnapshot = startupSnapshot; } - const pluginBootstrap = await prepareGatewayPluginBootstrap({ - cfgAtStart, - startupRuntimeConfig, - minimalTestGateway, - log, - }); + const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", () => + prepareGatewayPluginBootstrap({ + cfgAtStart, + startupRuntimeConfig, + minimalTestGateway, + log, + }), + ); const { gatewayPluginConfigAtStart, defaultWorkspaceDir, @@ -326,17 +365,19 @@ export async function startGatewayServer( ...listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []), ]), ); - 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 runtimeConfig = await startupTrace.measure("runtime.config", () => + 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, @@ -392,12 +433,14 @@ export async function startGatewayServer( const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } = createGatewayAuthRateLimiters(rateLimitConfig); - const controlUiRootState = await resolveGatewayControlUiRootState({ - controlUiRootOverride, - controlUiEnabled, - gatewayRuntime, - log, - }); + const controlUiRootState = await startupTrace.measure("control-ui.root", () => + resolveGatewayControlUiRootState({ + controlUiRootOverride, + controlUiEnabled, + gatewayRuntime, + log, + }), + ); const wizardRunner = opts.wizardRunner ?? runSetupWizard; const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker(); @@ -405,7 +448,9 @@ export async function startGatewayServer( const deps = createDefaultDeps(); let runtimeState: GatewayServerLiveState | null = null; let canvasHostServer: CanvasHostServer | null = null; - const gatewayTls = await loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls")); + const gatewayTls = await startupTrace.measure("tls.runtime", () => + loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls")), + ); if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) { throw new Error(gatewayTls.error ?? "gateway tls: failed to enable"); } @@ -446,36 +491,39 @@ export async function startGatewayServer( removeChatRun, chatAbortControllers, toolEventRecipients, - } = await createGatewayRuntimeState({ - cfg: cfgAtStart, - bindHost, - port, - controlUiEnabled, - controlUiBasePath, - controlUiRoot: controlUiRootState, - openAiChatCompletionsEnabled, - openAiChatCompletionsConfig, - openResponsesEnabled, - openResponsesConfig, - strictTransportSecurityHeader, - resolvedAuth, - rateLimiter: authRateLimiter, - gatewayTls, - getResolvedAuth, - hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig, - getHookClientIpConfig: () => runtimeState?.hookClientIpConfig ?? initialHookClientIpConfig, - pluginRegistry, - pinChannelRegistry: !minimalTestGateway, - deps, - canvasRuntime, - canvasHostEnabled, - allowCanvasHostInTests: opts.allowCanvasHostInTests, - logCanvas, - log, - logHooks, - logPlugins, - getReadiness, - }); + } = await startupTrace.measure("runtime.state", () => + createGatewayRuntimeState({ + cfg: cfgAtStart, + bindHost, + port, + controlUiEnabled, + controlUiBasePath, + controlUiRoot: controlUiRootState, + openAiChatCompletionsEnabled, + openAiChatCompletionsConfig, + openResponsesEnabled, + openResponsesConfig, + strictTransportSecurityHeader, + resolvedAuth, + rateLimiter: authRateLimiter, + gatewayTls, + getResolvedAuth, + hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig, + getHookClientIpConfig: () => runtimeState?.hookClientIpConfig ?? initialHookClientIpConfig, + pluginRegistry, + pinChannelRegistry: !minimalTestGateway, + deps, + canvasRuntime, + canvasHostEnabled, + allowCanvasHostInTests: opts.allowCanvasHostInTests, + logCanvas, + log, + logHooks, + logPlugins, + getReadiness, + }), + ); + startupTrace.mark("http.bound"); const { nodeRegistry, nodePresenceTimers, @@ -559,40 +607,42 @@ export async function startGatewayServer( }; try { - const earlyRuntime = await startGatewayEarlyRuntime({ - minimalTestGateway, - cfgAtStart, - port, - gatewayTls, - tailscaleMode, - log, - logDiscovery, - nodeRegistry, - broadcast, - nodeSendToAllSubscribed, - getPresenceVersion, - getHealthVersion, - refreshGatewayHealthSnapshot, - logHealth, - dedupe, - chatAbortControllers, - chatRunState, - chatRunBuffers, - chatDeltaSentAt, - chatDeltaLastBroadcastLen, - removeChatRun, - agentRunSeq, - nodeSendToSession, - ...(typeof cfgAtStart.media?.ttlHours === "number" - ? { mediaCleanupTtlMs: resolveMediaCleanupTtlMs(cfgAtStart.media.ttlHours) } - : {}), - skillsRefreshDelayMs: runtimeState.skillsRefreshDelayMs, - getSkillsRefreshTimer: () => runtimeState.skillsRefreshTimer, - setSkillsRefreshTimer: (timer) => { - runtimeState.skillsRefreshTimer = timer; - }, - loadConfig, - }); + const earlyRuntime = await startupTrace.measure("runtime.early", () => + startGatewayEarlyRuntime({ + minimalTestGateway, + cfgAtStart, + port, + gatewayTls, + tailscaleMode, + log, + logDiscovery, + nodeRegistry, + broadcast, + nodeSendToAllSubscribed, + getPresenceVersion, + getHealthVersion, + refreshGatewayHealthSnapshot, + logHealth, + dedupe, + chatAbortControllers, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, + chatDeltaLastBroadcastLen, + removeChatRun, + agentRunSeq, + nodeSendToSession, + ...(typeof cfgAtStart.media?.ttlHours === "number" + ? { mediaCleanupTtlMs: resolveMediaCleanupTtlMs(cfgAtStart.media.ttlHours) } + : {}), + skillsRefreshDelayMs: runtimeState.skillsRefreshDelayMs, + getSkillsRefreshTimer: () => runtimeState.skillsRefreshTimer, + setSkillsRefreshTimer: (timer) => { + runtimeState.skillsRefreshTimer = timer; + }, + loadConfig, + }), + ); runtimeState.bonjourStop = earlyRuntime.bonjourStop; runtimeState.skillsChangeUnsub = earlyRuntime.skillsChangeUnsub; if (earlyRuntime.maintenance) { @@ -747,30 +797,33 @@ export async function startGatewayServer( stopGatewayUpdateCheck: runtimeState.stopGatewayUpdateCheck, tailscaleCleanup: runtimeState.tailscaleCleanup, pluginServices: runtimeState.pluginServices, - } = await startGatewayPostAttachRuntime({ - minimalTestGateway, - cfgAtStart, - bindHost, - bindHosts: httpBindHosts, - port, - tlsEnabled: gatewayTls.enabled, - log, - isNixMode, - startupStartedAt: opts.startupStartedAt, - broadcast, - tailscaleMode, - resetOnExit: tailscaleConfig.resetOnExit ?? false, - controlUiBasePath, - logTailscale, - gatewayPluginConfigAtStart, - pluginRegistry, - defaultWorkspaceDir, - deps, - startChannels, - logHooks, - logChannels, - unavailableGatewayMethods, - })); + } = await startupTrace.measure("runtime.post-attach", () => + startGatewayPostAttachRuntime({ + minimalTestGateway, + cfgAtStart, + bindHost, + bindHosts: httpBindHosts, + port, + tlsEnabled: gatewayTls.enabled, + log, + isNixMode, + startupStartedAt: opts.startupStartedAt, + broadcast, + tailscaleMode, + resetOnExit: tailscaleConfig.resetOnExit ?? false, + controlUiBasePath, + logTailscale, + gatewayPluginConfigAtStart, + pluginRegistry, + defaultWorkspaceDir, + deps, + startChannels, + logHooks, + logChannels, + unavailableGatewayMethods, + }), + )); + startupTrace.mark("ready"); // Keep scheduled work inert until post-attach sidecars finish. const activated = activateGatewayScheduledServices({ diff --git a/src/hooks/configured.ts b/src/hooks/configured.ts new file mode 100644 index 00000000000..7a43658cd76 --- /dev/null +++ b/src/hooks/configured.ts @@ -0,0 +1,34 @@ +import type { HookConfig, HookInstallRecord } from "../config/types.hooks.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getLegacyInternalHookHandlers } from "./legacy-config.js"; + +function hasEnabledEntry(entries: Record | undefined): boolean { + if (!entries) { + return false; + } + return Object.values(entries).some((entry) => entry?.enabled !== false); +} + +function hasConfiguredInstalls(installs: Record | undefined): boolean { + return installs ? Object.keys(installs).length > 0 : false; +} + +export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { + const internal = config.hooks?.internal; + if (!internal || internal.enabled === false) { + return false; + } + if (internal.enabled === true) { + return true; + } + if (hasEnabledEntry(internal.entries)) { + return true; + } + if ((internal.load?.extraDirs ?? []).some((dir) => dir.trim().length > 0)) { + return true; + } + if (hasConfiguredInstalls(internal.installs)) { + return true; + } + return getLegacyInternalHookHandlers(config).length > 0; +} diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index 05216debb84..4ae613979b2 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -7,6 +7,7 @@ import { setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; import { stripAnsi } from "../terminal/ansi.js"; import { captureEnv } from "../test-utils/env.js"; +import { hasConfiguredInternalHooks } from "./configured.js"; import { clearInternalHooks, getRegisteredEventKeys, @@ -133,6 +134,25 @@ describe("loader", () => { }); describe("loadInternalHooks", () => { + it("detects configured internal hook surfaces", () => { + expect(hasConfiguredInternalHooks({} satisfies OpenClawConfig)).toBe(false); + expect( + hasConfiguredInternalHooks({ + hooks: { internal: { entries: { "session-memory": { enabled: true } } } }, + } satisfies OpenClawConfig), + ).toBe(true); + expect( + hasConfiguredInternalHooks({ + hooks: { internal: { entries: { "session-memory": { enabled: false } } } }, + } satisfies OpenClawConfig), + ).toBe(false); + expect( + hasConfiguredInternalHooks({ + hooks: { internal: { load: { extraDirs: ["/tmp/hooks"] } } }, + } satisfies OpenClawConfig), + ).toBe(true); + }); + const createLegacyHandlerConfig = () => createEnabledHooksConfig([ { @@ -172,10 +192,7 @@ describe("loader", () => { } }); - it("should treat missing hooks.internal.enabled as enabled (default-on)", async () => { - // Empty config should NOT skip loading — it should attempt discovery. - // With no discoverable hooks in the temp dir (bundled dir is overridden - // to /nonexistent), this returns 0 but does NOT bail at the guard. + it("skips hook discovery until internal hooks are configured", async () => { for (const cfg of [ {} satisfies OpenClawConfig, { hooks: {} } satisfies OpenClawConfig, diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index bd3ef5311fb..e7153918784 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -14,6 +14,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { shouldIncludeHook } from "./config.js"; +import { hasConfiguredInternalHooks } from "./configured.js"; import { buildImportUrl } from "./import-url.js"; import type { InternalHookHandler } from "./internal-hooks.js"; import { registerInternalHook, unregisterInternalHook } from "./internal-hooks.js"; @@ -86,8 +87,7 @@ export async function loadInternalHooks( ): Promise { resetLoadedInternalHooks(); - // Hooks are on by default; only skip when explicitly disabled. - if (cfg.hooks?.internal?.enabled === false) { + if (!hasConfiguredInternalHooks(cfg)) { return 0; } diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index ba989a9573b..15754456b27 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -320,6 +320,21 @@ function hasPluginSdkSubpathArtifact(packageRoot: string, subpath: string) { ); } +function listDistPluginSdkArtifactSubpaths(packageRoot: string): Set { + try { + const distPluginSdkDir = path.join(packageRoot, "dist", "plugin-sdk"); + return new Set( + fs + .readdirSync(distPluginSdkDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) + .map((entry) => entry.name.slice(0, -".js".length)) + .filter((subpath) => isSafePluginSdkSubpathSegment(subpath)), + ); + } catch { + return new Set(); + } +} + function listPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] { if (!shouldIncludePrivateLocalOnlyPluginSdkSubpaths()) { return []; @@ -389,6 +404,9 @@ export function resolvePluginSdkScopedAliasMap( return cached; } const aliasMap: Record = {}; + const distPluginSdkArtifacts = orderedKinds.includes("dist") + ? listDistPluginSdkArtifactSubpaths(packageRoot) + : new Set(); for (const subpath of listPluginSdkExportedSubpaths({ modulePath, argv1: params.argv1, @@ -397,6 +415,9 @@ export function resolvePluginSdkScopedAliasMap( })) { for (const kind of orderedKinds) { if (kind === "dist") { + if (!distPluginSdkArtifacts.has(subpath)) { + continue; + } const candidate = path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`); if (isUsableDistPluginSdkArtifact(candidate)) { for (const packageName of PLUGIN_SDK_PACKAGE_NAMES) { diff --git a/src/security/audit-extra.summary.ts b/src/security/audit-extra.summary.ts index 416a69de0dd..edada8549e1 100644 --- a/src/security/audit-extra.summary.ts +++ b/src/security/audit-extra.summary.ts @@ -9,6 +9,7 @@ import { } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; +import { hasConfiguredInternalHooks } from "../hooks/configured.js"; import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; @@ -178,7 +179,7 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi const group = summarizeGroupPolicy(cfg); const elevated = cfg.tools?.elevated?.enabled !== false; const webhooksEnabled = cfg.hooks?.enabled === true; - const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false; + const internalHooksEnabled = hasConfiguredInternalHooks(cfg); const browserEnabled = cfg.browser?.enabled ?? true; const detail = diff --git a/src/security/audit-extra.sync.test.ts b/src/security/audit-extra.sync.test.ts index 788c1244f67..e8dfc9bf73f 100644 --- a/src/security/audit-extra.sync.test.ts +++ b/src/security/audit-extra.sync.test.ts @@ -27,9 +27,9 @@ describe("collectAttackSurfaceSummaryFindings", () => { expectedDetail: ["hooks.webhooks: enabled", "hooks.internal: enabled"], }, { - name: "reports internal hooks as enabled by default and webhooks as disabled when neither is configured", + name: "reports internal hooks as disabled until configured", cfg: {} satisfies OpenClawConfig, - expectedDetail: ["hooks.webhooks: disabled", "hooks.internal: enabled"], + expectedDetail: ["hooks.webhooks: disabled", "hooks.internal: disabled"], }, { name: "reports internal hooks as disabled when explicitly set to false", diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 163822b6cd6..27a2c1b9553 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -149,6 +149,12 @@ describe("resolveBuildAllSteps", () => { expect(step?.cache).toBeUndefined(); }); + it("does not cache hook metadata over compiled hook handlers", () => { + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "copy-hook-metadata"); + expect(step).toBeTruthy(); + expect(step?.cache).toBeUndefined(); + }); + it("rejects unknown build profiles", () => { expect(() => resolveBuildAllSteps("wat")).toThrow("Unknown build profile: wat"); });