Files
openclaw/src/infra/approval-handler-runtime-types.ts
Feelw00 ea9793b2e1 fix(approvals): release Matrix reaction target on mid-flight cancel
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).
2026-05-16 16:41:07 +01:00

299 lines
9.6 KiB
TypeScript

import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ChannelApprovalNativePlannedTarget } from "./approval-native-delivery.js";
import type { PreparedChannelNativeApprovalTarget } from "./approval-native-runtime.js";
import type { ChannelApprovalKind } from "./approval-types.js";
import type {
ExpiredApprovalView,
PendingApprovalView,
ResolvedApprovalView,
} from "./approval-view-model.types.js";
import type { ExecApprovalChannelRuntimeEventKind } from "./exec-approval-channel-runtime.types.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "./exec-approvals.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
export type { ChannelApprovalKind } from "./approval-types.js";
export type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
export type ChannelApprovalCapabilityHandlerContext = {
cfg: OpenClawConfig;
accountId?: string | null;
gatewayUrl?: string;
context?: unknown;
};
export type ChannelApprovalNativeFinalAction<TPayload> =
| { kind: "update"; payload: TPayload }
| { kind: "delete" }
| { kind: "clear-actions" }
| { kind: "leave" };
export type ChannelApprovalNativeAvailabilityAdapter = {
isConfigured: (params: ChannelApprovalCapabilityHandlerContext) => boolean;
shouldHandle: (
params: ChannelApprovalCapabilityHandlerContext & { request: ApprovalRequest },
) => boolean;
};
export type ChannelApprovalNativePresentationAdapter<
TPendingPayload = unknown,
TFinalPayload = unknown,
> = {
buildPendingPayload: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
nowMs: number;
view: PendingApprovalView;
},
) => TPendingPayload | Promise<TPendingPayload>;
buildResolvedResult: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: ResolvedApprovalView;
entry: unknown;
},
) =>
| ChannelApprovalNativeFinalAction<TFinalPayload>
| Promise<ChannelApprovalNativeFinalAction<TFinalPayload>>;
buildExpiredResult: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
view: ExpiredApprovalView;
entry: unknown;
},
) =>
| ChannelApprovalNativeFinalAction<TFinalPayload>
| Promise<ChannelApprovalNativeFinalAction<TFinalPayload>>;
};
type ChannelApprovalNativeTransportAdapterForView<
TPreparedTarget = unknown,
TPendingEntry = unknown,
TPendingPayload = unknown,
TFinalPayload = unknown,
TPendingView extends PendingApprovalView = PendingApprovalView,
> = {
prepareTarget: (
params: ChannelApprovalCapabilityHandlerContext & {
plannedTarget: ChannelApprovalNativePlannedTarget;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
},
) =>
| PreparedChannelNativeApprovalTarget<TPreparedTarget>
| null
| Promise<PreparedChannelNativeApprovalTarget<TPreparedTarget> | null>;
deliverPending: (
params: ChannelApprovalCapabilityHandlerContext & {
plannedTarget: ChannelApprovalNativePlannedTarget;
preparedTarget: TPreparedTarget;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
},
) => TPendingEntry | null | Promise<TPendingEntry | null>;
updateEntry?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
payload: TFinalPayload;
phase: "resolved" | "expired";
},
) => Promise<void>;
deleteEntry?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
phase: "resolved" | "expired";
},
) => Promise<void>;
};
export type ChannelApprovalNativeTransportAdapter<
TPreparedTarget = unknown,
TPendingEntry = unknown,
TPendingPayload = unknown,
TFinalPayload = unknown,
> = ChannelApprovalNativeTransportAdapterForView<
TPreparedTarget,
TPendingEntry,
TPendingPayload,
TFinalPayload
>;
type ChannelApprovalNativeInteractionAdapterForView<
TPendingEntry = unknown,
TBinding = unknown,
TPendingPayload = unknown,
TPendingView extends PendingApprovalView = PendingApprovalView,
> = {
bindPending?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
},
) => TBinding | null | Promise<TBinding | null>;
unbindPending?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
binding: TBinding;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
},
) => Promise<void> | void;
clearPendingActions?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
phase: "resolved" | "expired";
},
) => Promise<void>;
cancelDelivered?: (
params: ChannelApprovalCapabilityHandlerContext & {
entry: TPendingEntry;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
},
) => Promise<void> | void;
};
export type ChannelApprovalNativeInteractionAdapter<
TPendingEntry = unknown,
TBinding = unknown,
> = ChannelApprovalNativeInteractionAdapterForView<TPendingEntry, TBinding>;
type ChannelApprovalNativeObserveAdapterForView<
TPreparedTarget = unknown,
TPendingPayload = unknown,
TPendingEntry = unknown,
TPendingView extends PendingApprovalView = PendingApprovalView,
> = {
onDeliveryError?: (
params: ChannelApprovalCapabilityHandlerContext & {
error: unknown;
plannedTarget: ChannelApprovalNativePlannedTarget;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
},
) => void;
onDuplicateSkipped?: (
params: ChannelApprovalCapabilityHandlerContext & {
plannedTarget: ChannelApprovalNativePlannedTarget;
preparedTarget: PreparedChannelNativeApprovalTarget<TPreparedTarget>;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
},
) => void;
onDelivered?: (
params: ChannelApprovalCapabilityHandlerContext & {
plannedTarget: ChannelApprovalNativePlannedTarget;
preparedTarget: PreparedChannelNativeApprovalTarget<TPreparedTarget>;
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
view: TPendingView;
pendingPayload: TPendingPayload;
entry: TPendingEntry;
},
) => void;
};
export type ChannelApprovalNativeObserveAdapter<
TPreparedTarget = unknown,
TPendingPayload = unknown,
TPendingEntry = unknown,
> = ChannelApprovalNativeObserveAdapterForView<TPreparedTarget, TPendingPayload, TPendingEntry>;
export type ChannelApprovalNativeRuntimeAdapter<
TPendingPayload = unknown,
TPreparedTarget = unknown,
TPendingEntry = unknown,
TBinding = unknown,
TFinalPayload = unknown,
> = {
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
resolveApprovalKind?: (request: ApprovalRequest) => ChannelApprovalKind;
availability: ChannelApprovalNativeAvailabilityAdapter;
presentation: ChannelApprovalNativePresentationAdapter<TPendingPayload, TFinalPayload>;
transport: ChannelApprovalNativeTransportAdapter<
TPreparedTarget,
TPendingEntry,
TPendingPayload,
TFinalPayload
>;
interactions?: ChannelApprovalNativeInteractionAdapter<TPendingEntry, TBinding>;
observe?: ChannelApprovalNativeObserveAdapter;
};
export type ChannelApprovalNativeRuntimeSpec<
TPendingPayload,
TPreparedTarget,
TPendingEntry,
TBinding = unknown,
TFinalPayload = unknown,
TPendingView extends PendingApprovalView = PendingApprovalView,
TResolvedView extends ResolvedApprovalView = ResolvedApprovalView,
TExpiredView extends ExpiredApprovalView = ExpiredApprovalView,
> = {
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
resolveApprovalKind?: (request: ApprovalRequest) => ChannelApprovalKind;
availability: ChannelApprovalNativeAvailabilityAdapter;
presentation: {
buildPendingPayload: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
nowMs: number;
view: TPendingView;
},
) => TPendingPayload | Promise<TPendingPayload>;
buildResolvedResult: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: TResolvedView;
entry: TPendingEntry;
},
) =>
| ChannelApprovalNativeFinalAction<TFinalPayload>
| Promise<ChannelApprovalNativeFinalAction<TFinalPayload>>;
buildExpiredResult: (
params: ChannelApprovalCapabilityHandlerContext & {
request: ApprovalRequest;
view: TExpiredView;
entry: TPendingEntry;
},
) =>
| ChannelApprovalNativeFinalAction<TFinalPayload>
| Promise<ChannelApprovalNativeFinalAction<TFinalPayload>>;
};
transport: ChannelApprovalNativeTransportAdapterForView<
TPreparedTarget,
TPendingEntry,
TPendingPayload,
TFinalPayload,
TPendingView
>;
interactions?: ChannelApprovalNativeInteractionAdapterForView<
TPendingEntry,
TBinding,
TPendingPayload,
TPendingView
>;
observe?: ChannelApprovalNativeObserveAdapterForView<
TPreparedTarget,
TPendingPayload,
TPendingEntry,
TPendingView
>;
};