// Bootstraps approval handlers from channel plugin capabilities. import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.js"; import type { ChannelRuntimeSurface } from "../channels/plugins/channel-runtime-surface.types.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withGatewayNativeApprovalRuntime } from "./approval-gateway-runtime-context.js"; import type { GatewayNativeApprovalRuntime } from "./approval-gateway-runtime.types.js"; import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, createChannelApprovalHandlerFromCapability, type ChannelApprovalHandler, } from "./approval-handler-runtime.js"; import { getChannelRuntimeContext, watchChannelRuntimeContexts, } from "./channel-runtime-context.js"; import { isExecApprovalChannelRuntimeTerminalStartError } from "./exec-approval-channel-runtime.js"; type ApprovalBootstrapHandler = ChannelApprovalHandler; const APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS = 1_000; function isRetryableApprovalBootstrapStartError(error: unknown): boolean { const message = String(error); return ( message.includes("gateway readiness unavailable before approval client start") || message.includes("gateway approval client start aborted before readiness") || message.includes("gateway readiness unavailable before exec approval runtime start") || message.includes("gateway approval runtime start aborted before readiness") || message.includes("gateway event loop readiness timeout") || message.includes("gateway starting") || message.includes("code=1013") || message.includes("close code 1013") ); } function formatRetryableApprovalBootstrapStartError(error: unknown): string { const message = String(error); if (message.includes("gateway event loop readiness timeout")) { return "gateway readiness unavailable before approval handler start"; } return message; } /** Starts the native approval handler for a channel runtime context and returns its cleanup hook. */ export async function startChannelApprovalHandlerBootstrap(params: { plugin: Pick; cfg: OpenClawConfig; accountId: string; channelRuntime?: ChannelRuntimeSurface; gatewayRuntime?: GatewayNativeApprovalRuntime; logger?: ReturnType; }): Promise<() => Promise> { const capability = resolveChannelApprovalCapability(params.plugin); if (!capability?.nativeRuntime || !params.channelRuntime) { return async () => {}; } const channelLabel = params.plugin.meta.label || params.plugin.id; const logger = params.logger ?? createSubsystemLogger(`${params.plugin.id}/approval-bootstrap`); let activeGeneration = 0; let activeHandler: ApprovalBootstrapHandler | null = null; let retryTimer: NodeJS.Timeout | null = null; const invalidateActiveHandler = () => { activeGeneration += 1; }; const clearRetryTimer = () => { if (!retryTimer) { return; } clearTimeout(retryTimer); retryTimer = null; }; const stopHandler = async () => { const handler = activeHandler; activeHandler = null; if (!handler) { return; } await handler.stop(); }; const startHandlerForContext = async (context: unknown, generation: number) => { if (generation !== activeGeneration) { return; } await stopHandler(); if (generation !== activeGeneration) { return; } const handler = await withGatewayNativeApprovalRuntime(params.gatewayRuntime, () => createChannelApprovalHandlerFromCapability({ capability, label: `${params.plugin.id}/native-approvals`, clientDisplayName: `${channelLabel} Native Approvals (${params.accountId})`, channel: params.plugin.id, channelLabel, cfg: params.cfg, accountId: params.accountId, context, }), ); if (!handler) { return; } if (generation !== activeGeneration) { // Runtime contexts can unregister while the handler factory awaits; stop stale handlers. await handler.stop().catch(() => {}); return; } activeHandler = handler as ApprovalBootstrapHandler; try { await withGatewayNativeApprovalRuntime(params.gatewayRuntime, () => handler.start()); } catch (error) { if (activeHandler === handler) { activeHandler = null; } await handler.stop().catch(() => {}); throw error; } }; const spawn = (label: string, promise: Promise) => { void promise.catch((error: unknown) => { logger.error(`${label}: ${String(error)}`); }); }; const scheduleRetryForContext = (context: unknown, generation: number) => { if (generation !== activeGeneration) { return; } clearRetryTimer(); retryTimer = setTimeout(() => { retryTimer = null; if (generation !== activeGeneration) { return; } spawn( "failed to retry native approval handler", startHandlerForRegisteredContext(context, generation), ); }, APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS); retryTimer.unref?.(); }; const startHandlerForRegisteredContext = async (context: unknown, generation: number) => { try { await startHandlerForContext(context, generation); } catch (error) { if (generation === activeGeneration) { if (isExecApprovalChannelRuntimeTerminalStartError(error)) { logger.error(`native approval handler disabled: ${String(error)}`); return; } if (isRetryableApprovalBootstrapStartError(error)) { logger.warn( `native approval handler deferred until gateway readiness recovers: ${formatRetryableApprovalBootstrapStartError(error)}`, ); scheduleRetryForContext(context, generation); return; } logger.error(`failed to start native approval handler: ${String(error)}`); scheduleRetryForContext(context, generation); } } }; const unsubscribe = watchChannelRuntimeContexts({ channelRuntime: params.channelRuntime, channelId: params.plugin.id, accountId: params.accountId, capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, onEvent: (event) => { if (event.type === "registered") { clearRetryTimer(); invalidateActiveHandler(); const generation = activeGeneration; spawn( "failed to start native approval handler", startHandlerForRegisteredContext(event.context, generation), ); return; } clearRetryTimer(); invalidateActiveHandler(); spawn("failed to stop native approval handler", stopHandler()); }, }) ?? (() => {}); const existingContext = getChannelRuntimeContext({ channelRuntime: params.channelRuntime, channelId: params.plugin.id, accountId: params.accountId, capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, }); if (existingContext !== undefined) { clearRetryTimer(); invalidateActiveHandler(); const generation = activeGeneration; spawn( "failed to start native approval handler", startHandlerForRegisteredContext(existingContext, generation), ); } return async () => { unsubscribe(); clearRetryTimer(); invalidateActiveHandler(); await stopHandler(); }; }