// Runtime contracts for approval handlers used by execution requests. import type { ChannelApprovalCapability, ChannelApprovalNativeAdapter, } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, createLazyChannelApprovalNativeRuntimeAdapter, } from "./approval-handler-adapter-runtime.js"; import type { ApprovalRequest, ApprovalResolved, ChannelApprovalCapabilityHandlerContext, ChannelApprovalKind, ChannelApprovalNativeFinalAction, ChannelApprovalNativeRuntimeAdapter, ChannelApprovalNativeRuntimeSpec, } from "./approval-handler-runtime-types.js"; import type { ChannelNativeApprovalDeliveryCallbacks, ChannelNativeApprovalTransportSpec, } from "./approval-native-runtime-types.js"; import { createChannelNativeApprovalRuntime } from "./approval-native-runtime.js"; import { normalizeApprovalRequest } from "./approval-types.js"; import { buildExpiredApprovalView, buildPendingApprovalView, buildResolvedApprovalView, } from "./approval-view-model.js"; import type { ExpiredApprovalView, PendingApprovalView, ResolvedApprovalView, } from "./approval-view-model.types.js"; import type { ExecApprovalChannelRuntime } from "./exec-approval-channel-runtime.js"; export type { ApprovalActionView, ApprovalMetadataView, ApprovalViewModel, ExecApprovalExpiredView, ExecApprovalPendingView, ExecApprovalResolvedView, ExpiredApprovalView, PendingApprovalView, PluginApprovalExpiredView, PluginApprovalPendingView, PluginApprovalResolvedView, ResolvedApprovalView, } from "./approval-view-model.types.js"; export { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, createLazyChannelApprovalNativeRuntimeAdapter, }; export type { ChannelApprovalCapabilityHandlerContext, ChannelApprovalNativeAvailabilityAdapter, ChannelApprovalNativeFinalAction, ChannelApprovalNativeInteractionAdapter, ChannelApprovalNativeObserveAdapter, ChannelApprovalNativePresentationAdapter, ChannelApprovalNativeRuntimeAdapter, ChannelApprovalNativeRuntimeSpec, ChannelApprovalNativeTransportAdapter, } from "./approval-handler-runtime-types.js"; export type ChannelApprovalHandler< TRequest extends ApprovalRequest = ApprovalRequest, TResolved extends ApprovalResolved = ApprovalResolved, > = ExecApprovalChannelRuntime; type WrappedPendingEntry = { entry: unknown; binding?: unknown; }; type ActiveApprovalEntries = { request: ApprovalRequest; approvalKind: ChannelApprovalKind; entries: WrappedPendingEntry[]; }; type WrappedPendingContent = { view: PendingApprovalView; payload: unknown; }; function consumeActiveWrappedEntries( activeEntries: Map, requestId: string, fallbackEntries: WrappedPendingEntry[], ): WrappedPendingEntry[] { const entries = activeEntries.get(requestId)?.entries ?? fallbackEntries; activeEntries.delete(requestId); return entries; } async function finalizeWrappedEntries(params: { entries: WrappedPendingEntry[]; phase: "resolved" | "expired"; request: ApprovalRequest; log: ReturnType; runEntry: (wrapped: WrappedPendingEntry) => Promise; }): Promise { for (const wrapped of params.entries) { try { await params.runEntry(wrapped); } catch (error) { params.log.error( `failed to finalize ${params.phase} native approval entry ` + `approval=${params.request.id}: ${String(error)}`, ); } } } async function unbindWrappedEntries(params: { entries: WrappedPendingEntry[]; request: ApprovalRequest; approvalKind: ChannelApprovalKind; baseContext: ChannelApprovalCapabilityHandlerContext; nativeRuntime: ChannelApprovalNativeRuntimeAdapter; log: ReturnType; }): Promise { if (!params.nativeRuntime.interactions?.unbindPending) { return; } for (const wrapped of params.entries) { if (wrapped.binding === undefined) { continue; } try { await params.nativeRuntime.interactions.unbindPending({ ...params.baseContext, entry: wrapped.entry, binding: wrapped.binding, request: params.request, approvalKind: params.approvalKind, }); } catch (error) { params.log.error( `failed to unbind stopped native approval entry ` + `approval=${params.request.id}: ${String(error)}`, ); } } } async function applyApprovalFinalAction(params: { nativeRuntime: ChannelApprovalNativeRuntimeAdapter; baseContext: ChannelApprovalCapabilityHandlerContext; wrapped: WrappedPendingEntry; request: ApprovalRequest; approvalKind: ChannelApprovalKind; result: ChannelApprovalNativeFinalAction; phase: "resolved" | "expired"; }): Promise { switch (params.result.kind) { case "update": await params.nativeRuntime.transport.updateEntry?.({ ...params.baseContext, entry: params.wrapped.entry, request: params.request, approvalKind: params.approvalKind, payload: params.result.payload, phase: params.phase, }); return; case "delete": await params.nativeRuntime.transport.deleteEntry?.({ ...params.baseContext, entry: params.wrapped.entry, phase: params.phase, }); return; case "clear-actions": await params.nativeRuntime.interactions?.clearPendingActions?.({ ...params.baseContext, entry: params.wrapped.entry, phase: params.phase, }); // `clear-actions` updates interaction controls but leaves the delivered content in place. case "leave": } } /** Adapts a strongly typed channel native approval spec into the erased runtime contract. */ export function createChannelApprovalNativeRuntimeAdapter< TPendingPayload, TPreparedTarget, TPendingEntry, TBinding = unknown, TFinalPayload = unknown, TPendingView extends PendingApprovalView = PendingApprovalView, TResolvedView extends ResolvedApprovalView = ResolvedApprovalView, TExpiredView extends ExpiredApprovalView = ExpiredApprovalView, >( spec: ChannelApprovalNativeRuntimeSpec< TPendingPayload, TPreparedTarget, TPendingEntry, TBinding, TFinalPayload, TPendingView, TResolvedView, TExpiredView >, ): ChannelApprovalNativeRuntimeAdapter< TPendingPayload, TPreparedTarget, TPendingEntry, TBinding, TFinalPayload > { return { ...(spec.eventKinds ? { eventKinds: spec.eventKinds } : {}), ...(spec.resolveApprovalKind ? { resolveApprovalKind: spec.resolveApprovalKind } : {}), availability: { isConfigured: spec.availability.isConfigured, shouldHandle: spec.availability.shouldHandle, }, presentation: { buildPendingPayload: async (params) => await spec.presentation.buildPendingPayload(params as never), buildResolvedResult: async (params) => await spec.presentation.buildResolvedResult(params as never), buildExpiredResult: async (params) => await spec.presentation.buildExpiredResult(params as never), }, transport: { prepareTarget: async (params) => await spec.transport.prepareTarget(params as never), deliverPending: async (params) => await spec.transport.deliverPending(params as never), ...(spec.transport.updateEntry ? { updateEntry: async ( params: { entry: unknown; request: ApprovalRequest; approvalKind: ChannelApprovalKind; payload: unknown; phase: "resolved" | "expired"; } & ChannelApprovalCapabilityHandlerContext, ) => await spec.transport.updateEntry?.(params as never), } : {}), ...(spec.transport.deleteEntry ? { deleteEntry: async ( params: { entry: unknown; phase: "resolved" | "expired"; } & ChannelApprovalCapabilityHandlerContext, ) => await spec.transport.deleteEntry?.(params as never), } : {}), }, ...(spec.interactions ? { interactions: { ...(spec.interactions.bindPending ? { bindPending: async (params) => (await spec.interactions!.bindPending!(params as never)) ?? null, } : {}), ...(spec.interactions.unbindPending ? { unbindPending: async (params) => await spec.interactions?.unbindPending?.(params as never), } : {}), ...(spec.interactions.clearPendingActions ? { clearPendingActions: async (params) => await spec.interactions?.clearPendingActions?.(params as never), } : {}), ...(spec.interactions.cancelDelivered ? { cancelDelivered: async (params) => await spec.interactions?.cancelDelivered?.(params as never), } : {}), }, } : {}), ...(spec.observe ? { observe: { ...(spec.observe.onDeliveryError ? { onDeliveryError: (params) => spec.observe?.onDeliveryError?.(params as never), } : {}), ...(spec.observe.onDuplicateSkipped ? { onDuplicateSkipped: (params) => spec.observe?.onDuplicateSkipped?.(params as never), } : {}), ...(spec.observe.onDelivered ? { onDelivered: (params) => spec.observe?.onDelivered?.(params as never), } : {}), ...(spec.observe.onFinalized ? { onFinalized: (params) => { // SAFETY: The factory preserves the request and lifecycle types for this adapter. return spec.observe?.onFinalized?.(params as never); }, } : {}), }, } : {}), }; } type ChannelApprovalHandlerRuntimeSpec = { label: string; clientDisplayName: string; cfg: OpenClawConfig; gatewayUrl?: string; eventKinds?: readonly ChannelApprovalKind[]; channel?: string; channelLabel?: string; accountId?: string | null; nativeAdapter?: ChannelApprovalNativeAdapter | null; /** @deprecated Trusted compatibility override; omit to derive ownership from the payload. */ resolveApprovalKind?: (request: TRequest) => ChannelApprovalKind; isConfigured: () => boolean; shouldHandle: (request: TRequest) => boolean; nowMs?: () => number; }; type ChannelApprovalHandlerContentSpec< TPendingContent, TRequest extends ApprovalRequest = ApprovalRequest, > = { buildPendingContent: (params: { request: TRequest; approvalKind: ChannelApprovalKind; nowMs: number; }) => TPendingContent | Promise; }; type ChannelApprovalHandlerTransportSpec< TPendingEntry, TPreparedTarget, TPendingContent, TRequest extends ApprovalRequest = ApprovalRequest, > = ChannelNativeApprovalTransportSpec; type ChannelApprovalHandlerLifecycleSpec< TPendingEntry, TPreparedTarget, TPendingContent, TRequest extends ApprovalRequest = ApprovalRequest, TResolved extends ApprovalResolved = ApprovalResolved, > = ChannelNativeApprovalDeliveryCallbacks< TPendingEntry, TPreparedTarget, TPendingContent, TRequest > & { finalizeResolved: (params: { request: TRequest; resolved: TResolved; entries: TPendingEntry[]; }) => Promise; finalizeExpired?: (params: { request: TRequest; entries: TPendingEntry[] }) => Promise; onStopped?: () => Promise | void; }; /** Adapter contract used by core to run a channel's native approval delivery lifecycle. */ export type ChannelApprovalHandlerAdapter< TPendingEntry, TPreparedTarget, TPendingContent, TRequest extends ApprovalRequest = ApprovalRequest, TResolved extends ApprovalResolved = ApprovalResolved, > = { runtime: ChannelApprovalHandlerRuntimeSpec; content: ChannelApprovalHandlerContentSpec; transport: ChannelApprovalHandlerTransportSpec< TPendingEntry, TPreparedTarget, TPendingContent, TRequest >; lifecycle: ChannelApprovalHandlerLifecycleSpec< TPendingEntry, TPreparedTarget, TPendingContent, TRequest, TResolved >; }; /** Creates the shared approval handler runtime from channel-specific content and transport hooks. */ export function createChannelApprovalHandler< TPendingEntry, TPreparedTarget, TPendingContent, TRequest extends ApprovalRequest = ApprovalRequest, TResolved extends ApprovalResolved = ApprovalResolved, >( adapter: ChannelApprovalHandlerAdapter< TPendingEntry, TPreparedTarget, TPendingContent, TRequest, TResolved >, ): ChannelApprovalHandler { return createChannelNativeApprovalRuntime< TPendingEntry, TPreparedTarget, TPendingContent, TRequest, TResolved >({ label: adapter.runtime.label, clientDisplayName: adapter.runtime.clientDisplayName, cfg: adapter.runtime.cfg, gatewayUrl: adapter.runtime.gatewayUrl, eventKinds: adapter.runtime.eventKinds, channel: adapter.runtime.channel, channelLabel: adapter.runtime.channelLabel, accountId: adapter.runtime.accountId, nativeAdapter: adapter.runtime.nativeAdapter, ...(adapter.runtime.resolveApprovalKind ? { resolveApprovalKind: adapter.runtime.resolveApprovalKind } : {}), isConfigured: adapter.runtime.isConfigured, shouldHandle: adapter.runtime.shouldHandle, nowMs: adapter.runtime.nowMs, buildPendingContent: adapter.content.buildPendingContent, prepareTarget: adapter.transport.prepareTarget, deliverTarget: adapter.transport.deliverTarget, onDeliveryError: adapter.lifecycle.onDeliveryError, onDuplicateSkipped: adapter.lifecycle.onDuplicateSkipped, onDelivered: adapter.lifecycle.onDelivered, finalizeResolved: adapter.lifecycle.finalizeResolved, finalizeExpired: adapter.lifecycle.finalizeExpired, onStopped: adapter.lifecycle.onStopped, }); } /** Builds a shared approval handler from a plugin approval capability, or null when unsupported. */ export async function createChannelApprovalHandlerFromCapability(params: { capability?: Pick | null; label: string; clientDisplayName: string; channel: string; channelLabel: string; cfg: OpenClawConfig; accountId?: string | null; gatewayUrl?: string; context?: unknown; nowMs?: () => number; }): Promise { const nativeRuntime = params.capability?.nativeRuntime; if (!nativeRuntime) { return null; } const log = createSubsystemLogger(params.label); const activeEntries = new Map(); let stopped = false; const resolveApprovalKind = (request: ApprovalRequest): ChannelApprovalKind => { const normalizedRequest = normalizeApprovalRequest(request); return nativeRuntime.resolveApprovalKind?.(normalizedRequest) ?? normalizedRequest.approvalKind; }; const baseContext: ChannelApprovalCapabilityHandlerContext = { cfg: params.cfg, accountId: params.accountId, gatewayUrl: params.gatewayUrl, context: params.context, }; return createChannelApprovalHandler({ runtime: { label: params.label, clientDisplayName: params.clientDisplayName, channel: params.channel, channelLabel: params.channelLabel, cfg: params.cfg, accountId: params.accountId, gatewayUrl: params.gatewayUrl, eventKinds: nativeRuntime.eventKinds, nativeAdapter: params.capability?.native as ChannelApprovalNativeAdapter | null, ...(nativeRuntime.resolveApprovalKind ? { resolveApprovalKind: nativeRuntime.resolveApprovalKind } : {}), isConfigured: () => nativeRuntime.availability.isConfigured(baseContext), shouldHandle: (request) => { const approvalKind = resolveApprovalKind(request); return nativeRuntime.availability.shouldHandle({ ...baseContext, request, approvalKind, }); }, nowMs: params.nowMs, }, content: { buildPendingContent: async ({ request, approvalKind, nowMs }) => { const view = buildPendingApprovalView(request); return { view, payload: await nativeRuntime.presentation.buildPendingPayload({ ...baseContext, request, approvalKind, nowMs, view, }), }; }, }, transport: { prepareTarget: async ({ plannedTarget, request, approvalKind, pendingContent }) => { return await nativeRuntime.transport.prepareTarget({ ...baseContext, plannedTarget, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, }); }, deliverTarget: async ({ plannedTarget, preparedTarget, request, approvalKind, pendingContent, }) => { const entry = await nativeRuntime.transport.deliverPending({ ...baseContext, plannedTarget, preparedTarget, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, }); if (!entry) { return null; } if (stopped) { // onStopped fired between deliverPending and bindPending. The wrapped // entry is not yet in activeEntries, so there is no map leak, but // adapters that register side-effects inside deliverPending (e.g. the // Matrix reaction target store) need explicit cleanup. unbindPending // would violate its contract without a binding, so route cleanup // through the optional cancelDelivered hook, which takes the entry. await nativeRuntime.interactions?.cancelDelivered?.({ ...baseContext, entry, request, approvalKind, }); return null; } const binding = await nativeRuntime.interactions?.bindPending?.({ ...baseContext, entry, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, }); if (stopped) { if (binding !== undefined && binding !== null) { await nativeRuntime.interactions?.unbindPending?.({ ...baseContext, entry, binding, request, approvalKind, }); } else { // bindPending returned without a binding handle, but deliverPending // may have left side-effects. Adapters that wire the same store // from both deliverPending and bindPending (e.g. Matrix) drain it // via the binding branch above; this branch covers the rest. await nativeRuntime.interactions?.cancelDelivered?.({ ...baseContext, entry, request, approvalKind, }); } return null; } const wrapped: WrappedPendingEntry = { entry, ...(binding === undefined || binding === null ? {} : { binding }), }; const activeRequest = activeEntries.get(request.id) ?? { request, approvalKind, entries: [], }; activeRequest.entries.push(wrapped); activeEntries.set(request.id, activeRequest); return wrapped; }, }, lifecycle: { onDeliveryError: ({ error, plannedTarget, request, approvalKind, pendingContent }) => { nativeRuntime.observe?.onDeliveryError?.({ ...baseContext, error, plannedTarget, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, }); }, onDuplicateSkipped: ({ plannedTarget, preparedTarget, request, approvalKind, pendingContent, }) => { nativeRuntime.observe?.onDuplicateSkipped?.({ ...baseContext, plannedTarget, preparedTarget, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, }); }, onDelivered: ({ plannedTarget, preparedTarget, request, approvalKind, pendingContent, entry, }) => { nativeRuntime.observe?.onDelivered?.({ ...baseContext, plannedTarget, preparedTarget, request, approvalKind, view: pendingContent.view, pendingPayload: pendingContent.payload, entry: entry.entry, }); }, finalizeResolved: async ({ request, resolved, entries }) => { const resolvedEntries = consumeActiveWrappedEntries(activeEntries, request.id, entries); const approvalKind = resolveApprovalKind(request); const view = buildResolvedApprovalView(request, resolved); await finalizeWrappedEntries({ entries: resolvedEntries, phase: "resolved", request, log, runEntry: async (wrapped) => { if (wrapped.binding !== undefined) { await nativeRuntime.interactions?.unbindPending?.({ ...baseContext, entry: wrapped.entry, binding: wrapped.binding, request, approvalKind, }); } const result = await nativeRuntime.presentation.buildResolvedResult({ ...baseContext, request, resolved, view, entry: wrapped.entry, }); await applyApprovalFinalAction({ nativeRuntime, baseContext, wrapped, request, approvalKind, result, phase: "resolved", }); }, }); nativeRuntime.observe?.onFinalized?.({ ...baseContext, request, approvalKind, phase: "resolved", }); }, finalizeExpired: async ({ request, entries }) => { const expiredEntries = consumeActiveWrappedEntries(activeEntries, request.id, entries); const approvalKind = resolveApprovalKind(request); const view = buildExpiredApprovalView(request); await finalizeWrappedEntries({ entries: expiredEntries, phase: "expired", request, log, runEntry: async (wrapped) => { if (wrapped.binding !== undefined) { await nativeRuntime.interactions?.unbindPending?.({ ...baseContext, entry: wrapped.entry, binding: wrapped.binding, request, approvalKind, }); } const result = await nativeRuntime.presentation.buildExpiredResult({ ...baseContext, request, view, entry: wrapped.entry, }); await applyApprovalFinalAction({ nativeRuntime, baseContext, wrapped, request, approvalKind, result, phase: "expired", }); }, }); nativeRuntime.observe?.onFinalized?.({ ...baseContext, request, approvalKind, phase: "expired", }); }, onStopped: async () => { stopped = true; if (activeEntries.size === 0) { activeEntries.clear(); return; } for (const activeRequest of activeEntries.values()) { await unbindWrappedEntries({ entries: activeRequest.entries, request: activeRequest.request, approvalKind: activeRequest.approvalKind, baseContext, nativeRuntime, log, }); } activeEntries.clear(); }, }, }); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */