import { randomUUID } from "node:crypto"; import { performance } from "node:perf_hooks"; import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginLogger } from "../plugins/types.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; import type { ErrorShape } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/version.js"; import type { GatewayRequestContext, GatewayRequestHandler, GatewayRequestOptions, } from "./server-methods/types.js"; // ── Fallback gateway context for non-WS paths (Telegram, WhatsApp, etc.) ── // The WS path sets a per-request scope via AsyncLocalStorage, but channel // adapters (Telegram polling, etc.) invoke the agent directly without going // through handleGatewayRequest. We store the gateway context at startup so // dispatchGatewayMethod can use it as a fallback. const FALLBACK_GATEWAY_CONTEXT_STATE_KEY: unique symbol = Symbol.for( "openclaw.fallbackGatewayContextState", ); type FallbackGatewayContextState = { context: GatewayRequestContext | undefined; resolveContext: (() => GatewayRequestContext | undefined) | undefined; }; const getFallbackGatewayContextState = () => resolveGlobalSingleton(FALLBACK_GATEWAY_CONTEXT_STATE_KEY, () => ({ context: undefined, resolveContext: undefined, })); export function setFallbackGatewayContext(ctx: GatewayRequestContext): () => void { const fallbackGatewayContextState = getFallbackGatewayContextState(); fallbackGatewayContextState.context = ctx; fallbackGatewayContextState.resolveContext = undefined; return () => { const currentFallbackGatewayContextState = getFallbackGatewayContextState(); if ( currentFallbackGatewayContextState.context === ctx && currentFallbackGatewayContextState.resolveContext === undefined ) { currentFallbackGatewayContextState.context = undefined; } }; } export function setFallbackGatewayContextResolver( resolveContext: () => GatewayRequestContext | undefined, ): () => void { const fallbackGatewayContextState = getFallbackGatewayContextState(); fallbackGatewayContextState.context = undefined; fallbackGatewayContextState.resolveContext = resolveContext; return () => { const currentFallbackGatewayContextState = getFallbackGatewayContextState(); if (currentFallbackGatewayContextState.resolveContext === resolveContext) { currentFallbackGatewayContextState.context = undefined; currentFallbackGatewayContextState.resolveContext = undefined; } }; } export function clearFallbackGatewayContext(): void { const fallbackGatewayContextState = getFallbackGatewayContextState(); fallbackGatewayContextState.context = undefined; fallbackGatewayContextState.resolveContext = undefined; } function getFallbackGatewayContext(): GatewayRequestContext | undefined { const fallbackGatewayContextState = getFallbackGatewayContextState(); const resolved = fallbackGatewayContextState.resolveContext?.(); return resolved ?? fallbackGatewayContextState.context; } type PluginSubagentOverridePolicy = { allowModelOverride: boolean; allowAnyModel: boolean; hasConfiguredAllowlist: boolean; allowedModels: Set; }; type PluginSubagentPolicyState = { policies: Record; }; const PLUGIN_SUBAGENT_POLICY_STATE_KEY: unique symbol = Symbol.for( "openclaw.pluginSubagentOverridePolicyState", ); const getPluginSubagentPolicyState = () => resolveGlobalSingleton(PLUGIN_SUBAGENT_POLICY_STATE_KEY, () => ({ policies: {}, })); function normalizeAllowedModelRef(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { return null; } if (trimmed === "*") { return "*"; } const slash = trimmed.indexOf("/"); if (slash <= 0 || slash >= trimmed.length - 1) { return null; } const providerRaw = trimmed.slice(0, slash).trim(); const modelRaw = trimmed.slice(slash + 1).trim(); if (!providerRaw || !modelRaw) { return null; } const normalized = normalizeModelRef(providerRaw, modelRaw); return `${normalized.provider}/${normalized.model}`; } export function setPluginSubagentOverridePolicies(cfg: OpenClawConfig): void { const pluginSubagentPolicyState = getPluginSubagentPolicyState(); const normalized = normalizePluginsConfig(cfg.plugins); const policies: PluginSubagentPolicyState["policies"] = {}; for (const [pluginId, entry] of Object.entries(normalized.entries)) { const allowModelOverride = entry.subagent?.allowModelOverride === true; const hasConfiguredAllowlist = entry.subagent?.hasAllowedModelsConfig === true; const configuredAllowedModels = entry.subagent?.allowedModels ?? []; const allowedModels = new Set(); let allowAnyModel = false; for (const modelRef of configuredAllowedModels) { const normalizedModelRef = normalizeAllowedModelRef(modelRef); if (!normalizedModelRef) { continue; } if (normalizedModelRef === "*") { allowAnyModel = true; continue; } allowedModels.add(normalizedModelRef); } if ( !allowModelOverride && !hasConfiguredAllowlist && allowedModels.size === 0 && !allowAnyModel ) { continue; } policies[pluginId] = { allowModelOverride, allowAnyModel, hasConfiguredAllowlist, allowedModels, }; } pluginSubagentPolicyState.policies = policies; } function authorizeFallbackModelOverride(params: { pluginId?: string; provider?: string; model?: string; }): { allowed: true } | { allowed: false; reason: string } { const pluginSubagentPolicyState = getPluginSubagentPolicyState(); const pluginId = params.pluginId?.trim(); if (!pluginId) { return { allowed: false, reason: "provider/model override requires plugin identity in fallback subagent runs.", }; } const policy = pluginSubagentPolicyState.policies[pluginId]; if (!policy?.allowModelOverride) { return { allowed: false, reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests. ` + "See https://docs.openclaw.ai/tools/plugin#runtime-helpers and search for: " + "plugins.entries..subagent.allowModelOverride", }; } if (policy.allowAnyModel) { return { allowed: true }; } if (policy.hasConfiguredAllowlist && policy.allowedModels.size === 0) { return { allowed: false, reason: `plugin "${pluginId}" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.`, }; } if (policy.allowedModels.size === 0) { return { allowed: true }; } const requestedModelRef = resolveRequestedFallbackModelRef(params); if (!requestedModelRef) { return { allowed: false, reason: "fallback provider/model overrides that use an allowlist must resolve to a canonical provider/model target.", }; } if (policy.allowedModels.has(requestedModelRef)) { return { allowed: true }; } return { allowed: false, reason: `model override "${requestedModelRef}" is not allowlisted for plugin "${pluginId}".`, }; } function resolveRequestedFallbackModelRef(params: { provider?: string; model?: string; }): string | null { if (params.provider && params.model) { const normalizedRequest = normalizeModelRef(params.provider, params.model); return `${normalizedRequest.provider}/${normalizedRequest.model}`; } const rawModel = params.model?.trim(); if (!rawModel || !rawModel.includes("/")) { return null; } const parsed = parseModelRef(rawModel, ""); if (!parsed?.provider || !parsed.model) { return null; } return `${parsed.provider}/${parsed.model}`; } // ── Internal gateway dispatch for plugin runtime ──────────────────── function createSyntheticOperatorClient(params?: { allowModelOverride?: boolean; pluginRuntimeOwnerId?: string; scopes?: string[]; }): GatewayRequestOptions["client"] { const pluginRuntimeOwnerId = typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim() ? params.pluginRuntimeOwnerId.trim() : undefined; return { connect: { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, version: "internal", platform: "node", mode: GATEWAY_CLIENT_MODES.BACKEND, }, role: "operator", scopes: params?.scopes ?? [WRITE_SCOPE], }, internal: { allowModelOverride: params?.allowModelOverride === true, ...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}), ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), }, }; } function hasAdminScope(client: GatewayRequestOptions["client"] | undefined): boolean { const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; return scopes.includes(ADMIN_SCOPE); } function canClientUseModelOverride(client: GatewayRequestOptions["client"]): boolean { return hasAdminScope(client) || client?.internal?.allowModelOverride === true; } function mergeGatewayClientInternal( client: GatewayRequestOptions["client"] | undefined, internal: NonNullable["internal"], ): GatewayRequestOptions["client"] { if (!client || !internal) { return client ?? null; } return { ...client, internal: { ...client.internal, ...internal, }, }; } type DispatchGatewayMethodInProcessOptions = { allowSyntheticModelOverride?: boolean; disableSyntheticClient?: boolean; expectFinal?: boolean; forceSyntheticClient?: boolean; pluginRuntimeOwnerId?: string; requireScopedClient?: boolean; syntheticScopes?: string[]; timeoutMs?: number; }; export type GatewayMethodDispatchResponse = { ok: boolean; payload?: unknown; error?: ErrorShape; meta?: Record; }; function unwrapGatewayMethodDispatchResponse( method: string, response: GatewayMethodDispatchResponse, ): unknown { if (!response.ok) { throw new Error(response.error?.message ?? `Gateway method "${method}" failed.`); } return response.payload; } export async function dispatchGatewayMethodInProcessRaw( method: string, params: unknown, options?: DispatchGatewayMethodInProcessOptions, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); const context = scope?.context ?? getFallbackGatewayContext(); const isWebchatConnect = scope?.isWebchatConnect ?? (() => false); if (!context) { throw new Error( `In-process gateway dispatch requires a gateway request scope (method: ${method}). No scope set and no fallback context available.`, ); } if (options?.requireScopedClient === true && !scope?.client) { throw new Error( `In-process gateway dispatch requires an authenticated plugin request scope (method: ${method}).`, ); } let firstResponse: GatewayMethodDispatchResponse | undefined; let finalResponse: GatewayMethodDispatchResponse | undefined; let resolveFinalResponse: ((response: GatewayMethodDispatchResponse) => void) | undefined; const { handleGatewayRequest } = await import("./server-methods.js"); const pluginRuntimeOwnerId = typeof options?.pluginRuntimeOwnerId === "string" && options.pluginRuntimeOwnerId.trim() ? options.pluginRuntimeOwnerId.trim() : undefined; const syntheticClient = createSyntheticOperatorClient({ allowModelOverride: options?.allowSyntheticModelOverride === true, ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), scopes: options?.syntheticScopes, }); const scopedClient = mergeGatewayClientInternal( scope?.client, pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : undefined, ); if (options?.disableSyntheticClient === true && !scopedClient) { throw new Error(`In-process gateway dispatch requires a scoped client (method: ${method}).`); } await handleGatewayRequest({ req: { type: "req", id: `plugin-subagent-${randomUUID()}`, method, params, }, client: options?.forceSyntheticClient === true ? syntheticClient : (scopedClient ?? (options?.disableSyntheticClient === true ? null : syntheticClient)), isWebchatConnect, respond: (ok, payload, error, meta) => { const response = { ok, payload, error, ...(meta ? { meta } : {}) }; if (!firstResponse) { firstResponse = response; return; } if (!finalResponse) { finalResponse = response; resolveFinalResponse?.(response); } }, context, }); if (!firstResponse) { throw new Error(`Gateway method "${method}" completed without a response.`); } const firstPayload = firstResponse.payload as { status?: unknown } | undefined; if (options?.expectFinal !== true || firstPayload?.status !== "accepted") { return firstResponse; } const final = finalResponse ?? (await new Promise((resolve, reject) => { resolveFinalResponse = resolve; const timeoutMs = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) ? resolveSafeTimeoutDelayMs(options.timeoutMs) : undefined; const timeout = timeoutMs === undefined ? undefined : setTimeout(() => { reject(new Error(`gateway request timeout for ${method}`)); }, timeoutMs); if (finalResponse) { if (timeout) { clearTimeout(timeout); } resolve(finalResponse); return; } resolveFinalResponse = (response) => { if (timeout) { clearTimeout(timeout); } resolve(response); }; })); return final; } async function dispatchGatewayMethod( method: string, params: unknown, options?: DispatchGatewayMethodInProcessOptions, ): Promise { const response = await dispatchGatewayMethodInProcessRaw(method, params, options); return unwrapGatewayMethodDispatchResponse(method, response) as T; } export async function dispatchGatewayMethodInProcess( method: string, params: Record, options?: DispatchGatewayMethodInProcessOptions, ): Promise { return await dispatchGatewayMethod(method, params, options); } export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => { const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>("sessions.get", { key: params.sessionKey, ...(params.limit != null && { limit: params.limit }), }); return { messages: Array.isArray(payload?.messages) ? payload.messages : [] }; }; return { async run(params) { const scope = getPluginRuntimeGatewayRequestScope(); const pluginId = typeof scope?.pluginId === "string" && scope.pluginId.trim() ? scope.pluginId.trim() : undefined; const overrideRequested = Boolean(params.provider || params.model); const hasRequestScopeClient = Boolean(scope?.client); let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); let allowSyntheticModelOverride = false; if (overrideRequested && !allowOverride && !hasRequestScopeClient) { const fallbackAuth = authorizeFallbackModelOverride({ pluginId: scope?.pluginId, provider: params.provider, model: params.model, }); if (!fallbackAuth.allowed) { throw new Error(fallbackAuth.reason); } allowOverride = true; allowSyntheticModelOverride = true; } if (overrideRequested && !allowOverride) { throw new Error("provider/model override is not authorized for this plugin subagent run."); } const payload = await dispatchGatewayMethod<{ runId?: string }>( "agent", { sessionKey: params.sessionKey, message: params.message, deliver: params.deliver ?? false, ...(allowOverride && params.provider && { provider: params.provider }), ...(allowOverride && params.model && { model: params.model }), ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), ...(params.lane && { lane: params.lane }), ...(params.lightContext === true && { bootstrapContextMode: "lightweight" }), // The gateway `agent` schema requires `idempotencyKey: NonEmptyString`, // so fall back to a generated UUID when the caller omits it. Without // this, plugin subagent runs (for example memory-core dreaming // narrative) silently fail schema validation at the gateway. idempotencyKey: params.idempotencyKey || randomUUID(), }, { allowSyntheticModelOverride, ...(pluginId ? { pluginRuntimeOwnerId: pluginId } : {}), }, ); const runId = payload?.runId; if (typeof runId !== "string" || !runId) { throw new Error("Gateway agent method returned an invalid runId."); } return { runId }; }, async waitForRun(params) { const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>( "agent.wait", { runId: params.runId, ...(params.timeoutMs != null && { timeoutMs: params.timeoutMs }), }, ); const status = payload?.status; if (status !== "ok" && status !== "error" && status !== "timeout") { throw new Error(`Gateway agent.wait returned unexpected status: ${status}`); } return { status, ...(typeof payload?.error === "string" && payload.error && { error: payload.error }), }; }, getSessionMessages, async getSession(params) { return getSessionMessages(params); }, async deleteSession(params) { const scope = getPluginRuntimeGatewayRequestScope(); const pluginId = typeof scope?.pluginId === "string" && scope.pluginId.trim() ? scope.pluginId.trim() : undefined; const pluginOwnedCleanupOptions = pluginId ? { pluginRuntimeOwnerId: pluginId, ...(!hasAdminScope(scope?.client) ? { forceSyntheticClient: true, syntheticScopes: [ADMIN_SCOPE], } : {}), } : undefined; await dispatchGatewayMethod( "sessions.delete", { key: params.sessionKey, deleteTranscript: params.deleteTranscript ?? true, }, pluginOwnedCleanupOptions, ); }, }; } export function createGatewayNodesRuntime(): PluginRuntime["nodes"] { return { async list(params) { const payload = await dispatchGatewayMethod<{ nodes?: unknown[] }>("node.list", {}); const nodes = Array.isArray(payload?.nodes) ? payload.nodes : []; const filteredNodes = params?.connected === true ? nodes.filter( (node) => node !== null && typeof node === "object" && (node as { connected?: unknown }).connected === true, ) : nodes; return { nodes: filteredNodes as Awaited>["nodes"], }; }, async invoke(params) { const payload = await dispatchGatewayMethod("node.invoke", { nodeId: params.nodeId, command: params.command, ...(params.params !== undefined && { params: params.params }), timeoutMs: params.timeoutMs, idempotencyKey: params.idempotencyKey || randomUUID(), }); return payload; }, }; } // ── Plugin loading ────────────────────────────────────────────────── function createGatewayPluginRegistrationLogger(params?: { suppressInfoLogs?: boolean; }): PluginLogger { const logger = createPluginRuntimeLoaderLogger(); if (params?.suppressInfoLogs !== true) { return logger; } return { ...logger, info: (_message: string) => undefined, }; } export function loadGatewayPlugins(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; autoEnabledReasons?: Readonly>; workspaceDir: string; log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug: (msg: string) => void; }; coreGatewayHandlers?: Record; coreGatewayMethodNames?: readonly string[]; hostServices?: PluginRegistryParams["hostServices"]; baseMethods: string[]; pluginIds?: string[]; pluginLookUpTable?: PluginLookUpTable; preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; startupTrace?: { detail: (name: string, metrics: ReadonlyArray) => void; }; }) { const started = performance.now(); const activationAutoEnabled = params.activationSourceConfig !== undefined && params.autoEnabledReasons === undefined ? applyPluginAutoEnable({ config: params.activationSourceConfig, env: process.env, ...(params.pluginLookUpTable?.manifestRegistry ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } : {}), }) : undefined; const autoEnableMs = performance.now() - started; const autoEnabled = params.activationSourceConfig !== undefined ? { config: params.cfg, changes: activationAutoEnabled?.changes ?? [], autoEnabledReasons: params.autoEnabledReasons ?? activationAutoEnabled?.autoEnabledReasons ?? {}, } : params.autoEnabledReasons !== undefined ? { config: params.cfg, changes: [], autoEnabledReasons: params.autoEnabledReasons, } : applyPluginAutoEnable({ config: params.cfg, env: process.env, ...(params.pluginLookUpTable?.manifestRegistry ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } : {}), }); const resolvedConfigMs = performance.now() - started; const resolvedConfig = autoEnabled.config; const pluginIds = params.pluginIds ?? [ ...( params.pluginLookUpTable ?? loadPluginLookUpTable({ config: resolvedConfig, activationSourceConfig: params.activationSourceConfig, workspaceDir: params.workspaceDir, env: process.env, }) ).startup.pluginIds, ]; const pluginIdsMs = performance.now() - started; if (pluginIds.length === 0) { clearActivatedPluginRuntimeState(); const pluginRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(pluginRegistry, undefined, "gateway-bindable", params.workspaceDir); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], ["resolvedConfigMs", resolvedConfigMs], ["pluginIdsMs", pluginIdsMs], ["loadMs", 0], ["pluginIds", "0"], ["pluginCount", 0], ["gatewayHandlerCount", 0], ]); return { pluginRegistry, gatewayMethods: [...params.baseMethods], }; } const beforeLoad = performance.now(); const loaderStatsBefore = getPluginModuleLoaderStats(); const pluginRegistry = loadOpenClawPlugins({ config: resolvedConfig, activationSourceConfig: params.activationSourceConfig ?? params.cfg, autoEnabledReasons: autoEnabled.autoEnabledReasons, workspaceDir: params.workspaceDir, onlyPluginIds: pluginIds, logger: createGatewayPluginRegistrationLogger({ suppressInfoLogs: params.suppressPluginInfoLogs, }), ...(params.coreGatewayHandlers !== undefined && { coreGatewayHandlers: params.coreGatewayHandlers, }), ...(params.coreGatewayMethodNames !== undefined && { coreGatewayMethodNames: params.coreGatewayMethodNames, }), ...(params.hostServices !== undefined && { hostServices: params.hostServices, }), runtimeOptions: { allowGatewaySubagentBinding: true, }, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, preferBuiltPluginArtifacts: true, ...(params.startupTrace !== undefined && { startupTrace: params.startupTrace, }), ...(params.pluginLookUpTable?.manifestRegistry ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } : {}), }); const loadMs = performance.now() - beforeLoad; const loaderStatsAfter = getPluginModuleLoaderStats(); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], ["resolvedConfigMs", resolvedConfigMs], ["pluginIdsMs", pluginIdsMs], ["loadMs", loadMs], ["pluginIds", String(pluginIds.length)], ["pluginCount", pluginIds.length], ["gatewayHandlers", String(pluginMethods.length)], ["gatewayHandlerCount", pluginMethods.length], ["loaderCallsCount", loaderStatsAfter.calls - loaderStatsBefore.calls], ["loaderNativeHitsCount", loaderStatsAfter.nativeHits - loaderStatsBefore.nativeHits], ["loaderNativeMissesCount", loaderStatsAfter.nativeMisses - loaderStatsBefore.nativeMisses], [ "loaderSourceTransformForcedCount", loaderStatsAfter.sourceTransformForced - loaderStatsBefore.sourceTransformForced, ], [ "loaderSourceTransformFallbacksCount", loaderStatsAfter.sourceTransformFallbacks - loaderStatsBefore.sourceTransformFallbacks, ], [ "loaderTopSourceTransformTargets", loaderStatsAfter.topSourceTransformTargets .slice(0, 3) .map((entry) => `${entry.count}:${entry.target}`) .join(","), ], ]); return { pluginRegistry, gatewayMethods }; }