perf: defer unconfigured gateway hooks

This commit is contained in:
Peter Steinberger
2026-04-20 19:46:13 +01:00
parent ee54a8d298
commit cf7b906216
14 changed files with 443 additions and 253 deletions

View File

@@ -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 <name>`, 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:

View File

@@ -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:**

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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"] }),
);

View File

@@ -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<void> {
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> = T | Promise<T>;
type GatewayPostAttachRuntimeDeps = {
getGlobalHookRunner: typeof getGlobalHookRunner;
getGlobalHookRunner: () => Awaitable<ReturnType<typeof getGlobalHookRunner>>;
logGatewayStartup: typeof logGatewayStartup;
scheduleGatewayUpdateCheck: typeof scheduleGatewayUpdateCheck;
scheduleGatewayUpdateCheck: (
...args: Parameters<typeof scheduleGatewayUpdateCheck>
) => Awaitable<ReturnType<typeof scheduleGatewayUpdateCheck>>;
startGatewaySidecars: typeof startGatewaySidecars;
startGatewayTailscaleExposure: typeof startGatewayTailscaleExposure;
startGatewayTailscaleExposure: (
...args: Parameters<typeof startGatewayTailscaleExposure>
) => ReturnType<typeof startGatewayTailscaleExposure>;
};
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)}`);

View File

@@ -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<T>(name: string, run: () => Promise<T> | T): Promise<T> {
const before = performance.now();
try {
return await run();
} finally {
const now = performance.now();
emit(name, now - before, now - started);
last = now;
}
},
};
}
type AuthRateLimitConfig = Parameters<typeof createAuthRateLimiter>[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({

34
src/hooks/configured.ts Normal file
View File

@@ -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<string, HookConfig> | undefined): boolean {
if (!entries) {
return false;
}
return Object.values(entries).some((entry) => entry?.enabled !== false);
}
function hasConfiguredInstalls(installs: Record<string, HookInstallRecord> | 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;
}

View File

@@ -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,

View File

@@ -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<number> {
resetLoadedInternalHooks();
// Hooks are on by default; only skip when explicitly disabled.
if (cfg.hooks?.internal?.enabled === false) {
if (!hasConfiguredInternalHooks(cfg)) {
return 0;
}

View File

@@ -320,6 +320,21 @@ function hasPluginSdkSubpathArtifact(packageRoot: string, subpath: string) {
);
}
function listDistPluginSdkArtifactSubpaths(packageRoot: string): Set<string> {
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<string, string> = {};
const distPluginSdkArtifacts = orderedKinds.includes("dist")
? listDistPluginSdkArtifactSubpaths(packageRoot)
: new Set<string>();
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) {

View File

@@ -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 =

View File

@@ -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",

View File

@@ -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");
});