mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 08:42:54 +00:00
Address the ClawSweeper R2 finding that the pre-bind stopped guard introduced in this PR drops a delivered entry without any cleanup. The prior PR comment block was correct only for adapters whose deliverPending has no in-process side effects; Matrix registers a reaction target in both an in-memory Map and a persistent store inside deliverPending, so the entry would leak until the 24h TTL (or process restart) every time stop() landed between deliverPending and bindPending. Add an optional cancelDelivered interaction hook on the runtime types, forward it through both the spec-to-adapter wrapper (createChannelApprovalNativeRuntimeAdapter) and the lazy adapter wrapper (createLazyChannelApprovalNativeRuntimeAdapter), and invoke it from the two stopped guards in deliverTarget: the pre-bind guard always calls it, and the post-bind guard calls it on the branch where bindPending returned no handle (so unbindPending cannot run). Matrix implements the hook by calling unregisterMatrixApprovalReactionTarget on the entry's roomId + reactionEventId, which is the exact key registerMatrixApprovalReactionTarget uses inside deliverPending. The other native runtime adapters (Slack, Discord, Telegram, qqbot) leave the hook unimplemented because their deliverPending paths only emit remote messages and keep no in-process state to drop. Regression coverage: - invokes cancelDelivered when stop() fires between deliverPending and bindPending (Deferred-gated deliverPending, asserts bindPending / unbindPending never run and cancelDelivered receives the entry) - invokes cancelDelivered when stop() fires after bindPending returned null (asserts unbindPending stays uncalled while cancelDelivered fires) AI-assisted: drafted with claude code (claude-opus-4-7).
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
|
import type {
|
|
ChannelApprovalNativeAvailabilityAdapter,
|
|
ChannelApprovalNativeRuntimeAdapter,
|
|
} from "./approval-handler-runtime-types.js";
|
|
import type { ExecApprovalChannelRuntimeEventKind } from "./exec-approval-channel-runtime.types.js";
|
|
|
|
export const CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY = "approval.native";
|
|
|
|
export function createLazyChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload = unknown,
|
|
TPreparedTarget = unknown,
|
|
TPendingEntry = unknown,
|
|
TBinding = unknown,
|
|
TFinalPayload = unknown,
|
|
>(params: {
|
|
load: () => Promise<
|
|
ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
>
|
|
>;
|
|
isConfigured: ChannelApprovalNativeAvailabilityAdapter["isConfigured"];
|
|
shouldHandle: ChannelApprovalNativeAvailabilityAdapter["shouldHandle"];
|
|
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
|
|
resolveApprovalKind?: ChannelApprovalNativeRuntimeAdapter["resolveApprovalKind"];
|
|
}): ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
> {
|
|
const loadRuntime = createLazyRuntimeModule(params.load);
|
|
let loadedRuntime: ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
> | null = null;
|
|
const loadResolvedRuntime = async (): Promise<
|
|
ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
>
|
|
> => {
|
|
const runtime = await loadRuntime();
|
|
loadedRuntime = runtime;
|
|
return runtime;
|
|
};
|
|
const loadRequired = async <TResult>(
|
|
select: (
|
|
runtime: ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
>,
|
|
) => TResult,
|
|
): Promise<TResult> => select(await loadResolvedRuntime());
|
|
const loadOptional = async <TResult>(
|
|
select: (
|
|
runtime: ChannelApprovalNativeRuntimeAdapter<
|
|
TPendingPayload,
|
|
TPreparedTarget,
|
|
TPendingEntry,
|
|
TBinding,
|
|
TFinalPayload
|
|
>,
|
|
) => TResult | undefined,
|
|
): Promise<TResult | undefined> => select(await loadResolvedRuntime());
|
|
|
|
return {
|
|
...(params.eventKinds ? { eventKinds: params.eventKinds } : {}),
|
|
...(params.resolveApprovalKind ? { resolveApprovalKind: params.resolveApprovalKind } : {}),
|
|
availability: {
|
|
isConfigured: params.isConfigured,
|
|
shouldHandle: params.shouldHandle,
|
|
},
|
|
presentation: {
|
|
buildPendingPayload: async (runtimeParams) =>
|
|
(await loadRequired((runtime) => runtime.presentation.buildPendingPayload))(runtimeParams),
|
|
buildResolvedResult: async (runtimeParams) =>
|
|
(await loadRequired((runtime) => runtime.presentation.buildResolvedResult))(runtimeParams),
|
|
buildExpiredResult: async (runtimeParams) =>
|
|
(await loadRequired((runtime) => runtime.presentation.buildExpiredResult))(runtimeParams),
|
|
},
|
|
transport: {
|
|
prepareTarget: async (runtimeParams) =>
|
|
(await loadRequired((runtime) => runtime.transport.prepareTarget))(runtimeParams),
|
|
deliverPending: async (runtimeParams) =>
|
|
(await loadRequired((runtime) => runtime.transport.deliverPending))(runtimeParams),
|
|
updateEntry: async (runtimeParams) =>
|
|
await (
|
|
await loadOptional((runtime) => runtime.transport.updateEntry)
|
|
)?.(runtimeParams),
|
|
deleteEntry: async (runtimeParams) =>
|
|
await (
|
|
await loadOptional((runtime) => runtime.transport.deleteEntry)
|
|
)?.(runtimeParams),
|
|
},
|
|
interactions: {
|
|
bindPending: async (runtimeParams) =>
|
|
(await loadOptional((runtime) => runtime.interactions?.bindPending))?.(runtimeParams) ??
|
|
null,
|
|
unbindPending: async (runtimeParams) =>
|
|
await (
|
|
await loadOptional((runtime) => runtime.interactions?.unbindPending)
|
|
)?.(runtimeParams),
|
|
clearPendingActions: async (runtimeParams) =>
|
|
await (
|
|
await loadOptional((runtime) => runtime.interactions?.clearPendingActions)
|
|
)?.(runtimeParams),
|
|
cancelDelivered: async (runtimeParams) =>
|
|
await (
|
|
await loadOptional((runtime) => runtime.interactions?.cancelDelivered)
|
|
)?.(runtimeParams),
|
|
},
|
|
observe: {
|
|
// Observe hooks are fire-and-forget at call sites. Reuse the already
|
|
// loaded runtime instead of introducing unawaited lazy-load promises.
|
|
onDeliveryError: (runtimeParams) => loadedRuntime?.observe?.onDeliveryError?.(runtimeParams),
|
|
onDuplicateSkipped: (runtimeParams) =>
|
|
loadedRuntime?.observe?.onDuplicateSkipped?.(runtimeParams),
|
|
onDelivered: (runtimeParams) => loadedRuntime?.observe?.onDelivered?.(runtimeParams),
|
|
},
|
|
};
|
|
}
|