From 9935ca3b30dcb03dd2b5e7495d32ab190259f1fc Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 11 Aug 2026 01:42:41 +0530 Subject: [PATCH] fix(approvals): bind native requests to channel accounts (#121673) Native approval delivery and resolution now stay bound to the originating or explicitly targeted channel account. Unbound requests fail closed across multiple eligible accounts; trusted reviewer-less SDK callers remain compatible. Co-authored-by: Ayaan Zaidi --- docs/.generated/plugin-sdk-api-baseline.jsonl | 53 ++-- .../discord/src/approval-native.test.ts | 31 +- extensions/discord/src/approval-shared.ts | 59 ++-- .../src/monitor/exec-approvals.test.ts | 13 +- .../discord/src/monitor/exec-approvals.ts | 7 +- .../src/approval-card-click.test.ts | 1 + .../googlechat/src/approval-card-click.ts | 1 + extensions/imessage/src/approval-polls.ts | 1 + .../imessage/src/approval-reactions.test.ts | 6 + extensions/imessage/src/approval-reactions.ts | 1 + extensions/matrix/src/exec-approvals.test.ts | 21 +- extensions/matrix/src/exec-approvals.ts | 91 ++---- .../matrix/monitor/reaction-events.test.ts | 4 + .../src/matrix/monitor/reaction-events.ts | 1 + extensions/qqbot/src/bridge/bootstrap.test.ts | 5 + extensions/qqbot/src/bridge/bootstrap.ts | 34 +-- extensions/qqbot/src/engine/adapter/index.ts | 2 + .../gateway/interaction-handler.test.ts | 30 +- .../src/engine/gateway/interaction-handler.ts | 24 +- extensions/qqbot/src/exec-approvals.test.ts | 30 +- extensions/qqbot/src/exec-approvals.ts | 131 +++------ .../signal/src/approval-reactions.test.ts | 2 + extensions/signal/src/approval-reactions.ts | 1 + extensions/slack/src/approval-native-gates.ts | 83 +++--- extensions/slack/src/approval-native.test.ts | 44 +++ extensions/slack/src/exec-approvals.ts | 8 - .../events/interactions.block-actions.ts | 2 + .../src/monitor/events/interactions.test.ts | 6 + ...bot-handlers.callback-approvals.runtime.ts | 2 + .../telegram/src/exec-approvals.test.ts | 44 ++- extensions/telegram/src/exec-approvals.ts | 120 ++------ .../whatsapp/src/approval-reactions.test.ts | 3 + extensions/whatsapp/src/approval-reactions.ts | 1 + .../src/approvals-validators.test.ts | 28 ++ .../gateway-protocol/src/schema/approvals.ts | 8 + .../src/schema/exec-approvals.ts | 2 + .../src/schema/plugin-approvals.ts | 2 + scripts/plugin-sdk-surface-report.mts | 4 +- src/auto-reply/reply/commands-approve.test.ts | 11 +- src/auto-reply/reply/commands-approve.ts | 8 +- ...from-config.abort-and-dedupe.test-utils.ts | 4 + src/gateway/approval-channel-custody.test.ts | 113 ++++++++ src/gateway/approval-channel-custody.ts | 62 ++++ .../server-methods/approval-record-lookup.ts | 169 +++++++++++ .../server-methods/approval-shared.test.ts | 39 +++ src/gateway/server-methods/approval-shared.ts | 267 ++++------------- src/gateway/server-methods/approval.test.ts | 47 +++ src/gateway/server-methods/approval.ts | 34 ++- src/gateway/server-methods/exec-approval.ts | 16 +- src/gateway/server-methods/plugin-approval.ts | 3 +- src/infra/approval-gateway-resolver.test.ts | 110 +++++++ src/infra/approval-gateway-resolver.ts | 18 +- .../approval-native-route-coordinator.test.ts | 225 +++++++++++++-- .../approval-native-route-coordinator.ts | 271 +++++++++++++++--- .../approval-native-route-notice.test.ts | 7 + src/infra/approval-native-route-notice.ts | 5 + src/infra/approval-native-runtime.ts | 45 ++- src/infra/approval-request-account-binding.ts | 117 +++++++- .../exec-approval-session-target.test.ts | 117 ++++++++ .../approval-native-helpers.test.ts | 25 ++ src/plugin-sdk/approval-native-helpers.ts | 44 ++- src/plugin-sdk/approval-native-runtime.ts | 1 + 62 files changed, 1930 insertions(+), 734 deletions(-) create mode 100644 src/gateway/approval-channel-custody.test.ts create mode 100644 src/gateway/approval-channel-custody.ts create mode 100644 src/gateway/server-methods/approval-record-lookup.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index e59f87797c8b..07bbc2a72ff8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,7 +53,7 @@ {"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"02de0842f1c90d94524ea23153bef7bd10cd81102fbd5e661e9219a033554483","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} +{"closureHash":"1cda81164bc5408ef8d58c44cc73d89609ae5004d768346528b182f75db1e388","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} @@ -450,7 +450,7 @@ {"closureHash":"beb04c5325dd0fa50a2f2433140b8fb2707f609de150a6f07991ec050aa74ecf","declaration":"export function createChannelApprovalCapability(params: { authorizeActorAction?: ChannelApprovalCapability[\"authorizeActorAction\"]; getActionAvailabilityState?: ChannelApprovalCapability[\"getActionAvailabilityState\"]; getExecInitiatingSurfaceState?: ChannelApprovalCapability[\"getExecInitiatingSurfaceState\"]; resolveApproveCommandBehavior?: ChannelApprovalCapability[\"resolveApproveCommandBehavior\"]; describeExecApprovalSetup?: ChannelApprovalCapability[\"describeExecApprovalSetup\"]; describePluginApprovalSetup?: ChannelApprovalCapability[\"describePluginApprovalSetup\"]; delivery?: ChannelApprovalCapability[\"delivery\"]; nativeRuntime?: ChannelApprovalCapability[\"nativeRuntime\"]; render?: ChannelApprovalCapability[\"render\"]; native?: ChannelApprovalCapability[\"native\"]; }): ChannelApprovalCapability;","entrypoint":"approval-delivery-runtime","exportName":"createChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime","kind":"function","recordType":"export"} {"closureHash":"5d6b198c8938ac9404cf096936d9ad45a3c6e8d8b9115a6798f5f8377ee024c1","declaration":"export function splitChannelApprovalCapability(capability: ChannelApprovalCapability): { auth: { authorizeActorAction?: ChannelApprovalCapability[\"authorizeActorAction\"]; getActionAvailabilityState?: ChannelApprovalCapability[\"getActionAvailabilityState\"]; getExecInitiatingSurfaceState?: ChannelApprovalCapability[\"getExecInitiatingSurfaceState\"]; resolveApproveCommandBehavior?: ChannelApprovalCapability[\"resolveApproveCommandBehavior\"]; }; delivery: ChannelApprovalCapability[\"delivery\"]; nativeRuntime: ChannelApprovalCapability[\"nativeRuntime\"]; render: ChannelApprovalCapability[\"render\"]; native: ChannelApprovalCapability[\"native\"]; describeExecApprovalSetup: ChannelApprovalCapability[\"describeExecApprovalSetup\"]; describePluginApprovalSetup: ChannelApprovalCapability[\"describePluginApprovalSetup\"]; };","entrypoint":"approval-delivery-runtime","exportName":"splitChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime","kind":"function","recordType":"export"} {"category":"runtime","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime","recordType":"module"} -{"closureHash":"be803b92aa9376ae719d7d237816ee31e306cb59e7e48d596f155612e72c0cde","declaration":"export function resolveApprovalOverGateway(params: LegacyResolveApprovalOverGatewayParams): Promise;\nexport function resolveApprovalOverGateway(params: CanonicalResolveApprovalOverGatewayParams): Promise;","entrypoint":"approval-gateway-runtime","exportName":"resolveApprovalOverGateway","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime","kind":"function","recordType":"export"} +{"closureHash":"4499db6098f2545b18ae2aa31174fa28834e8e8fb745e709b84a85c2939e718d","declaration":"export function resolveApprovalOverGateway(params: LegacyResolveApprovalOverGatewayParams): Promise;\nexport function resolveApprovalOverGateway(params: CanonicalResolveApprovalOverGatewayParams): Promise;","entrypoint":"approval-gateway-runtime","exportName":"resolveApprovalOverGateway","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime","kind":"function","recordType":"export"} {"closureHash":"315487017cd1fb4469e2478da08b10e1919f0c9a6b20f92182855a434662bba7","declaration":"export type ApprovalResolveResult = { applied: boolean; approval: { source?: { agentId?: string | undefined; sessionKey?: string | undefined; } | undefined; resolver?: { id?: string | undefined; kind: \"system\" | \"runtime\" | \"channel\" | \"device\"; } | undefined; id: string; status: \"allowed\"; reason: \"user\"; presentation: { agentId?: string | null | undefined; host?: string | null | undefined; commandPreview?: string | null | undefined; warningText?: string | null | undefined; nodeId?: string | null | undefined; kind: \"exec\"; commandText: string; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; } | { agentId?: string | null | undefined; detail?: string | undefined; pluginId?: string | null | undefined; toolName?: string | null | undefined; title: string; description: string; kind: \"plugin\"; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; severity: \"info\" | \"warning\" | \"critical\"; } | { agentId?: string | null | undefined; title: string; description: string; kind: \"system-agent\"; allowedDecisions: [\"allow-once\", \"deny\"]; proposalHash: string; }; decision: \"allow-once\" | \"allow-always\"; expiresAtMs: number; urlPath: string; createdAtMs: number; resolvedAtMs: number; } | { source?: { agentId?: string | undefined; sessionKey?: string | undefined; } | undefined; resolver?: { id?: string | undefined; kind: \"system\" | \"runtime\" | \"channel\" | \"device\"; } | undefined; id: string; status: \"denied\"; reason: \"user\" | \"malformed-verdict\" | \"no-route\" | \"storage-corrupt\"; presentation: { agentId?: string | null | undefined; host?: string | null | undefined; commandPreview?: string | null | undefined; warningText?: string | null | undefined; nodeId?: string | null | undefined; kind: \"exec\"; commandText: string; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; } | { agentId?: string | null | undefined; detail?: string | undefined; pluginId?: string | null | undefined; toolName?: string | null | undefined; title: string; description: string; kind: \"plugin\"; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; severity: \"info\" | \"warning\" | \"critical\"; } | { agentId?: string | null | undefined; title: string; description: string; kind: \"system-agent\"; allowedDecisions: [\"allow-once\", \"deny\"]; proposalHash: string; }; decision: \"deny\"; expiresAtMs: number; urlPath: string; createdAtMs: number; resolvedAtMs: number; } | { source?: { agentId?: string | undefined; sessionKey?: string | undefined; } | undefined; resolver?: { id?: string | undefined; kind: \"system\" | \"runtime\" | \"channel\" | \"device\"; } | undefined; id: string; status: \"expired\"; reason: \"timeout\"; presentation: { agentId?: string | null | undefined; host?: string | null | undefined; commandPreview?: string | null | undefined; warningText?: string | null | undefined; nodeId?: string | null | undefined; kind: \"exec\"; commandText: string; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; } | { agentId?: string | null | undefined; detail?: string | undefined; pluginId?: string | null | undefined; toolName?: string | null | undefined; title: string; description: string; kind: \"plugin\"; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; severity: \"info\" | \"warning\" | \"critical\"; } | { agentId?: string | null | undefined; title: string; description: string; kind: \"system-agent\"; allowedDecisions: [\"allow-once\", \"deny\"]; proposalHash: string; }; expiresAtMs: number; urlPath: string; createdAtMs: number; resolvedAtMs: number; } | { source?: { agentId?: string | undefined; sessionKey?: string | undefined; } | undefined; resolver?: { id?: string | undefined; kind: \"system\" | \"runtime\" | \"channel\" | \"device\"; } | undefined; id: string; status: \"cancelled\"; reason: \"run-aborted\" | \"gateway-restart\"; presentation: { agentId?: string | null | undefined; host?: string | null | undefined; commandPreview?: string | null | undefined; warningText?: string | null | undefined; nodeId?: string | null | undefined; kind: \"exec\"; commandText: string; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; } | { agentId?: string | null | undefined; detail?: string | undefined; pluginId?: string | null | undefined; toolName?: string | null | undefined; title: string; description: string; kind: \"plugin\"; allowedDecisions: (\"deny\" | \"allow-once\" | \"allow-always\")[]; severity: \"info\" | \"warning\" | \"critical\"; } | { agentId?: string | null | undefined; title: string; description: string; kind: \"system-agent\"; allowedDecisions: [\"allow-once\", \"deny\"]; proposalHash: string; }; expiresAtMs: number; urlPath: string; createdAtMs: number; resolvedAtMs: number; };};","entrypoint":"approval-gateway-runtime","exportName":"ApprovalResolveResult","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime","kind":"type","recordType":"export"} {"category":null,"entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime","recordType":"module"} {"closureHash":"92d54165c16e5e1a8c1c221e966419dabcac7c5492cf464fa2557859b83c6656","declaration":"export function createLazyChannelApprovalNativeRuntimeAdapter(params: { load: () => Promise>; isConfigured: ChannelApprovalNativeAvailabilityAdapter[\"isConfigured\"]; shouldHandle: ChannelApprovalNativeAvailabilityAdapter[\"shouldHandle\"]; eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[]; resolveApprovalKind?: ChannelApprovalNativeRuntimeAdapter[\"resolveApprovalKind\"]; }): ChannelApprovalNativeRuntimeAdapter;","entrypoint":"approval-handler-adapter-runtime","exportName":"createLazyChannelApprovalNativeRuntimeAdapter","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime","kind":"function","recordType":"export"} @@ -462,7 +462,7 @@ {"closureHash":"bd0f02b8feb6333b3a8a2ddf3f11c61e204dc392514094e3b422fe09584f07c0","declaration":"export 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;","entrypoint":"approval-handler-runtime","exportName":"createChannelApprovalHandlerFromCapability","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} {"closureHash":"1e33ddc228945677a50b177c6b59b8bb9b7ff2e929d0ab0d320d14ae0bd91cb4","declaration":"export function createChannelApprovalNativeRuntimeAdapter(spec: ChannelApprovalNativeRuntimeSpec): ChannelApprovalNativeRuntimeAdapter;","entrypoint":"approval-handler-runtime","exportName":"createChannelApprovalNativeRuntimeAdapter","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} {"closureHash":"92d54165c16e5e1a8c1c221e966419dabcac7c5492cf464fa2557859b83c6656","declaration":"export function createLazyChannelApprovalNativeRuntimeAdapter(params: { load: () => Promise>; isConfigured: ChannelApprovalNativeAvailabilityAdapter[\"isConfigured\"]; shouldHandle: ChannelApprovalNativeAvailabilityAdapter[\"shouldHandle\"]; eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[]; resolveApprovalKind?: ChannelApprovalNativeRuntimeAdapter[\"resolveApprovalKind\"]; }): ChannelApprovalNativeRuntimeAdapter;","entrypoint":"approval-handler-runtime","exportName":"createLazyChannelApprovalNativeRuntimeAdapter","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} -{"closureHash":"be803b92aa9376ae719d7d237816ee31e306cb59e7e48d596f155612e72c0cde","declaration":"export function resolveApprovalOverGateway(params: LegacyResolveApprovalOverGatewayParams): Promise;\nexport function resolveApprovalOverGateway(params: CanonicalResolveApprovalOverGatewayParams): Promise;","entrypoint":"approval-handler-runtime","exportName":"resolveApprovalOverGateway","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} +{"closureHash":"4499db6098f2545b18ae2aa31174fa28834e8e8fb745e709b84a85c2939e718d","declaration":"export function resolveApprovalOverGateway(params: LegacyResolveApprovalOverGatewayParams): Promise;\nexport function resolveApprovalOverGateway(params: CanonicalResolveApprovalOverGatewayParams): Promise;","entrypoint":"approval-handler-runtime","exportName":"resolveApprovalOverGateway","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} {"closureHash":"a71296b12398e5ef4f2ea45cb43533f008dfeacc7c757454c80604fb0c2f4dd9","declaration":"export function resolvePreparedApprovalAccountId(params: { plannedAccountId?: string | null; contextAccountId?: string | null; fallbackAccountId: string; }): string;\nexport function resolvePreparedApprovalAccountId(params: { plannedAccountId?: string | null; contextAccountId?: string | null; fallbackAccountId?: string | null; }): string | undefined;","entrypoint":"approval-handler-runtime","exportName":"resolvePreparedApprovalAccountId","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"function","recordType":"export"} {"closureHash":"3e60c012695ff8b279c3e08ad1815918cd04384aea6bee4ef0e3b5a1abbf85dd","declaration":"export const CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY: \"approval.native\";","entrypoint":"approval-handler-runtime","exportName":"CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"const","recordType":"export"} {"closureHash":"2c759d004d85d6ae3ba103553e10eaf23592d50c48b07227665b2da46c90b562","declaration":"export type ApprovalActionView = ApprovalActionView;","entrypoint":"approval-handler-runtime","exportName":"ApprovalActionView","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime","kind":"type","recordType":"export"} @@ -496,11 +496,12 @@ {"closureHash":"68a92d42c94325122639479b64b08bcb6b527527d78550da154f182770f0ad92","declaration":"export function createNativeApprovalChannelRouteGates(params: NativeApprovalChannelRouteGateParams): NativeApprovalChannelRouteGates;","entrypoint":"approval-native-runtime","exportName":"createNativeApprovalChannelRouteGates","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"b35aec69ffef7abb1ede0924015f385d4a3be844179aba8188e7e028da24f1f4","declaration":"export function createNativeApprovalForwardingFallbackSuppressor(params: NativeApprovalForwardingFallbackSuppressorParams): NonNullable[\"shouldSuppressForwardingFallback\"]>;","entrypoint":"approval-native-runtime","exportName":"createNativeApprovalForwardingFallbackSuppressor","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"8309b3faa56f6c7f77f66c086959dc2802dbfd71bd5ee629bc78c156f5ffd5af","declaration":"export function createNativeApprovalMessagingTargetResolvers(params: { channel: string; normalizeTo: (to: string) => string | null | undefined; }): { normalizeForwardTarget: (target: NativeApprovalForwardTarget) => NativeApprovalTarget | null; resolveTurnSourceTarget: (request: ApprovalRequest) => NativeApprovalTarget | null; resolveSessionTarget: (sessionTarget: ExecApprovalSessionTarget) => NativeApprovalTarget | null; normalizeTarget: (target: NativeApprovalTarget) => NativeApprovalTarget | null; };","entrypoint":"approval-native-runtime","exportName":"createNativeApprovalMessagingTargetResolvers","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} -{"closureHash":"3ae4eb9d78351d9fecf43dd347ef6647a992821eb4175e9749b360ddfaf167b3","declaration":"export function doesApprovalRequestMatchChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; accountId?: string | null; }): boolean;","entrypoint":"approval-native-runtime","exportName":"doesApprovalRequestMatchChannelAccount","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} +{"closureHash":"c69f92f0345a195089dcda6320bfbf50e5d5f05344f88b332c7c1406ae07496d","declaration":"export function doesApprovalRequestMatchChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; accountId?: string | null; }): boolean;","entrypoint":"approval-native-runtime","exportName":"doesApprovalRequestMatchChannelAccount","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} +{"closureHash":"34401ac1bb16aa92f7cc6f7bcccd16bbb3975c13d4c8cacaf50bb54e156b06fb","declaration":"export function doesApprovalRequestSelectChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; accountId?: string | null; defaultAccountId: string; eligibleAccountIds: readonly string[]; }): boolean;","entrypoint":"approval-native-runtime","exportName":"doesApprovalRequestSelectChannelAccount","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"a5523880a5e26e7826487d50504f05a7d266cd79f91a7b022c851d585000d4ef","declaration":"export function nativeApprovalTargetsMatch(params: { channel?: string | null; left: NativeApprovalTarget; right: NativeApprovalTarget; }): boolean;","entrypoint":"approval-native-runtime","exportName":"nativeApprovalTargetsMatch","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"0f54b69474c33ae9f8e70540d23676336a25c1243ffc868225fe61e5df184494","declaration":"export function resolveApprovalKind(request: ApprovalRequest, approvalKind?: ApprovalKind): ApprovalKind;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalKind","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} -{"closureHash":"a8d38c7bac40608f1ab06860fe5b1dc1d8c833acdfcff0759782cb8f99b338e8","declaration":"export function resolveApprovalRequestAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel?: string | null; }): string | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestAccountId","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} -{"closureHash":"ade23ace3d8089cba9dfbaa84a5645afae498de39ea80ea6a2caef7656e6f96f","declaration":"export function resolveApprovalRequestChannelAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; }): string | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestChannelAccountId","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} +{"closureHash":"a3cc7a2babeadfdd363d7b2f120ca38f977e99eb0e913306c2980ad36229cbe3","declaration":"export function resolveApprovalRequestAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel?: string | null; }): string | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestAccountId","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} +{"closureHash":"ea5b5dd97de6e1094255fd95684e8f23f4f61e01b6f891e061c9a952a3e29e03","declaration":"export function resolveApprovalRequestChannelAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; }): string | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestChannelAccountId","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"0c739cf721037e441ae66769a049ee2d57569155e67b57e9b969a30d924c5690","declaration":"export function resolveApprovalRequestOriginTarget(params: ApprovalRequestOriginTargetResolver): TTarget | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestOriginTarget","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"5d24a67519cca99f1136405e4df96025320f052cad81261aa03f055aaaf843e1","declaration":"export function resolveApprovalRequestSessionConversation(params: { request: ApprovalRequestLike; channel?: string | null; bundledFallback?: boolean; }): ApprovalRequestSessionConversation | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestSessionConversation","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} {"closureHash":"5318cf32f45941d23ff998af6ccd6424dc46d8068e5a2cbc05a6495f41dc4d03","declaration":"export function resolveApprovalRequestSessionTarget(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; }): ExecApprovalSessionTarget | null;","entrypoint":"approval-native-runtime","exportName":"resolveApprovalRequestSessionTarget","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime","kind":"function","recordType":"export"} @@ -549,7 +550,7 @@ {"closureHash":"e29553969c9a665206692de6aa9b80c4db820f00b368dcc057f3b0437641590b","declaration":"export function createChannelNativeApprovalRuntime(adapter: ChannelNativeApprovalRuntimeAdapter): ExecApprovalChannelRuntime;","entrypoint":"approval-runtime","exportName":"createChannelNativeApprovalRuntime","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"9bafb8ff43a7caa43a1467ebfbfee8a1f8d711b7dbd7b5e1ff2bf6d7d6395748","declaration":"export function createChannelNativeOriginTargetResolver(params: NativeOriginResolverParams): (input: ApprovalResolverParams) => TTarget | null;\nexport function createChannelNativeOriginTargetResolver(params: CustomOriginResolverParams): (input: ApprovalResolverParams) => TTarget | null;","entrypoint":"approval-runtime","exportName":"createChannelNativeOriginTargetResolver","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"d7bd58c4b2cb794d75eb371f58b2d86cf221fdf2925ad12799d8b6c0d91de4d7","declaration":"export function createResolvedApproverActionAuthAdapter(params: { channelLabel: string; resolveApprovers: (params: { cfg: OpenClawConfig; accountId?: string | null; }) => string[]; normalizeSenderId?: (value: string) => string | undefined; }): { authorizeActorAction({ cfg, accountId, senderId, approvalKind, }: { cfg: OpenClawConfig; accountId?: string | null; senderId?: string | null; action: \"approve\"; approvalKind: ApprovalKind; }): ApprovalAuthorizationResult | { readonly authorized: true; readonly reason?: undefined; } | { readonly authorized: false; readonly reason: `\\u274C You are not authorized to approve plugin requests on ${string}.` | `\\u274C You are not authorized to approve exec requests on ${string}.`; }; };","entrypoint":"approval-runtime","exportName":"createResolvedApproverActionAuthAdapter","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} -{"closureHash":"3ae4eb9d78351d9fecf43dd347ef6647a992821eb4175e9749b360ddfaf167b3","declaration":"export function doesApprovalRequestMatchChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; accountId?: string | null; }): boolean;","entrypoint":"approval-runtime","exportName":"doesApprovalRequestMatchChannelAccount","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} +{"closureHash":"c69f92f0345a195089dcda6320bfbf50e5d5f05344f88b332c7c1406ae07496d","declaration":"export function doesApprovalRequestMatchChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; accountId?: string | null; }): boolean;","entrypoint":"approval-runtime","exportName":"doesApprovalRequestMatchChannelAccount","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"9bfee4308a0baaa873b47f3a8f3ce5b0e66c13eb314e421d3735b8294d2044e2","declaration":"export function formatApprovalDisplayPath(value: string): string;","entrypoint":"approval-runtime","exportName":"formatApprovalDisplayPath","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"6f58c9f29cfd83f31aca177dd368fda19bb26ad565118741f200f12e75639d62","declaration":"export function getExecApprovalApproverDmNoticeText(): string;","entrypoint":"approval-runtime","exportName":"getExecApprovalApproverDmNoticeText","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"bfb7cef8e5738b0d0b44e843c36433d085fdf9ab02671b33130a1b86f78ae0ce","declaration":"export function getExecApprovalReplyMetadata(payload: ReplyPayload): ExecApprovalReplyMetadata | null;","entrypoint":"approval-runtime","exportName":"getExecApprovalReplyMetadata","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} @@ -558,8 +559,8 @@ {"closureHash":"0c23f942c456673943c1eb812728a3e9b28e88ce7876a50a09fdbe9af2931e8b","declaration":"export function matchesApprovalRequestFilters(params: { request: ApprovalRequestFilterInput; agentFilter?: string[]; sessionFilter?: string[]; fallbackAgentIdFromSessionKey?: boolean; }): boolean;","entrypoint":"approval-runtime","exportName":"matchesApprovalRequestFilters","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"d9486916ddf9373f4079be418d8a2fbd5ad0fc53e7e5d951891a38953f9e08f5","declaration":"export function matchesApprovalRequestSessionFilter(sessionKey: string, patterns: string[]): boolean;","entrypoint":"approval-runtime","exportName":"matchesApprovalRequestSessionFilter","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"fc31d860c8a73777f10cd9e65d314d6bb112844253499814b937dd7fcb8d8082","declaration":"export function resolveApprovalApprovers(params: { explicit?: readonly ApproverInput[] | null; allowFrom?: readonly ApproverInput[] | null; extraAllowFrom?: readonly ApproverInput[] | null; defaultTo?: string | null; normalizeApprover: (value: ApproverInput) => string | undefined; normalizeDefaultTo?: (value: string) => string | undefined; }): string[];","entrypoint":"approval-runtime","exportName":"resolveApprovalApprovers","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} -{"closureHash":"a8d38c7bac40608f1ab06860fe5b1dc1d8c833acdfcff0759782cb8f99b338e8","declaration":"export function resolveApprovalRequestAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel?: string | null; }): string | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestAccountId","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} -{"closureHash":"ade23ace3d8089cba9dfbaa84a5645afae498de39ea80ea6a2caef7656e6f96f","declaration":"export function resolveApprovalRequestChannelAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; }): string | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestChannelAccountId","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} +{"closureHash":"a3cc7a2babeadfdd363d7b2f120ca38f977e99eb0e913306c2980ad36229cbe3","declaration":"export function resolveApprovalRequestAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel?: string | null; }): string | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestAccountId","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} +{"closureHash":"ea5b5dd97de6e1094255fd95684e8f23f4f61e01b6f891e061c9a952a3e29e03","declaration":"export function resolveApprovalRequestChannelAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; channel: string; }): string | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestChannelAccountId","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"0c739cf721037e441ae66769a049ee2d57569155e67b57e9b969a30d924c5690","declaration":"export function resolveApprovalRequestOriginTarget(params: ApprovalRequestOriginTargetResolver): TTarget | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestOriginTarget","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"5318cf32f45941d23ff998af6ccd6424dc46d8068e5a2cbc05a6495f41dc4d03","declaration":"export function resolveApprovalRequestSessionTarget(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; }): ExecApprovalSessionTarget | null;","entrypoint":"approval-runtime","exportName":"resolveApprovalRequestSessionTarget","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} {"closureHash":"62e9d68f9a876756aec16f68e95e1cf4f6c816bb867f6e755d131a40f038c051","declaration":"export function resolveExecApprovalAllowedDecisions(params?: { ask?: string | null; allowAlwaysPersistence?: AllowAlwaysPersistenceDecision | null; }): readonly ExecApprovalDecision[];","entrypoint":"approval-runtime","exportName":"resolveExecApprovalAllowedDecisions","importSpecifier":"openclaw/plugin-sdk/approval-runtime","kind":"function","recordType":"export"} @@ -1096,7 +1097,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} -{"closureHash":"173c0275f2c7355b5f88e06b5e787b4db8abaa9119a1d86347fad77d78f3134e","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} +{"closureHash":"49a4da24b2a204c52ab1c65189b673096d19ac9d4fd3cb4e18f758b6b53473ba","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} @@ -1225,7 +1226,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} -{"closureHash":"173c0275f2c7355b5f88e06b5e787b4db8abaa9119a1d86347fad77d78f3134e","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} +{"closureHash":"49a4da24b2a204c52ab1c65189b673096d19ac9d4fd3cb4e18f758b6b53473ba","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} @@ -2282,7 +2283,7 @@ {"closureHash":"a8874cc3b2cb522f6ca38ba5e353ec290aad0495c56087396ecaeb1cee9981b6","declaration":"export type NodeMatchCandidate = NodeMatchCandidate;","entrypoint":"gateway-runtime","exportName":"NodeMatchCandidate","importSpecifier":"openclaw/plugin-sdk/gateway-runtime","kind":"type","recordType":"export"} {"closureHash":"c970e76e8d4d8e5fe4b81cb9efe47392d390f826eeb22e409d178473e6d28bed","declaration":"export type NodeSession = NodeSession;","entrypoint":"gateway-runtime","exportName":"NodeSession","importSpecifier":"openclaw/plugin-sdk/gateway-runtime","kind":"type","recordType":"export"} {"closureHash":"6cfef265e2aebd53c1e7ce29cb204228ac175b07f9d2f22b861e98cac0eaee4a","declaration":"export type NormalizedPluginNodeCapabilityUrl = NormalizedPluginNodeCapabilityUrl;","entrypoint":"gateway-runtime","exportName":"NormalizedPluginNodeCapabilityUrl","importSpecifier":"openclaw/plugin-sdk/gateway-runtime","kind":"type","recordType":"export"} -{"closureHash":"7caeba3edd20e510203c097a2fe1c699deffe44db127e147a77668021add899b","declaration":"export class GatewayClient {\n #client: BaseGatewayClient;\n constructor(opts: GatewayClientOptions);\n start(): void;\n stop(): void;\n stopAndWait(opts?: {\n timeoutMs?: number;\n }): Promise;\n request>(method: string, params?: unknown, opts?: GatewayClientRequestOptions): Promise;\n getConnectionMetadata(): GatewayClientConnectionMetadata;\n updateNodeManifest(manifest: {\n caps: string[];\n commands: string[];\n }): void;\n}","entrypoint":"gateway-runtime","exportName":"GatewayClient","importSpecifier":"openclaw/plugin-sdk/gateway-runtime","kind":"class","recordType":"export"} +{"closureHash":"68d07fbabd2d26ae4ff7706a9e45635f94bdc86c08d62bb1316cd70eff4776e8","declaration":"export class GatewayClient {\n #client: BaseGatewayClient;\n constructor(opts: GatewayClientOptions);\n start(): void;\n stop(): void;\n stopAndWait(opts?: {\n timeoutMs?: number;\n }): Promise;\n request>(method: string, params?: unknown, opts?: GatewayClientRequestOptions): Promise;\n getConnectionMetadata(): GatewayClientConnectionMetadata;\n updateNodeManifest(manifest: {\n caps: string[];\n commands: string[];\n }): void;\n}","entrypoint":"gateway-runtime","exportName":"GatewayClient","importSpecifier":"openclaw/plugin-sdk/gateway-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"group-access","importSpecifier":"openclaw/plugin-sdk/group-access","recordType":"module"} {"closureHash":"b6b34cd3844debbe7cda17ad7703e4fcffe4489789ae1763df8f8ab027150203","declaration":"export function evaluateGroupRouteAccessForPolicy(params: { groupPolicy: GroupPolicy; routeAllowlistConfigured: boolean; routeMatched: boolean; routeEnabled?: boolean; }): GroupRouteAccessDecision;","entrypoint":"group-access","exportName":"evaluateGroupRouteAccessForPolicy","importSpecifier":"openclaw/plugin-sdk/group-access","kind":"function","recordType":"export"} {"closureHash":"bf1a463ce98bf23d639d6e80112ffca836df6d63049bae6fef2a11a2ef1bb74c","declaration":"export function evaluateMatchedGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; allowlistConfigured: boolean; allowlistMatched: boolean; requireMatchInput?: boolean; hasMatchInput?: boolean; }): MatchedGroupAccessDecision;","entrypoint":"group-access","exportName":"evaluateMatchedGroupAccessForPolicy","importSpecifier":"openclaw/plugin-sdk/group-access","kind":"function","recordType":"export"} @@ -2353,7 +2354,7 @@ {"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"9d503d05e408ddd1a1c373e3c4485b1f9acff4c240fdc241840a2cfa8b55c3a7","declaration":"export function runChannelInboundEvent(params: RunChannelTurnParams): Promise>;\nexport function runChannelInboundEvent(params: ChannelInboundEventRunnerParams): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply(params: PreparedChannelTurn): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} -{"closureHash":"173c0275f2c7355b5f88e06b5e787b4db8abaa9119a1d86347fad77d78f3134e","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} +{"closureHash":"49a4da24b2a204c52ab1c65189b673096d19ac9d4fd3cb4e18f758b6b53473ba","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} {"closureHash":"278dc8a410230012ddbc3552ec621a224e6f7c5dd4cd3904c943ac98dd4259e4","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} @@ -3188,19 +3189,19 @@ {"closureHash":"aa77ab53be059777d5d2cef699f73a3bbb85566b7cf13e8c8b66c5b2911b28bd","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"0597d9237d07af179e8425da3f6f2b3a8b438016e9f0933aa9f0a089ad80a9d4","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"1a83f055cbdb9d39e501ee108ff025c869d62fc2fc1ce857307fe2b27c14546d","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"11614f98166613617253292df91a4fb08efda1c828baf7d8289b453ccece288b","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"7964af66e8929b0ff13cc10955fa1fcf05893b6cb8448d1259c946cd8e5b2973","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"dcde8e22ac59b99666a222159411002b5e548793ddded6193cba8a58f5434f0e","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin; createdAt: string; }): MeetingSessionRecord;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"d36a8d24dc765d7a09d6f65af7d037207944e2a3d909fd778cc2e9f337648785","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"65d6be7a735b0bcedf28e96a8ccefc890a0e5ac92a76d81ce994a16a63555091","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"67ca56fe483e55f7bac10cb5ded5ebfdbafc79313e0e752f23f51e2b27af42c9","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"27f2245dea1f2336946424a941e20fa29618fad4fe8f7a7e58437162e60841f9","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"50c34e16421f69550210e8a52add1d551ab37103acb997a79271f107cef24217","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} @@ -3211,8 +3212,8 @@ {"closureHash":"f30516ada25b28b114ff728dc1bcd1c56e9aae5690c85e8efbfc4f18e46b019e","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"fda83ef4de35d734ac77c09e44b7ca6dee25544556cf541493068e35abc4ebf2","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"62c3bd0658f5de2584e9a808c584b0f8b123c107c5fabd942f66d071ed105ec9","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"4dd021b7b0b8ec784a7a53ad99c3bd766e0258c2b3008a2d5e9d18bd4399efac","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"c14f4dfad4abcfa9f5d2a6f1fa234ea9c1c8c59e620b55146ea7bafd28698633","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3244,7 +3245,7 @@ {"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles = MeetingSessionRuntimeHandles;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages = MeetingSessionRuntimeMessages;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"f76e98394a2856996fefa33d0c90039a057a464cf70ccc5557dd0ed601563f45","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} +{"closureHash":"5c75acb6738b1fc625b9e2069ab88a2f05d8f2548d12bc56dfd1b4dbc22e6298","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3258,9 +3259,9 @@ {"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"efb7296acddcd6cdcf1831b39966d7398cbc192349214e68cf4467f38029b80e","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} +{"closureHash":"78a6512201cb163f007806771a957dcf8193a5210295c209b4700c12bfe5a1c7","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} {"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise;\n writeOutput(audio: Buffer): Promise;\n clearOutput(): Promise;\n dispose(): Promise;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} -{"closureHash":"f19b31df94c0740e38b43329ff98b51b95ae74f1a78a0002a3695e8fd8dd18f4","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} +{"closureHash":"c1fe15f22d9394da6c737905918b8d403b294c45b92ac23d5f9cc39daa0056cb","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} {"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} @@ -3654,10 +3655,10 @@ {"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"} {"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"} {"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"e6a62d1d8883224a14d5cec346034465567ce2d5dd5c779171cd1056efd602cd","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"b6d84ccc910fc9dbcab4db6100777fb18263f3aee9324a1712840f6b93659384","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"767c7bc1bfc8749c3031c90521b4c05a7a81cc7f7e01b5de6cfacd4409cc336e","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"9ee8f28a25b6c5e00dd9c380a1ee3198fff3006074b5f7496c2aef3898c88d73","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"985678fdcb0af3573ff3068e18d2b9bc35c9561768494d872f175489f831ebd7","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"9387081608b8e497be223573602df8fae62ec91e40a9253f6e2210fe8baab74f","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"aa6b4ef57dcb8ff639f329c3fcdfe0321604c3a6684d2b83a6a74e5800f82a0c","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a994310960e2da8c48e61853362c356b24bf6ea4f909576aba3992a7de07fda2","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} {"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"} {"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} {"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} diff --git a/extensions/discord/src/approval-native.test.ts b/extensions/discord/src/approval-native.test.ts index 7cab380b3b18..ad3e30129c15 100644 --- a/extensions/discord/src/approval-native.test.ts +++ b/extensions/discord/src/approval-native.test.ts @@ -109,6 +109,33 @@ describe("createDiscordNativeApprovalAdapter", () => { ).toBe(true); }); + it("reports each configured account as a raw candidate for coordinator selection", () => { + const cfg = { + commands: { ownerAllowFrom: ["discord:123"] }, + channels: { + discord: { + accounts: { + default: { token: "token-default", execApprovals: { enabled: true } }, + ops: { token: "token-ops", execApprovals: { enabled: true } }, + }, + }, + }, + } as const; + const request = { + id: "approval-unbound", + request: { command: "pwd", turnSourceChannel: "discord" }, + createdAtMs: 1, + expiresAtMs: 2, + } as const; + + expect( + shouldHandleDiscordApprovalRequest({ cfg: cfg as never, accountId: "default", request }), + ).toBe(true); + expect( + shouldHandleDiscordApprovalRequest({ cfg: cfg as never, accountId: "ops", request }), + ).toBe(true); + }); + it("describes the correct Discord exec-approval setup path", () => { const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({ channel: "discord", @@ -299,7 +326,7 @@ describe("createDiscordNativeApprovalAdapter", () => { const target = await adapter.native?.resolveOriginTarget?.({ cfg: NATIVE_DELIVERY_CFG as never, - accountId: "main", + accountId: "default", approvalKind: "plugin", request: { id: "abc", @@ -347,7 +374,7 @@ describe("createDiscordNativeApprovalAdapter", () => { const target = await adapter.native?.resolveOriginTarget?.({ cfg: NATIVE_DELIVERY_CFG as never, - accountId: "main", + accountId: "default", approvalKind: "plugin", request: { id: "abc", diff --git a/extensions/discord/src/approval-shared.ts b/extensions/discord/src/approval-shared.ts index 9d205beb8da5..31245c919430 100644 --- a/extensions/discord/src/approval-shared.ts +++ b/extensions/discord/src/approval-shared.ts @@ -1,5 +1,5 @@ // Discord plugin module implements approval shared behavior. -import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; +import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest, @@ -8,7 +8,7 @@ import type { DiscordExecApprovalConfig, OpenClawConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { resolveDiscordAccount } from "./accounts.js"; +import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { isChannelExecApprovalClientEnabledFromConfig, matchesApprovalRequestFilters, @@ -17,41 +17,46 @@ import { getDiscordExecApprovalApprovers } from "./exec-approvals.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +function isDiscordApprovalAccountEligible(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ApprovalRequest; + configOverride?: DiscordExecApprovalConfig | null; +}): boolean { + const account = resolveDiscordAccount(params); + const config = params.configOverride ?? account.config.execApprovals; + return ( + account.enabled && + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getDiscordExecApprovalApprovers(params).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + }) + ); +} + export function shouldHandleDiscordApprovalRequest(params: { cfg: OpenClawConfig; accountId?: string | null; request: ApprovalRequest; configOverride?: DiscordExecApprovalConfig | null; }): boolean { - const config = - params.configOverride ?? - resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).config.execApprovals; - const approvers = getDiscordExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - configOverride: params.configOverride, - }); + const accountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg); if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, + !doesApprovalRequestSelectChannelAccount({ + ...params, channel: "discord", - accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + eligibleAccountIds: isDiscordApprovalAccountEligible({ ...params, accountId }) + ? [accountId] + : [], }) ) { return false; } - if ( - !isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: approvers.length, - }) - ) { - return false; - } - return matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - }); + return isDiscordApprovalAccountEligible(params); } diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index 5f775d0bc59f..b7fe310ae5af 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -141,7 +141,7 @@ describe("discord exec approval monitor helpers", () => { await button.run(interaction, { kind: approvalKind, id: "abc", action: "allow-once" }); expect(interaction["acknowledge"]).toHaveBeenCalled(); - expect(resolveApproval).toHaveBeenCalledWith("abc", approvalKind, "allow-once"); + expect(resolveApproval).toHaveBeenCalledWith("abc", approvalKind, "allow-once", "123"); expect(JSON.stringify(editReply.mock.calls[0]?.[0])).toContain("Approval resolved"); expect(interaction["followUp"]).not.toHaveBeenCalled(); }, @@ -262,7 +262,7 @@ describe("discord exec approval monitor helpers", () => { }); expect(ctx.getApprovers()).toEqual(["123"]); - await expect(ctx.resolveApproval("abc", approvalKind, "allow-once")).resolves.toEqual({ + await expect(ctx.resolveApproval("abc", approvalKind, "allow-once", "123")).resolves.toEqual({ ok: true, resolution, }); @@ -272,7 +272,8 @@ describe("discord exec approval monitor helpers", () => { approvalKind, decision: "allow-once", channel: "discord", - senderId: "default", + accountId: "default", + senderId: "123", gatewayUrl: "ws://127.0.0.1:18789", }); }, @@ -286,7 +287,7 @@ describe("discord exec approval monitor helpers", () => { config: { enabled: true, approvers: ["123"] }, }); - await expect(ctx.resolveApproval("abc", "exec", "allow-once")).resolves.toEqual({ + await expect(ctx.resolveApproval("abc", "exec", "allow-once", "123")).resolves.toEqual({ ok: false, reason: "error", }); @@ -304,7 +305,7 @@ describe("discord exec approval monitor helpers", () => { config: { enabled: true, approvers: ["123"] }, }); - await expect(ctx.resolveApproval("abc", "plugin", "allow-once")).resolves.toEqual({ + await expect(ctx.resolveApproval("abc", "plugin", "allow-once", "123")).resolves.toEqual({ ok: false, reason: "not-found", }); @@ -318,7 +319,7 @@ describe("discord exec approval monitor helpers", () => { config: { enabled: true, approvers: ["123"] }, }); - await expect(ctx.resolveApproval("abc", "exec", "allow-once")).resolves.toEqual({ + await expect(ctx.resolveApproval("abc", "exec", "allow-once", "123")).resolves.toEqual({ ok: false, reason: "error", }); diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 71e85ea26992..4c62672c8118 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -32,6 +32,7 @@ type ExecApprovalButtonContext = { approvalId: string, approvalKind: PendingApprovalView["approvalKind"], decision: ExecApprovalDecision, + senderId: string, ) => Promise; }; @@ -141,6 +142,7 @@ class ExecApprovalButton extends Button { parsed.approvalId, parsed.approvalKind, parsed.action, + userId, ); if (!result.ok) { try { @@ -197,7 +199,7 @@ export function createDiscordExecApprovalButtonContext(params: { accountId: params.accountId, configOverride: params.config, }), - resolveApproval: async (approvalId, approvalKind, decision) => { + resolveApproval: async (approvalId, approvalKind, decision, senderId) => { try { const resolution = await resolveApprovalOverGateway({ cfg: params.cfg, @@ -205,7 +207,8 @@ export function createDiscordExecApprovalButtonContext(params: { approvalKind, decision, channel: "discord", - senderId: params.accountId, + accountId: params.accountId, + senderId, gatewayUrl: params.gatewayUrl, }); return { ok: true, resolution }; diff --git a/extensions/googlechat/src/approval-card-click.test.ts b/extensions/googlechat/src/approval-card-click.test.ts index f4ba9451f784..22133caa0066 100644 --- a/extensions/googlechat/src/approval-card-click.test.ts +++ b/extensions/googlechat/src/approval-card-click.test.ts @@ -165,6 +165,7 @@ describe("maybeHandleGoogleChatApprovalCardClick", () => { approvalKind: "exec", decision: "allow-once", channel: "googlechat", + accountId: "default", senderId: "users/123", }); expect(updateGoogleChatMessage).toHaveBeenCalledWith({ diff --git a/extensions/googlechat/src/approval-card-click.ts b/extensions/googlechat/src/approval-card-click.ts index 5e8c43021e7d..a57cd0c14d17 100644 --- a/extensions/googlechat/src/approval-card-click.ts +++ b/extensions/googlechat/src/approval-card-click.ts @@ -87,6 +87,7 @@ export async function maybeHandleGoogleChatApprovalCardClick(params: { approvalKind: consumed.approvalKind, decision: consumed.decision, channel: "googlechat", + accountId: params.target.account.accountId, senderId: actor, }); await updateGoogleChatMessage({ diff --git a/extensions/imessage/src/approval-polls.ts b/extensions/imessage/src/approval-polls.ts index 934bf8fee258..18f557dc6c56 100644 --- a/extensions/imessage/src/approval-polls.ts +++ b/extensions/imessage/src/approval-polls.ts @@ -543,6 +543,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: { approvalKind: target.approvalKind, decision, channel: "imessage", + accountId: params.accountId, senderId: event.actorHandle, gatewayUrl: params.gatewayUrl, ...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}), diff --git a/extensions/imessage/src/approval-reactions.test.ts b/extensions/imessage/src/approval-reactions.test.ts index eb285a08386c..0630f4c24d33 100644 --- a/extensions/imessage/src/approval-reactions.test.ts +++ b/extensions/imessage/src/approval-reactions.test.ts @@ -774,6 +774,7 @@ describe("iMessage approval reactions", () => { approvalId: "exec-self", decision: "allow-once", channel: "imessage", + accountId: "default", senderId: "+15551230000", gatewayRuntime, }), @@ -857,6 +858,7 @@ describe("iMessage approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -901,6 +903,7 @@ describe("iMessage approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -959,6 +962,7 @@ describe("iMessage approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -1027,6 +1031,7 @@ describe("iMessage approval reactions", () => { approvalKind: "plugin", decision: "allow-once", channel: "imessage", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -1067,6 +1072,7 @@ describe("iMessage approval reactions", () => { approvalKind: "exec", decision: "deny", channel: "imessage", + accountId: "default", senderId: "+15551239999", gatewayUrl: undefined, }); diff --git a/extensions/imessage/src/approval-reactions.ts b/extensions/imessage/src/approval-reactions.ts index 78a382d97a8f..f2004fb9026c 100644 --- a/extensions/imessage/src/approval-reactions.ts +++ b/extensions/imessage/src/approval-reactions.ts @@ -694,6 +694,7 @@ export async function handleIMessageApprovalReaction(params: { approvalKind: target.approvalKind, decision: target.decision, channel: "imessage", + accountId: params.accountId, senderId: event.actorHandle, gatewayUrl: params.gatewayUrl, ...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}), diff --git a/extensions/matrix/src/exec-approvals.test.ts b/extensions/matrix/src/exec-approvals.test.ts index 25acb16332ea..128583040d1c 100644 --- a/extensions/matrix/src/exec-approvals.test.ts +++ b/extensions/matrix/src/exec-approvals.test.ts @@ -422,7 +422,7 @@ describe("matrix exec approvals", () => { ).toBe(true); }); - it("rejects unbound foreign-channel approvals in multi-account matrix configs", () => { + it("reports each eligible foreign-channel account as a raw route candidate", () => { const cfg = buildMultiAccountMatrixConfig({}); const request = makeForeignChannelApprovalRequest({ id: "req-4" }); @@ -432,14 +432,29 @@ describe("matrix exec approvals", () => { accountId: "default", request, }), - ).toBe(false); + ).toBe(true); expect( shouldHandleMatrixExecApprovalRequest({ cfg, accountId: "ops", request, }), - ).toBe(false); + ).toBe(true); + }); + + it("reports each eligible same-channel account as a raw route candidate", () => { + const cfg = buildMultiAccountMatrixConfig({}); + const request: MatrixExecApprovalRequest = { + id: "req-same-channel-unbound", + request: { command: "echo hi", turnSourceChannel: "matrix" }, + createdAtMs: 0, + expiresAtMs: 1000, + }; + + expect(shouldHandleMatrixExecApprovalRequest({ cfg, accountId: "default", request })).toBe( + true, + ); + expect(shouldHandleMatrixExecApprovalRequest({ cfg, accountId: "ops", request })).toBe(true); }); it("allows unbound foreign-channel approvals when only one matrix account can handle them", () => { diff --git a/extensions/matrix/src/exec-approvals.ts b/extensions/matrix/src/exec-approvals.ts index da242df5cb4c..2df3464090a8 100644 --- a/extensions/matrix/src/exec-approvals.ts +++ b/extensions/matrix/src/exec-approvals.ts @@ -7,18 +7,16 @@ import { isChannelExecApprovalTargetRecipient, matchesApprovalRequestFilters, } from "openclaw/plugin-sdk/approval-client-runtime"; -import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime"; +import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getMatrixApprovalAuthApprovers } from "./approval-auth.js"; import { normalizeMatrixApproverId } from "./approval-ids.js"; -import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js"; +import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js"; import type { CoreConfig } from "./types.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; @@ -44,45 +42,31 @@ function resolveMatrixExecApprovalConfig(params: { }; } -function countMatrixExecApprovalEligibleAccounts(params: { +function isMatrixExecApprovalAccountEligible(params: { cfg: OpenClawConfig; + accountId: string; request: ApprovalRequest; approvalKind: ApprovalKind; -}): number { - return listMatrixAccountIds(params.cfg).filter((accountId) => { - const account = resolveMatrixAccount({ cfg: params.cfg, accountId }); - if (!account.enabled || !account.configured) { - return false; - } - const config = resolveMatrixExecApprovalConfig({ - cfg: params.cfg, - accountId, - }); - const filters = config?.enabled - ? { - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, - } - : { - agentFilter: undefined, - sessionFilter: undefined, - }; - return ( - isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getMatrixApprovalApprovers({ - cfg: params.cfg, - accountId, - approvalKind: params.approvalKind, - }).length, - }) && - matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: filters.agentFilter, - sessionFilter: filters.sessionFilter, - }) - ); - }).length; +}): boolean { + const account = resolveMatrixAccount(params); + if (!account.enabled || !account.configured) { + return false; + } + const config = resolveMatrixExecApprovalConfig(params); + const filters = config?.enabled + ? { agentFilter: config.agentFilter, sessionFilter: config.sessionFilter } + : { agentFilter: undefined, sessionFilter: undefined }; + return ( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getMatrixApprovalApprovers(params).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: filters.agentFilter, + sessionFilter: filters.sessionFilter, + }) + ); } function matchesMatrixRequestAccount(params: { @@ -91,28 +75,15 @@ function matchesMatrixRequestAccount(params: { request: ApprovalRequest; approvalKind: ApprovalKind; }): boolean { - const turnSourceChannel = normalizeLowercaseStringOrEmpty( - params.request.request.turnSourceChannel, - ); - const boundAccountId = resolveApprovalRequestChannelAccountId({ - cfg: params.cfg, - request: params.request, + const accountId = params.accountId ?? resolveDefaultMatrixAccountId(params.cfg); + return doesApprovalRequestSelectChannelAccount({ + ...params, channel: "matrix", + defaultAccountId: resolveDefaultMatrixAccountId(params.cfg), + eligibleAccountIds: isMatrixExecApprovalAccountEligible({ ...params, accountId }) + ? [accountId] + : [], }); - if (turnSourceChannel && turnSourceChannel !== "matrix" && !boundAccountId) { - return ( - countMatrixExecApprovalEligibleAccounts({ - cfg: params.cfg, - request: params.request, - approvalKind: params.approvalKind, - }) <= 1 - ); - } - return ( - !boundAccountId || - !params.accountId || - normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId) - ); } export function getMatrixExecApprovalApprovers(params: { diff --git a/extensions/matrix/src/matrix/monitor/reaction-events.test.ts b/extensions/matrix/src/matrix/monitor/reaction-events.test.ts index 6c2424d2bd84..d0dad368d033 100644 --- a/extensions/matrix/src/matrix/monitor/reaction-events.test.ts +++ b/extensions/matrix/src/matrix/monitor/reaction-events.test.ts @@ -193,6 +193,7 @@ describe("matrix approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "matrix", + accountId: "default", senderId: "@owner:example.org", }); expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled(); @@ -263,6 +264,7 @@ describe("matrix approval reactions", () => { approvalKind: "exec", decision: "deny", channel: "matrix", + accountId: "default", senderId: "@owner:example.org", }); expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled(); @@ -291,6 +293,7 @@ describe("matrix approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "matrix", + accountId: "default", senderId: "@owner:example.org", }); expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled(); @@ -327,6 +330,7 @@ describe("matrix approval reactions", () => { approvalKind: "plugin", decision: "allow-once", channel: "matrix", + accountId: "default", senderId: "@owner:example.org", }); expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled(); diff --git a/extensions/matrix/src/matrix/monitor/reaction-events.ts b/extensions/matrix/src/matrix/monitor/reaction-events.ts index 5e8b9a7963fb..1a9e1ce06b51 100644 --- a/extensions/matrix/src/matrix/monitor/reaction-events.ts +++ b/extensions/matrix/src/matrix/monitor/reaction-events.ts @@ -129,6 +129,7 @@ async function maybeResolveMatrixApprovalReaction(params: { approvalKind: params.target.approvalKind, decision: params.target.decision, channel: "matrix", + accountId: params.accountId, senderId: params.senderId, }); // Retire every delivered anchor; losing surfaces also need the canonical diff --git a/extensions/qqbot/src/bridge/bootstrap.test.ts b/extensions/qqbot/src/bridge/bootstrap.test.ts index acc6d528f038..e21d66efb045 100644 --- a/extensions/qqbot/src/bridge/bootstrap.test.ts +++ b/extensions/qqbot/src/bridge/bootstrap.test.ts @@ -88,6 +88,8 @@ describe("QQBot built-in platform adapter", () => { approvalId: "exec:looks-like-exec/1", approvalKind: "plugin", decision: "allow-once", + accountId: "default", + senderId: "owner", }); expect(mocks.resolveApprovalOverGateway).toHaveBeenCalledWith({ @@ -95,6 +97,9 @@ describe("QQBot built-in platform adapter", () => { approvalId: "exec:looks-like-exec/1", approvalKind: "plugin", decision: "allow-once", + channel: "qqbot", + accountId: "default", + senderId: "owner", clientDisplayName: "QQBot Approval Handler", }); expect(result).toBe(canonicalLoserResult); diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts index 7e062935a976..bbf2f231250c 100644 --- a/extensions/qqbot/src/bridge/bootstrap.ts +++ b/extensions/qqbot/src/bridge/bootstrap.ts @@ -1,3 +1,18 @@ +import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { + registerPlatformAdapter, + registerPlatformAdapterFactory, + hasPlatformAdapter, + type PlatformAdapter, +} from "../engine/adapter/index.js"; +import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js"; /** * Bootstrap the PlatformAdapter for the built-in version. * @@ -22,22 +37,6 @@ * statically at the top level so they work reliably in both production and * vitest (which resolves bare specifiers via `resolve.alias`, not Node CJS). */ - -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { - registerPlatformAdapter, - registerPlatformAdapterFactory, - hasPlatformAdapter, - type PlatformAdapter, -} from "../engine/adapter/index.js"; -import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js"; import { getBridgeLogger } from "./logger.js"; const loadReadRemoteMediaBuffer = createLazyRuntimeNamedExport( @@ -113,6 +112,9 @@ function createBuiltinAdapter(): PlatformAdapter { approvalId: params.approvalId, approvalKind: params.approvalKind, decision: params.decision, + channel: "qqbot", + accountId: params.accountId, + senderId: params.senderId, clientDisplayName: "QQBot Approval Handler", }); } catch (err) { diff --git a/extensions/qqbot/src/engine/adapter/index.ts b/extensions/qqbot/src/engine/adapter/index.ts index ec13ba6781cf..25a44b3a219f 100644 --- a/extensions/qqbot/src/engine/adapter/index.ts +++ b/extensions/qqbot/src/engine/adapter/index.ts @@ -51,6 +51,8 @@ export interface PlatformAdapter { approvalId: string; approvalKind: "exec" | "plugin"; decision: "allow-once" | "allow-always" | "deny"; + accountId: string; + senderId: string; }): Promise; } diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts index ccec1a49c3b6..a2e32168e0d5 100644 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts +++ b/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts @@ -46,11 +46,14 @@ const appliedApprovalResult = { const resolveApprovalMock = vi.fn( async (): Promise => appliedApprovalResult, ); -const expectedApprovalResolve = { - approvalId: "exec:abc12345", - approvalKind: "exec", - decision: "allow-once", -} as const; +const expectedApprovalResolve = (senderId = "ATTACKER_OPENID") => + ({ + approvalId: "exec:abc12345", + approvalKind: "exec", + decision: "allow-once", + accountId: "default", + senderId, + }) as const; function makeAccount(config: GatewayAccount["config"] = {}): GatewayAccount { return { @@ -190,7 +193,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")), ); }); @@ -217,6 +220,8 @@ describe("createInteractionHandler approval buttons", () => { approvalId: "exec:looks-like-exec/1", approvalKind: "plugin", decision: "deny", + accountId: "default", + senderId: "OWNER_OPENID", }), ); }); @@ -340,7 +345,7 @@ describe("createInteractionHandler approval buttons", () => { ); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")), ); }); @@ -352,7 +357,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), ); }); @@ -418,7 +423,10 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith({ + ...expectedApprovalResolve(), + accountId: "bot2", + }), ); }); @@ -449,7 +457,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), ); }); @@ -474,7 +482,7 @@ describe("createInteractionHandler approval buttons", () => { handler(makeApprovalEvent()); await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve), + expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), ); }); diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.ts index a4bcda823b80..5efc37d070b8 100644 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.ts +++ b/extensions/qqbot/src/engine/gateway/interaction-handler.ts @@ -290,7 +290,11 @@ async function handleApprovalButtonInteraction(params: { } try { - const result = await adapter.resolveApproval(params.parsed); + const result = await adapter.resolveApproval({ + ...params.parsed, + accountId: params.account.accountId, + senderId: authorization.senderId, + }); const canonicalDecision = "decision" in result.approval ? `, decision=${result.approval.decision}` : ""; const canonicalOutcome = formatCanonicalApprovalOutcome(result.approval); @@ -379,21 +383,13 @@ async function authorizeApprovalButtonActor(params: { event: InteractionEvent; approvalKind: "exec" | "plugin"; resolveCommandAuthorized?: QQBotCommandAuthorizationResolver; -}): Promise<{ authorized: boolean; reason?: string }> { +}): Promise<{ authorized: true; senderId: string } | { authorized: false; reason?: string }> { const senderIds = resolveApprovalActorSenderIds(params.event); if (senderIds.length === 0) { - const result = authorizeQQBotApprovalAction({ - cfg: params.cfg, - accountId: params.account.accountId, - senderId: null, - approvalKind: params.approvalKind, - }); - return result.authorized && isImplicitSameChatApprovalAuthorization(result) - ? { authorized: false, reason: "You are not authorized to approve this request." } - : result; + return { authorized: false, reason: "You are not authorized to approve this request." }; } - let denial: { authorized: boolean; reason?: string } | undefined; + let denial: { authorized: false; reason?: string } | undefined; for (const senderId of senderIds) { const result = authorizeQQBotApprovalAction({ cfg: params.cfg, @@ -412,7 +408,7 @@ async function authorizeApprovalButtonActor(params: { resolveCommandAuthorized: params.resolveCommandAuthorized, })) ) { - return result; + return { authorized: true, senderId }; } denial ??= { authorized: false, @@ -420,7 +416,7 @@ async function authorizeApprovalButtonActor(params: { }; continue; } - denial ??= result; + denial ??= { authorized: false, ...(result.reason ? { reason: result.reason } : {}) }; } return denial ?? { authorized: false, reason: "You are not authorized to approve this request." }; } diff --git a/extensions/qqbot/src/exec-approvals.test.ts b/extensions/qqbot/src/exec-approvals.test.ts index 98e1b3e906ea..2bad6c2684ec 100644 --- a/extensions/qqbot/src/exec-approvals.test.ts +++ b/extensions/qqbot/src/exec-approvals.test.ts @@ -3,7 +3,7 @@ import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/app import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerPlatformAdapter, type PlatformAdapter } from "./engine/adapter/index.js"; -import { authorizeQQBotApprovalAction } from "./exec-approvals.js"; +import { authorizeQQBotApprovalAction, matchesQQBotApprovalAccount } from "./exec-approvals.js"; describe("authorizeQQBotApprovalAction", () => { beforeEach(() => { @@ -66,4 +66,32 @@ describe("authorizeQQBotApprovalAction", () => { expect(result).toEqual({ authorized: true }); expect(isImplicitSameChatApprovalAuthorization(result)).toBe(false); }); + + it("reports each configured account as a raw route candidate", () => { + const cfg = { + channels: { + qqbot: { + accounts: { + default: { + appId: "default-app", + clientSecret: "default-secret", + execApprovals: { enabled: true, approvers: ["OWNER"] }, + }, + ops: { + appId: "ops-app", + clientSecret: "ops-secret", + execApprovals: { enabled: true, approvers: ["OWNER"] }, + }, + }, + }, + }, + } as OpenClawConfig; + const request = { + id: "req-unbound", + request: { command: "echo hi", turnSourceChannel: "qqbot" }, + }; + + expect(matchesQQBotApprovalAccount({ cfg, accountId: "default", request })).toBe(true); + expect(matchesQQBotApprovalAccount({ cfg, accountId: "ops", request })).toBe(true); + }); }); diff --git a/extensions/qqbot/src/exec-approvals.ts b/extensions/qqbot/src/exec-approvals.ts index e3bf73069fc5..8e4603d78b66 100644 --- a/extensions/qqbot/src/exec-approvals.ts +++ b/extensions/qqbot/src/exec-approvals.ts @@ -8,18 +8,14 @@ import { isChannelExecApprovalClientEnabledFromConfig, matchesApprovalRequestFilters, } from "openclaw/plugin-sdk/approval-client-runtime"; -import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime"; +import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { listQQBotAccountIds, resolveQQBotAccount } from "./bridge/config.js"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./bridge/config.js"; import type { QQBotExecApprovalConfig } from "./types.js"; function normalizeApproverId(value: string | number): string | undefined { @@ -54,32 +50,28 @@ function getQQBotExecApprovalApprovers(params: { }); } -function countQQBotExecApprovalEligibleAccounts(params: { +function isQQBotExecApprovalAccountEligible(params: { cfg: OpenClawConfig; + accountId: string; request: ExecApprovalRequest | PluginApprovalRequest; -}): number { - return listQQBotAccountIds(params.cfg).filter((accountId) => { - const account = resolveQQBotAccount(params.cfg, accountId); - if (!account.enabled || account.secretSource === "none") { - return false; - } - const config = resolveQQBotExecApprovalConfig({ - cfg: params.cfg, - accountId, - }); - return ( - isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getQQBotExecApprovalApprovers({ cfg: params.cfg, accountId }).length, - }) && - matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - fallbackAgentIdFromSessionKey: true, - }) - ); - }).length; +}): boolean { + const account = resolveQQBotAccount(params.cfg, params.accountId); + if (!account.enabled || account.secretSource === "none") { + return false; + } + const config = resolveQQBotExecApprovalConfig(params); + return ( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getQQBotExecApprovalApprovers(params).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + fallbackAgentIdFromSessionKey: true, + }) + ); } function matchesQQBotRequestAccount(params: { @@ -87,81 +79,30 @@ function matchesQQBotRequestAccount(params: { accountId?: string | null; request: ExecApprovalRequest | PluginApprovalRequest; }): boolean { - const turnSourceChannel = normalizeLowercaseStringOrEmpty( - params.request.request.turnSourceChannel, - ); - const boundAccountId = resolveApprovalRequestChannelAccountId({ - cfg: params.cfg, - request: params.request, + const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg); + return doesApprovalRequestSelectChannelAccount({ + ...params, channel: "qqbot", + defaultAccountId: resolveDefaultQQBotAccountId(params.cfg), + eligibleAccountIds: isQQBotExecApprovalAccountEligible({ ...params, accountId }) + ? [accountId] + : [], }); - if (turnSourceChannel && turnSourceChannel !== "qqbot" && !boundAccountId) { - return ( - countQQBotExecApprovalEligibleAccounts({ - cfg: params.cfg, - request: params.request, - }) <= 1 - ); - } - return ( - !boundAccountId || - !params.accountId || - normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId) - ); } -/** - * Count QQBot accounts that could actually deliver a native approval - * message — i.e. accounts that are enabled and have resolvable secrets. - * Disabled or unconfigured accounts never spawn a handler, so they - * must not contribute to the single-account shortcut in the fallback - * ownership check below. - */ -function countQQBotFallbackEligibleAccounts(cfg: OpenClawConfig): number { - return listQQBotAccountIds(cfg).filter((accountId) => { - const account = resolveQQBotAccount(cfg, accountId); - return account.enabled && account.secretSource !== "none"; - }).length; -} - -/** - * Fallback account-ownership check — applied when `execApprovals` is NOT - * configured for any QQBot account. In this mode every enabled account - * handler would otherwise race to deliver the same approval to its own - * openid namespace, so we must enforce per-account isolation. - * - * Rules: - * - If the request carries a bound account (via `turnSourceAccountId` - * or session binding), only the handler whose `accountId` matches it - * delivers the approval. This is strict: a handler with an unknown - * `accountId` (null/undefined) must not claim a bound request. - * - If no account is bound, only deliver when there is a single - * *eligible* QQBot account (enabled + secret resolved). Disabled or - * unconfigured accounts never deliver anyway, so they shouldn't - * block the remaining single account from handling the approval. - * Multiple eligible accounts cannot safely race because openids are - * account-scoped — cross-account delivery hits the QQ Bot API with - * a mismatched token and fails. - */ function matchesQQBotFallbackRequestAccount(params: { cfg: OpenClawConfig; accountId?: string | null; request: ExecApprovalRequest | PluginApprovalRequest; }): boolean { - const boundAccountId = resolveApprovalRequestChannelAccountId({ - cfg: params.cfg, - request: params.request, + const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg); + const account = resolveQQBotAccount(params.cfg, accountId); + return doesApprovalRequestSelectChannelAccount({ + ...params, channel: "qqbot", + defaultAccountId: resolveDefaultQQBotAccountId(params.cfg), + eligibleAccountIds: account.enabled && account.secretSource !== "none" ? [accountId] : [], }); - - if (boundAccountId) { - if (!params.accountId) { - return false; - } - return normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId); - } - - return countQQBotFallbackEligibleAccounts(params.cfg) <= 1; } /** diff --git a/extensions/signal/src/approval-reactions.test.ts b/extensions/signal/src/approval-reactions.test.ts index 1f609fde2e81..e4140a9e6352 100644 --- a/extensions/signal/src/approval-reactions.test.ts +++ b/extensions/signal/src/approval-reactions.test.ts @@ -761,6 +761,7 @@ describe("Signal approval reactions", () => { approvalKind: "plugin", decision: "allow-once", channel: "signal", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -822,6 +823,7 @@ describe("Signal approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "signal", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); diff --git a/extensions/signal/src/approval-reactions.ts b/extensions/signal/src/approval-reactions.ts index 7ff4c1d55078..728b514560e1 100644 --- a/extensions/signal/src/approval-reactions.ts +++ b/extensions/signal/src/approval-reactions.ts @@ -917,6 +917,7 @@ export async function maybeResolveSignalApprovalReaction(params: { approvalKind: target.approvalKind, decision: target.decision, channel: "signal", + accountId: params.accountId, senderId: actorId, gatewayUrl: params.gatewayUrl, }); diff --git a/extensions/slack/src/approval-native-gates.ts b/extensions/slack/src/approval-native-gates.ts index 7915a7870114..80a7a126593a 100644 --- a/extensions/slack/src/approval-native-gates.ts +++ b/extensions/slack/src/approval-native-gates.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/approval-client-runtime"; import { createNativeApprovalChannelRouteGates, - doesApprovalRequestMatchChannelAccount, + doesApprovalRequestSelectChannelAccount, resolveApprovalRequestSessionConversation, } from "openclaw/plugin-sdk/approval-native-runtime"; import type { @@ -311,29 +311,19 @@ function shouldHandleSlackPluginViaNativeClientConfig(params: { request: SlackNativeApprovalRequest; }): boolean { if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, + !doesApprovalRequestSelectChannelAccount({ + ...params, channel: "slack", - accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + eligibleAccountIds: listSlackNativeApprovalEligibleAccountIds({ + ...params, + approvalKind: "plugin", + }), }) ) { return false; } - const config = resolveSlackNativeApprovalConfig(params); - if ( - !isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getSlackApprovalApprovers(params).length, - }) - ) { - return false; - } - return matchesSlackNativeApprovalFilters({ - request: params.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - }); + return isSlackNativeApprovalAccountEligible({ ...params, approvalKind: "plugin" }); } function matchesSlackNativeApprovalFilters(params: { @@ -348,6 +338,35 @@ function matchesSlackNativeApprovalFilters(params: { }); } +function isSlackNativeApprovalAccountEligible(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: SlackNativeApprovalRequest; + approvalKind: SlackApprovalKind; +}): boolean { + const config = resolveSlackNativeApprovalConfig(params); + const approverCount = + params.approvalKind === "exec" + ? getSlackExecApprovalApprovers(params).length + : getSlackApprovalApprovers(params).length; + return ( + isSlackApprovalTransportEnabled(params) && + isChannelExecApprovalClientEnabledFromConfig({ enabled: config?.enabled, approverCount }) && + matchesSlackNativeApprovalFilters({ + request: params.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + }) + ); +} + +function listSlackNativeApprovalEligibleAccountIds( + params: Parameters[0], +): string[] { + const accountId = params.accountId ?? resolveDefaultSlackAccountId(params.cfg); + return isSlackNativeApprovalAccountEligible({ ...params, accountId }) ? [accountId] : []; +} + function isAnyForwardedSlackExplicitTargetEligible(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -435,29 +454,19 @@ export function shouldHandleSlackNativeApprovalRequest(params: { ); } if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, + !doesApprovalRequestSelectChannelAccount({ + ...params, channel: "slack", - accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + eligibleAccountIds: listSlackNativeApprovalEligibleAccountIds({ + ...params, + approvalKind: "exec", + }), }) ) { return false; } - const config = resolveSlackNativeApprovalConfig(params); - if ( - !isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getSlackExecApprovalApprovers(params).length, - }) - ) { - return false; - } - return matchesSlackNativeApprovalFilters({ - request: params.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - }); + return isSlackNativeApprovalAccountEligible({ ...params, approvalKind: "exec" }); } export function resolveEnterpriseApprovalTeamId( diff --git a/extensions/slack/src/approval-native.test.ts b/extensions/slack/src/approval-native.test.ts index 01df655c7d3f..ab99130222c5 100644 --- a/extensions/slack/src/approval-native.test.ts +++ b/extensions/slack/src/approval-native.test.ts @@ -107,6 +107,50 @@ async function resolvePluginOriginTarget(sessionKey: string) { } describe("slack native approval adapter", () => { + it("reports each configured account as a raw route candidate", () => { + const cfg = { + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-default", + appToken: "xapp-default", + execApprovals: { enabled: true, approvers: ["U123APPROVER"] }, + }, + ops: { + botToken: "xoxb-ops", + appToken: "xapp-ops", + execApprovals: { enabled: true, approvers: ["U123APPROVER"] }, + }, + }, + }, + }, + } as OpenClawConfig; + const request = { + id: "req-unbound", + request: { command: "echo hi", turnSourceChannel: "slack" }, + createdAtMs: 0, + expiresAtMs: 1000, + }; + + expect( + slackApprovalCapability.nativeRuntime?.availability.shouldHandle({ + cfg, + accountId: "default", + approvalKind: "exec", + request, + }), + ).toBe(true); + expect( + slackApprovalCapability.nativeRuntime?.availability.shouldHandle({ + cfg, + accountId: "ops", + approvalKind: "exec", + request, + }), + ).toBe(true); + }); + it("subscribes the native runtime to exec and plugin approval events", () => { expect(slackApprovalCapability.nativeRuntime?.eventKinds).toEqual(["exec", "plugin"]); }); diff --git a/extensions/slack/src/exec-approvals.ts b/extensions/slack/src/exec-approvals.ts index 536b5fac5509..9c82e9e88b9c 100644 --- a/extensions/slack/src/exec-approvals.ts +++ b/extensions/slack/src/exec-approvals.ts @@ -4,7 +4,6 @@ import { createChannelExecApprovalProfile, isChannelExecApprovalTargetRecipient, } from "openclaw/plugin-sdk/approval-client-runtime"; -import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveSlackAccount } from "./accounts.js"; @@ -70,13 +69,6 @@ const slackExecApprovalProfile = createChannelExecApprovalProfile({ resolveApprovers: getSlackExecApprovalApprovers, normalizeSenderId: normalizeSlackApproverId, isTargetRecipient: isSlackExecApprovalTargetRecipient, - matchesRequestAccount: (params) => - doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, - channel: "slack", - accountId: params.accountId, - }), }); export const isSlackExecApprovalClientEnabled = slackExecApprovalProfile.isClientEnabled; diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index cb0914d19d27..745d69501aef 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -657,6 +657,7 @@ async function handleSlackApprovalInteraction(params: { approvalKind: params.approval.approvalKind, decision: params.approval.decision, channel: "slack", + accountId: params.ctx.accountId, senderId: params.parsed.userId, }); const terminalLabel = resolveSlackApprovalTerminalLabel(result.approval); @@ -752,6 +753,7 @@ async function handleSlackLegacyApprovalInteraction(params: { approvalId: parsedApproval.approvalId, decision: parsedApproval.decision, channel: "slack", + accountId: params.ctx.accountId, senderId: params.parsed.userId, resolveMethod, }); diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 93ab61b508a1..bb6a92aeb3db 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -1559,6 +1559,7 @@ describe("registerSlackInteractionEvents", () => { decision: "allow-once", senderId: "U123", channel: "slack", + accountId: "default", }); expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled(); expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled(); @@ -1687,6 +1688,7 @@ describe("registerSlackInteractionEvents", () => { decision: "allow-once", senderId: "U123", channel: "slack", + accountId: "default", }); expectRecordFields(chatUpdateCall(app), { channel: "C1", @@ -1890,6 +1892,7 @@ describe("registerSlackInteractionEvents", () => { decision: "allow-always", senderId: "U123OWNER", channel: "slack", + accountId: "default", }); expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled(); expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled(); @@ -1969,6 +1972,7 @@ describe("registerSlackInteractionEvents", () => { senderId: "U123OWNER", resolveMethod: "plugin", channel: "slack", + accountId: "default", }); expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled(); expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled(); @@ -2042,6 +2046,7 @@ describe("registerSlackInteractionEvents", () => { decision: "allow-once", senderId: "U123OWNER", channel: "slack", + accountId: "default", }; expect(resolveApprovalOverGatewayMock).toHaveBeenNthCalledWith(1, { ...expectedCommon, @@ -2121,6 +2126,7 @@ describe("registerSlackInteractionEvents", () => { senderId: "U999EXEC", resolveMethod: "exec", channel: "slack", + accountId: "default", }); expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled(); expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled(); diff --git a/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts b/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts index 8778193ae9e6..cd3237ac9d79 100644 --- a/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts @@ -100,6 +100,7 @@ export function createTelegramCallbackApprovalRuntime(params: { approvalKind: approvalCallback.approvalKind, decision: approvalCallback.decision, channel: "telegram", + accountId, senderId, })) as ApprovalResolveResult; @@ -197,6 +198,7 @@ export function createTelegramCallbackApprovalRuntime(params: { approvalId: approvalCallback.approvalId, decision: approvalCallback.decision, channel: "telegram", + accountId, senderId, resolveMethod: approvalKind, }); diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index 3b9b5cdf2c1e..8746b4582c04 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -273,7 +273,7 @@ describe("telegram exec approvals", () => { ).toBe(true); }); - it("rejects unbound foreign-channel approvals in multi-account telegram configs", () => { + it("reports each eligible foreign-channel account as a raw route candidate", () => { const cfg = buildMultiAccountTelegramConfig({}); const request = makeForeignChannelApprovalRequest({ id: "req-3" }); @@ -283,14 +283,33 @@ describe("telegram exec approvals", () => { accountId: "default", request, }), - ).toBe(false); + ).toBe(true); expect( shouldHandleTelegramExecApprovalRequest({ cfg, accountId: "ops", request, }), - ).toBe(false); + ).toBe(true); + }); + + it("reports each eligible same-channel account as a raw route candidate", () => { + const cfg = buildMultiAccountTelegramConfig({}); + const request: TelegramExecApprovalRequest = { + id: "req-same-channel-unbound", + request: { + command: "echo hi", + turnSourceChannel: "telegram", + sessionKey: "agent:ops:missing", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }; + + expect(shouldHandleTelegramExecApprovalRequest({ cfg, accountId: "default", request })).toBe( + true, + ); + expect(shouldHandleTelegramExecApprovalRequest({ cfg, accountId: "ops", request })).toBe(true); }); it("allows unbound foreign-channel approvals when only one telegram account can handle them", () => { @@ -384,8 +403,18 @@ describe("telegram exec approvals", () => { }); it("preserves unscoped telegram targets when mixed with scoped target accountIds", () => { + const baseCfg = buildMultiAccountTelegramConfig({}); const cfg = { - ...buildMultiAccountTelegramConfig({}), + ...baseCfg, + channels: { + telegram: { + ...baseCfg.channels?.telegram, + accounts: { + ...baseCfg.channels?.telegram?.accounts, + other: telegramAccount("other", { enabled: true, approvers: ["123"] }), + }, + }, + }, approvals: { exec: { enabled: true, @@ -421,6 +450,13 @@ describe("telegram exec approvals", () => { request, }), ).toBe(true); + expect( + shouldHandleTelegramExecApprovalRequest({ + cfg, + accountId: "other", + request, + }), + ).toBe(false); }); it("ignores disabled telegram accounts when checking foreign-channel ambiguity", () => { diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index cb9ec52c187c..53967fbb5fd7 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -6,7 +6,7 @@ import { isChannelExecApprovalTargetRecipient, matchesApprovalRequestFilters, } from "openclaw/plugin-sdk/approval-client-runtime"; -import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime"; +import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest, @@ -14,12 +14,8 @@ import type { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { normalizeTelegramChatId, resolveTelegramTargetChatType } from "./targets.js"; function normalizeApproverId(value: string | number): string { @@ -83,73 +79,27 @@ export function isTelegramExecApprovalTargetRecipient(params: { }); } -function countTelegramExecApprovalEligibleAccounts(params: { +function isTelegramExecApprovalAccountEligible(params: { cfg: OpenClawConfig; + accountId: string; request: ExecApprovalRequest | PluginApprovalRequest; -}): number { - return listTelegramAccountIds(params.cfg).filter((accountId) => { - const account = resolveTelegramAccount({ cfg: params.cfg, accountId }); - if (!account.enabled || account.tokenSource === "none") { - return false; - } - const config = resolveTelegramExecApprovalConfig({ - cfg: params.cfg, - accountId, - }); - return ( - isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getTelegramExecApprovalApprovers({ cfg: params.cfg, accountId }).length, - }) && - matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - fallbackAgentIdFromSessionKey: true, - }) - ); - }).length; -} - -function isExecApprovalRequest( - request: ExecApprovalRequest | PluginApprovalRequest, -): request is ExecApprovalRequest { - return "command" in request.request; -} - -function isTargetForwardingMode(mode?: string): boolean { - return mode === "targets" || mode === "both"; -} - -function matchesExplicitTelegramForwardTargetAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: ExecApprovalRequest | PluginApprovalRequest; -}): boolean | undefined { - const forwardingConfig = isExecApprovalRequest(params.request) - ? params.cfg.approvals?.exec - : params.cfg.approvals?.plugin; - if (!forwardingConfig?.enabled || !isTargetForwardingMode(forwardingConfig.mode)) { - return undefined; +}): boolean { + const account = resolveTelegramAccount(params); + if (!account.enabled || account.tokenSource === "none") { + return false; } - const telegramTargets = (forwardingConfig.targets ?? []).filter( - (target) => normalizeLowercaseStringOrEmpty(target.channel) === "telegram", - ); - if (telegramTargets.some((target) => !normalizeOptionalString(target.accountId))) { - return undefined; - } - const scopedTelegramAccountIds = telegramTargets - .map((target) => normalizeOptionalString(target.accountId)) - .filter((accountId): accountId is string => Boolean(accountId)); - if (scopedTelegramAccountIds.length === 0) { - return undefined; - } - const normalizedAccountId = params.accountId ? normalizeAccountId(params.accountId) : ""; + const config = resolveTelegramExecApprovalConfig(params); return ( - Boolean(normalizedAccountId) && - scopedTelegramAccountIds.some( - (accountId) => normalizeAccountId(accountId) === normalizedAccountId, - ) + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getTelegramExecApprovalApprovers(params).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + fallbackAgentIdFromSessionKey: true, + }) ); } @@ -158,31 +108,15 @@ function matchesTelegramRequestAccount(params: { accountId?: string | null; request: ExecApprovalRequest | PluginApprovalRequest; }): boolean { - const explicitTargetMatch = matchesExplicitTelegramForwardTargetAccount(params); - if (explicitTargetMatch !== undefined) { - return explicitTargetMatch; - } - const turnSourceChannel = normalizeLowercaseStringOrEmpty( - params.request.request.turnSourceChannel, - ); - const boundAccountId = resolveApprovalRequestChannelAccountId({ - cfg: params.cfg, - request: params.request, + const accountId = params.accountId ?? resolveDefaultTelegramAccountId(params.cfg); + return doesApprovalRequestSelectChannelAccount({ + ...params, channel: "telegram", + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + eligibleAccountIds: isTelegramExecApprovalAccountEligible({ ...params, accountId }) + ? [accountId] + : [], }); - if (turnSourceChannel && turnSourceChannel !== "telegram" && !boundAccountId) { - return ( - countTelegramExecApprovalEligibleAccounts({ - cfg: params.cfg, - request: params.request, - }) <= 1 - ); - } - return ( - !boundAccountId || - !params.accountId || - normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId) - ); } const telegramExecApprovalProfile = createChannelExecApprovalProfile({ diff --git a/extensions/whatsapp/src/approval-reactions.test.ts b/extensions/whatsapp/src/approval-reactions.test.ts index b149364ae562..71d90d088cea 100644 --- a/extensions/whatsapp/src/approval-reactions.test.ts +++ b/extensions/whatsapp/src/approval-reactions.test.ts @@ -178,6 +178,7 @@ describe("WhatsApp approval reactions", () => { approvalKind: "plugin", decision: "allow-once", channel: "whatsapp", + accountId: "default", senderId: "+15551230000", gatewayUrl: undefined, }); @@ -255,6 +256,7 @@ describe("WhatsApp approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "whatsapp", + accountId: "default", senderId: "+15551230001", gatewayUrl: undefined, }); @@ -320,6 +322,7 @@ describe("WhatsApp approval reactions", () => { approvalKind: "exec", decision: "allow-once", channel: "whatsapp", + accountId: "default", senderId: testCase.actorId, gatewayUrl: undefined, }); diff --git a/extensions/whatsapp/src/approval-reactions.ts b/extensions/whatsapp/src/approval-reactions.ts index 06ce403e49f0..e1a582a1d620 100644 --- a/extensions/whatsapp/src/approval-reactions.ts +++ b/extensions/whatsapp/src/approval-reactions.ts @@ -518,6 +518,7 @@ export async function maybeResolveWhatsAppApprovalReaction(params: { approvalKind: target.approvalKind, decision: target.decision, channel: "whatsapp", + accountId: params.accountId, senderId: actorId, gatewayUrl: params.gatewayUrl, }); diff --git a/packages/gateway-protocol/src/approvals-validators.test.ts b/packages/gateway-protocol/src/approvals-validators.test.ts index d7be7ff1df74..802d14cf6b26 100644 --- a/packages/gateway-protocol/src/approvals-validators.test.ts +++ b/packages/gateway-protocol/src/approvals-validators.test.ts @@ -6,6 +6,8 @@ import { validateApprovalHistoryResult, validateApprovalPresentation, validateApprovalResolveParams, + validateExecApprovalResolveParams, + validatePluginApprovalResolveParams, validateApprovalResolveResult, } from "./index.js"; @@ -101,6 +103,32 @@ describe("unified approval protocol validators", () => { ).toBe(false); }); + it("accepts only complete channel reviewer facts on every resolve surface", () => { + const reviewer = { channel: "telegram", accountId: "ops", senderId: "owner" }; + expect( + validateApprovalResolveParams({ + id: execRecord.id, + kind: "exec", + decision: "deny", + reviewer, + }), + ).toBe(true); + expect( + validateExecApprovalResolveParams({ id: execRecord.id, decision: "deny", reviewer }), + ).toBe(true); + expect( + validatePluginApprovalResolveParams({ id: pluginRecord.id, decision: "deny", reviewer }), + ).toBe(true); + expect( + validateApprovalResolveParams({ + id: execRecord.id, + kind: "exec", + decision: "deny", + reviewer: { channel: "telegram", accountId: "ops" }, + }), + ).toBe(false); + }); + it("validates pending and every fail-closed terminal state", () => { const pending = { ...execRecord, status: "pending" } as const; const allowed = { diff --git a/packages/gateway-protocol/src/schema/approvals.ts b/packages/gateway-protocol/src/schema/approvals.ts index ab499d60a135..bc3dddf90773 100644 --- a/packages/gateway-protocol/src/schema/approvals.ts +++ b/packages/gateway-protocol/src/schema/approvals.ts @@ -243,10 +243,17 @@ export const ApprovalHistoryResultSchema = closedObject({ }); /** Reviewer decision for one approval identified by its exact full id. */ +export const ApprovalChannelReviewerSchema = closedObject({ + channel: NonEmptyString, + accountId: NonEmptyString, + senderId: NonEmptyString, +}); + export const ApprovalResolveParamsSchema = closedObject({ id: ApprovalRecordCommonFields.id, kind: ApprovalKindSchema, decision: ApprovalDecisionSchema, + reviewer: Type.Optional(ApprovalChannelReviewerSchema), }); /** First-answer outcome plus the canonical recorded state returned to all contenders. */ @@ -315,6 +322,7 @@ export type ApprovalGetParams = Static; export type ApprovalGetResult = Static; export type ApprovalHistoryParams = Static; export type ApprovalHistoryResult = Static; +export type ApprovalChannelReviewer = Static; export type ApprovalResolveParams = Static; export type ApprovalResolveResult = Static; export type AllowedApprovalSnapshot = Static; diff --git a/packages/gateway-protocol/src/schema/exec-approvals.ts b/packages/gateway-protocol/src/schema/exec-approvals.ts index 8faf8e7ddfa0..43b1b804ff9c 100644 --- a/packages/gateway-protocol/src/schema/exec-approvals.ts +++ b/packages/gateway-protocol/src/schema/exec-approvals.ts @@ -1,6 +1,7 @@ import type { Static } from "typebox"; // Gateway Protocol schema module defines protocol validation shapes. import { Type } from "typebox"; +import { ApprovalChannelReviewerSchema } from "./approvals.js"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; @@ -311,6 +312,7 @@ export const ExecApprovalRequestParamsSchema = closedObject({ export const ExecApprovalResolveParamsSchema = closedObject({ id: NonEmptyString, decision: NonEmptyString, + reviewer: Type.Optional(ApprovalChannelReviewerSchema), }); // Owner-local wire types derived directly from local schema consts so the diff --git a/packages/gateway-protocol/src/schema/plugin-approvals.ts b/packages/gateway-protocol/src/schema/plugin-approvals.ts index 8a16d534aadd..a4226c284da1 100644 --- a/packages/gateway-protocol/src/schema/plugin-approvals.ts +++ b/packages/gateway-protocol/src/schema/plugin-approvals.ts @@ -1,6 +1,7 @@ import type { Static } from "typebox"; // Gateway Protocol schema module defines protocol validation shapes. import { Type } from "typebox"; +import { ApprovalChannelReviewerSchema } from "./approvals.js"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; @@ -56,6 +57,7 @@ export const PluginApprovalRequestParamsSchema = closedObject({ export const PluginApprovalResolveParamsSchema = closedObject({ id: NonEmptyString, decision: NonEmptyString, + reviewer: Type.Optional(ApprovalChannelReviewerSchema), }); // Owner-local wire types derived directly from local schema consts so the diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 7349c081b754..282200cf69bd 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -255,7 +255,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -2: remove unused WhatsApp-specific ack policy exports from channel-feedback. // -7: retire unused and duplicate inbound-dispatch compatibility exports. // +7: restore still-existing deprecated inbound-dispatch compatibility re-exports. - 4847, + 4848, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -312,7 +312,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -1: remove the unused WhatsApp-specific ack policy helper. // -10: collapse inbound-dispatch callable aliases and wrappers. // +7: restore still-existing deprecated inbound-dispatch callable re-exports. - 2917, + 2918, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts index d0f3a67169d2..926a850c61d0 100644 --- a/src/auto-reply/reply/commands-approve.test.ts +++ b/src/auto-reply/reply/commands-approve.test.ts @@ -43,7 +43,9 @@ function expectApprovalResolverCall(params: { }) { const request = approvalResolverRequest(params.callIndex ?? 0); expect(request).toHaveProperty("cfg"); - expect(request).toHaveProperty("senderId"); + const hasReviewer = Object.hasOwn(request, "channel"); + expect(Object.hasOwn(request, "accountId")).toBe(hasReviewer); + expect(Object.hasOwn(request, "senderId")).toBe(hasReviewer); expect(request.approvalId).toBe(params.id); expect(request.decision).toBe(params.decision ?? "allow-once"); expect(request.resolveMethod).toBe( @@ -364,6 +366,7 @@ describe("handleApproveCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain("Approval allow-once submitted"); expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" }); + expect(approvalResolverRequest()).toMatchObject({ channel: "telegram", accountId: "work" }); }); it.each([ @@ -740,6 +743,12 @@ describe("handleApproveCommand", () => { method: "exec.approval.resolve", id: "abc", }); + const request = approvalResolverRequest( + resolveApprovalOverGatewayMock.mock.calls.length - 1, + ); + expect(request).not.toHaveProperty("channel"); + expect(request).not.toHaveProperty("accountId"); + expect(request).not.toHaveProperty("senderId"); } } }); diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index fa5d2493d467..888d9c0f6f28 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -202,7 +202,13 @@ export async function handleApproveCommandFromContext( cfg: params.cfg, approvalId: parsed.id, decision: parsed.decision, - senderId: params.command.senderId, + ...(approvalCapability?.authorizeActorAction + ? { + channel: params.command.channel, + accountId: effectiveAccountId, + senderId: params.command.senderId, + } + : {}), resolveMethod, clientDisplayName: `Chat approval (${resolvedBy})`, }); diff --git a/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts index fc4280b3e42e..bbfed8ff7ea3 100644 --- a/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.abort-and-dedupe.test-utils.ts @@ -1423,6 +1423,8 @@ describe("dispatchReplyFromConfig", () => { channelLabel: channel === "discord" ? "Discord" : "Signal", accountId: "default", requestGateway: async () => ({ ok: true }) as T, + shouldHandle: () => true, + classifyRoute: () => "unbound", }) : undefined; reporter?.start(); @@ -1471,6 +1473,8 @@ describe("dispatchReplyFromConfig", () => { channelLabel: "Signal", accountId: "default", requestGateway: async () => ({ ok: true }) as T, + shouldHandle: () => true, + classifyRoute: () => "unbound", }); reporter.start(); try { diff --git a/src/gateway/approval-channel-custody.test.ts b/src/gateway/approval-channel-custody.test.ts new file mode 100644 index 000000000000..b59e44127c82 --- /dev/null +++ b/src/gateway/approval-channel-custody.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { prepareApprovalChannelCustody } from "./approval-channel-custody.js"; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + listAccountIds: vi.fn(), + defaultAccountId: vi.fn(), +})); + +vi.mock("../channels/plugins/index.js", () => ({ + getLoadedChannelPlugin: () => ({ + config: { + listAccountIds: mocks.listAccountIds, + defaultAccountId: mocks.defaultAccountId, + }, + }), + resolveChannelApprovalCapability: () => ({ authorizeActorAction: mocks.authorize }), +})); + +const reviewer = (accountId: string) => ({ + channel: "telegram", + accountId, + senderId: "owner", +}); + +const request = (payload: { + command: string; + turnSourceChannel?: string; + turnSourceAccountId?: string; +}) => ({ id: "approval-1", request: payload, createdAtMs: 1, expiresAtMs: 2 }); + +describe("prepareApprovalChannelCustody", () => { + beforeEach(() => { + mocks.authorize.mockReset().mockReturnValue({ authorized: true }); + mocks.listAccountIds.mockReset().mockReturnValue(["default", "ops"]); + mocks.defaultAccountId.mockReset().mockReturnValue("default"); + }); + + it("authorizes only the account recorded by the request source", () => { + const approval = request({ + command: "printf approval", + turnSourceChannel: "telegram", + turnSourceAccountId: "ops", + }); + expect( + prepareApprovalChannelCustody({ + cfg: {}, + approvalKind: "exec", + reviewer: reviewer("ops"), + })?.authorizes(approval), + ).toBe(true); + expect( + prepareApprovalChannelCustody({ + cfg: {}, + approvalKind: "exec", + reviewer: reviewer("default"), + })?.authorizes(approval), + ).toBe(false); + }); + + it("unions explicit scoped targets with the documented default account", () => { + const cfg: OpenClawConfig = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [ + { channel: "telegram", to: "1" }, + { channel: "telegram", to: "2", accountId: "ops" }, + ], + }, + }, + }; + mocks.listAccountIds.mockReturnValue(["default", "ops", "other"]); + for (const accountId of ["default", "ops"]) { + expect( + prepareApprovalChannelCustody({ + cfg, + approvalKind: "exec", + reviewer: reviewer(accountId), + })?.authorizes(request({ command: "printf approval" })), + ).toBe(true); + } + expect( + prepareApprovalChannelCustody({ + cfg, + approvalKind: "exec", + reviewer: reviewer("other"), + })?.authorizes(request({ command: "printf approval" })), + ).toBe(false); + }); + + it("allows an unbound request only for one actor-authorized account", () => { + mocks.authorize.mockImplementation(({ accountId }) => ({ authorized: accountId === "ops" })); + expect( + prepareApprovalChannelCustody({ + cfg: {}, + approvalKind: "exec", + reviewer: reviewer("ops"), + })?.authorizes(request({ command: "printf approval" })), + ).toBe(true); + + mocks.authorize.mockReturnValue({ authorized: true }); + expect( + prepareApprovalChannelCustody({ + cfg: {}, + approvalKind: "exec", + reviewer: reviewer("ops"), + })?.authorizes(request({ command: "printf approval" })), + ).toBe(false); + }); +}); diff --git a/src/gateway/approval-channel-custody.ts b/src/gateway/approval-channel-custody.ts new file mode 100644 index 000000000000..4e6c63df5ed3 --- /dev/null +++ b/src/gateway/approval-channel-custody.ts @@ -0,0 +1,62 @@ +import type { ApprovalChannelReviewer } from "../../packages/gateway-protocol/src/index.js"; +import { + getLoadedChannelPlugin, + resolveChannelApprovalCapability, +} from "../channels/plugins/index.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + doesApprovalRequestSelectChannelAccount, + type ApprovalRequestLike, +} from "../infra/approval-request-account-binding.js"; +import type { ChannelApprovalKind } from "../infra/approval-types.js"; + +type PreparedApprovalChannelCustody = { + resolverId: string; + authorizes: (request: ApprovalRequestLike) => boolean; +}; + +export function prepareApprovalChannelCustody(params: { + cfg: OpenClawConfig; + approvalKind: ChannelApprovalKind; + reviewer: ApprovalChannelReviewer; +}): PreparedApprovalChannelCustody | null { + const channel = params.reviewer.channel.trim().toLowerCase(); + const accountId = params.reviewer.accountId.trim(); + const senderId = params.reviewer.senderId.trim(); + if (!channel || !accountId || !senderId) { + return null; + } + const plugin = getLoadedChannelPlugin(channel); + const capability = resolveChannelApprovalCapability(plugin); + const authorizeActorAction = capability?.authorizeActorAction; + if (!plugin || !authorizeActorAction) { + return null; + } + const isActorAuthorized = (candidateAccountId: string) => + authorizeActorAction({ + cfg: params.cfg, + accountId: candidateAccountId, + senderId, + action: "approve", + approvalKind: params.approvalKind, + }).authorized; + if (!isActorAuthorized(accountId)) { + return null; + } + const eligibleAccountIds = plugin.config.listAccountIds(params.cfg).filter(isActorAuthorized); + if (!eligibleAccountIds.includes(accountId)) { + return null; + } + return { + resolverId: `${channel}:${accountId}`, + authorizes: (request) => + doesApprovalRequestSelectChannelAccount({ + cfg: params.cfg, + request, + channel, + accountId, + defaultAccountId: plugin.config.defaultAccountId?.(params.cfg) ?? "", + eligibleAccountIds, + }), + }; +} diff --git a/src/gateway/server-methods/approval-record-lookup.ts b/src/gateway/server-methods/approval-record-lookup.ts new file mode 100644 index 000000000000..011b4fa6153b --- /dev/null +++ b/src/gateway/server-methods/approval-record-lookup.ts @@ -0,0 +1,169 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import type { + ExecApprovalIdLookupResult, + ExecApprovalManager, + ExecApprovalRecord, +} from "../exec-approval-manager.js"; +import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../method-scopes.js"; +import type { GatewayClient, RespondFn } from "./types.js"; + +const APPROVAL_NOT_FOUND_DETAILS = { + reason: ErrorCodes.APPROVAL_NOT_FOUND, + remediation: "Re-request the action; pending approvals are cleared after expiry or restart.", +} as const; + +type PendingApprovalLookupError = + | "missing" + | { code: (typeof ErrorCodes)["INVALID_REQUEST"]; message: string }; + +export type ApprovalRecordLookupResult = + | { ok: true; approvalId: string; snapshot: ExecApprovalRecord } + | { ok: false; response: PendingApprovalLookupError }; + +function normalizeApprovalIdentity(value: string | null | undefined): string | null { + return normalizeOptionalString(value) ?? null; +} + +export function normalizeApprovalIdentities( + values: readonly string[] | null | undefined, +): string[] { + const normalized = new Set(); + for (const value of values ?? []) { + const identity = normalizeApprovalIdentity(value); + if (identity) { + normalized.add(identity); + } + } + return [...normalized]; +} + +export function isApprovalRecordVisibleToClient(params: { + record: ExecApprovalRecord; + client: GatewayClient | null; +}): boolean { + const scopes = Array.isArray(params.client?.connect?.scopes) ? params.client.connect.scopes : []; + if (scopes.includes(ADMIN_SCOPE)) { + return true; + } + const requestedByDeviceId = normalizeApprovalIdentity(params.record.requestedByDeviceId); + const requestedByClientId = normalizeApprovalIdentity(params.record.requestedByClientId); + const hasApprovalsScope = scopes.includes(APPROVALS_SCOPE); + if (hasApprovalsScope && params.client?.internal?.approvalRuntime === true) { + return true; + } + const approvalReviewerDeviceIds = normalizeApprovalIdentities( + params.record.approvalReviewerDeviceIds, + ); + const clientDeviceId = normalizeApprovalIdentity(params.client?.connect?.device?.id); + if (hasApprovalsScope && clientDeviceId && approvalReviewerDeviceIds.includes(clientDeviceId)) { + return true; + } + // Legacy adapters retain exact requester connection/device authority. + if (requestedByDeviceId) { + return requestedByDeviceId === clientDeviceId; + } + const requestedByConnId = normalizeApprovalIdentity(params.record.requestedByConnId); + if (requestedByConnId) { + return requestedByConnId === normalizeApprovalIdentity(params.client?.connId); + } + if (requestedByClientId || approvalReviewerDeviceIds.length > 0) { + return false; + } + // Pre-binding pending approvals remain operable after upgrades and restarts. + return true; +} + +export function listVisiblePendingApprovalRequests(params: { + manager: ExecApprovalManager; + client?: GatewayClient | null; +}): Array<{ id: string; request: TPayload; createdAtMs: number; expiresAtMs: number }> { + return params.manager + .listPendingRecords() + .filter((record) => isApprovalRecordVisibleToClient({ record, client: params.client ?? null })) + .map(({ id, request, createdAtMs, expiresAtMs }) => ({ + id, + request, + createdAtMs, + expiresAtMs, + })); +} + +function resolveLookupError(params: { + resolvedId: ExecApprovalIdLookupResult; + exposeAmbiguousPrefixError?: boolean; +}): PendingApprovalLookupError { + if ( + params.resolvedId.kind === "none" || + (params.resolvedId.kind === "ambiguous" && !params.exposeAmbiguousPrefixError) + ) { + return "missing"; + } + return { + code: ErrorCodes.INVALID_REQUEST, + message: "ambiguous approval id prefix; use the full id", + }; +} + +function resolveApprovalRecordForState( + params: { + manager: ExecApprovalManager; + inputId: string; + client?: GatewayClient | null; + exposeAmbiguousPrefixError?: boolean; + recordFilter?: (record: ExecApprovalRecord) => boolean; + }, + expectedState: "pending" | "resolved", +): ApprovalRecordLookupResult { + const resolvedId = params.manager.lookupApprovalId(params.inputId, { + includeResolved: expectedState === "resolved", + filter: (record) => + isApprovalRecordVisibleToClient({ record, client: params.client ?? null }) && + (params.recordFilter?.(record) ?? true), + }); + if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") { + return { ok: false, response: resolveLookupError({ ...params, resolvedId }) }; + } + const snapshot = params.manager.getSnapshot(resolvedId.id); + const isResolved = snapshot?.resolvedAtMs !== undefined; + return !snapshot || isResolved !== (expectedState === "resolved") + ? { ok: false, response: "missing" } + : { ok: true, approvalId: resolvedId.id, snapshot }; +} + +export function resolvePendingApprovalRecord(params: { + manager: ExecApprovalManager; + inputId: string; + client?: GatewayClient | null; + exposeAmbiguousPrefixError?: boolean; + recordFilter?: (record: ExecApprovalRecord) => boolean; +}): ApprovalRecordLookupResult { + return resolveApprovalRecordForState(params, "pending"); +} + +export function resolveResolvedApprovalRecord( + params: Parameters>[0], +): ApprovalRecordLookupResult { + return resolveApprovalRecordForState(params, "resolved"); +} + +export function respondUnknownOrExpiredApproval(respond: RespondFn): void { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", { + details: APPROVAL_NOT_FOUND_DETAILS, + }), + ); +} + +export function respondPendingApprovalLookupError(params: { + respond: RespondFn; + response: PendingApprovalLookupError; +}): void { + if (params.response === "missing") { + respondUnknownOrExpiredApproval(params.respond); + return; + } + params.respond(false, undefined, errorShape(params.response.code, params.response.message)); +} diff --git a/src/gateway/server-methods/approval-shared.test.ts b/src/gateway/server-methods/approval-shared.test.ts index b3009d22c6b6..32dc5a26a73d 100644 --- a/src/gateway/server-methods/approval-shared.test.ts +++ b/src/gateway/server-methods/approval-shared.test.ts @@ -19,11 +19,16 @@ import { import type { GatewayClient, GatewayRequestContext } from "./types.js"; const hasApprovalTurnSourceRouteMock = vi.hoisted(() => vi.fn(() => true)); +const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn()); vi.mock("../../infra/approval-turn-source.js", () => ({ hasApprovalTurnSourceRoute: hasApprovalTurnSourceRouteMock, })); +vi.mock("../approval-channel-custody.js", () => ({ + prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock, +})); + type ApprovalClientLookup = NonNullable; function createApprovalClient(params: { @@ -1524,6 +1529,40 @@ describe("handlePendingApprovalRequest", () => { expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once"); }); + it("filters legacy prefix candidates by channel custody before resolving", async () => { + const manager = new ExecApprovalManager(); + const owned = manager.create({ command: "owned" }, 60_000, "approval-prefix-owned"); + const foreign = manager.create({ command: "foreign" }, 60_000, "approval-prefix-foreign"); + void manager.register(owned, 60_000); + void manager.register(foreign, 60_000); + prepareApprovalChannelCustodyMock.mockReturnValueOnce({ + resolverId: "telegram:ops", + authorizes: (request: { request: { command: string } }) => + request.request.command === "owned", + }); + const respond = vi.fn(); + + await handleApprovalResolve({ + approvalKind: "exec", + manager, + inputId: "approval-prefix", + decision: "deny", + reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" }, + respond, + context: { + broadcast: vi.fn(), + broadcastToConnIds: vi.fn(), + getRuntimeConfig: () => ({}), + } as unknown as GatewayRequestContext, + client: null, + exposeAmbiguousPrefixError: true, + }); + + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(manager.getSnapshot(owned.id)?.decision).toBe("deny"); + expect(manager.getSnapshot(foreign.id)?.decision).toBeUndefined(); + }); + it("targets resolved approval events to visible approval clients when available", async () => { const manager = new ExecApprovalManager(); const record = manager.create( diff --git a/src/gateway/server-methods/approval-shared.ts b/src/gateway/server-methods/approval-shared.ts index 075fae591df5..20e2ac78460b 100644 --- a/src/gateway/server-methods/approval-shared.ts +++ b/src/gateway/server-methods/approval-shared.ts @@ -2,23 +2,35 @@ // decision payloads, turn-source routing, and gateway error responses. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import type { ValidationError } from "../../../packages/gateway-protocol/src/index.js"; +import type { + ApprovalChannelReviewer, + ValidationError, +} from "../../../packages/gateway-protocol/src/index.js"; import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js"; import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; -import type { - ExecApprovalIdLookupResult, - ExecApprovalManager, - ExecApprovalRecord, -} from "../exec-approval-manager.js"; -import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../method-scopes.js"; +import type { ExecApprovalRequestPayload } from "../../infra/exec-approvals.js"; +import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js"; +import { prepareApprovalChannelCustody } from "../approval-channel-custody.js"; +import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; +import { + type ApprovalRecordLookupResult, + isApprovalRecordVisibleToClient, + normalizeApprovalIdentities, + resolvePendingApprovalRecord, + resolveResolvedApprovalRecord, + respondPendingApprovalLookupError, + respondUnknownOrExpiredApproval, +} from "./approval-record-lookup.js"; import { buildWaitResponse, type WaitReasonResolver } from "./approval-wait-response.js"; import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; -const APPROVAL_NOT_FOUND_DETAILS = { - reason: ErrorCodes.APPROVAL_NOT_FOUND, - remediation: "Re-request the action; pending approvals are cleared after expiry or restart.", -} as const; +export { + isApprovalRecordVisibleToClient, + listVisiblePendingApprovalRequests, + resolvePendingApprovalRecord, + respondPendingApprovalLookupError, +} from "./approval-record-lookup.js"; const APPROVAL_ALREADY_RESOLVED_DETAILS = { reason: "APPROVAL_ALREADY_RESOLVED", @@ -30,13 +42,6 @@ function resolveRecordedApprovalDecision( return record.decision ?? record.consumedDecision; } -type PendingApprovalLookupError = - | "missing" - | { - code: (typeof ErrorCodes)["INVALID_REQUEST"]; - message: string; - }; - type ApprovalTurnSourceFields = { turnSourceChannel?: string | null; turnSourceAccountId?: string | null; @@ -57,18 +62,12 @@ type ResolvedApprovalEvent = { request: TPayload; }; -type PendingApprovalListEntry = { - id: string; - request: TPayload; - createdAtMs: number; - expiresAtMs: number; -}; - type ApprovalRequestDeliveryRoute = "approval-client" | "forwarder" | "turn-source" | "none"; type ApprovalResolveParams = { id: string; decision: string; + reviewer?: ApprovalChannelReviewer; }; type ApprovalResolveParamsValidator = (( @@ -77,17 +76,6 @@ type ApprovalResolveParamsValidator = (( errors?: ValidationError[] | null; }; -type ApprovalRecordLookupResult = - | { - ok: true; - approvalId: string; - snapshot: ExecApprovalRecord; - } - | { - ok: false; - response: PendingApprovalLookupError; - }; - function isPromiseLike(value: T | Promise): value is Promise { return typeof value === "object" && value !== null && "then" in value; } @@ -96,113 +84,6 @@ function isApprovalDecision(value: string): value is ExecApprovalDecision { return value === "allow-once" || value === "allow-always" || value === "deny"; } -function respondUnknownOrExpiredApproval(respond: RespondFn): void { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", { - details: APPROVAL_NOT_FOUND_DETAILS, - }), - ); -} - -function resolvePendingApprovalLookupError(params: { - resolvedId: ExecApprovalIdLookupResult; - exposeAmbiguousPrefixError?: boolean; -}): PendingApprovalLookupError { - if (params.resolvedId.kind === "none") { - return "missing"; - } - if (params.resolvedId.kind === "ambiguous" && !params.exposeAmbiguousPrefixError) { - return "missing"; - } - return { - code: ErrorCodes.INVALID_REQUEST, - message: "ambiguous approval id prefix; use the full id", - }; -} - -function normalizeApprovalIdentity(value: string | null | undefined): string | null { - return normalizeOptionalString(value) ?? null; -} - -function normalizeApprovalIdentities(values: readonly string[] | null | undefined): string[] { - const normalized = new Set(); - for (const value of values ?? []) { - const identity = normalizeApprovalIdentity(value); - if (identity) { - normalized.add(identity); - } - } - return [...normalized]; -} - -/** Checks whether a client can observe or resolve an approval record. */ -export function isApprovalRecordVisibleToClient(params: { - record: ExecApprovalRecord; - client: GatewayClient | null; -}): boolean { - const scopes = Array.isArray(params.client?.connect?.scopes) ? params.client.connect.scopes : []; - if (scopes.includes(ADMIN_SCOPE)) { - return true; - } - - const requestedByDeviceId = normalizeApprovalIdentity(params.record.requestedByDeviceId); - const requestedByClientId = normalizeApprovalIdentity(params.record.requestedByClientId); - const hasApprovalsScope = scopes.includes(APPROVALS_SCOPE); - if (hasApprovalsScope && params.client?.internal?.approvalRuntime === true) { - return true; - } - - const approvalReviewerDeviceIds = normalizeApprovalIdentities( - params.record.approvalReviewerDeviceIds, - ); - const clientDeviceId = normalizeApprovalIdentity(params.client?.connect?.device?.id); - if (hasApprovalsScope && clientDeviceId && approvalReviewerDeviceIds.includes(clientDeviceId)) { - return true; - } - - // Shipped legacy adapters retain exact requester connection/device authority. - // Unified durable methods apply their separate record authorization after lookup. - if (requestedByDeviceId) { - return requestedByDeviceId === clientDeviceId; - } - - const requestedByConnId = normalizeApprovalIdentity(params.record.requestedByConnId); - if (requestedByConnId) { - return requestedByConnId === normalizeApprovalIdentity(params.client?.connId); - } - - if (requestedByClientId || approvalReviewerDeviceIds.length > 0) { - return false; - } - - // Unbound approvals predate requester metadata and remain visible so pending - // work can still be resolved after upgrades or gateway restarts. - return true; -} - -/** Returns only pending approval requests the connected client is allowed to see. */ -export function listVisiblePendingApprovalRequests(params: { - manager: ExecApprovalManager; - client?: GatewayClient | null; -}): PendingApprovalListEntry[] { - return params.manager - .listPendingRecords() - .filter((record) => - isApprovalRecordVisibleToClient({ - record, - client: params.client ?? null, - }), - ) - .map((record) => ({ - id: record.id, - request: record.request, - createdAtMs: record.createdAtMs, - expiresAtMs: record.expiresAtMs, - })); -} - /** Binds the current gateway client identity onto a newly-created approval record. */ export function bindApprovalRequesterMetadata(params: { record: ExecApprovalRecord; @@ -274,7 +155,11 @@ export function resolveApprovalDecisionParams; methodName: string; respond: RespondFn; -}): { inputId: string; decision: ExecApprovalDecision } | null { +}): { + inputId: string; + decision: ExecApprovalDecision; + reviewer?: ApprovalChannelReviewer; +} | null { const rawParams = params.rawParams; if (!assertValidParams(rawParams, params.validate, params.methodName, params.respond)) { return null; @@ -286,6 +171,7 @@ export function resolveApprovalDecisionParams(params: { params.context.broadcast(eventName, params.event, { dropIfSlow: true }); } -/** Finds a pending approval by full id or prefix after applying client visibility rules. */ -export function resolvePendingApprovalRecord(params: { - manager: ExecApprovalManager; - inputId: string; - client?: GatewayClient | null; - exposeAmbiguousPrefixError?: boolean; -}): ApprovalRecordLookupResult { - return resolveApprovalRecordForState(params, "pending"); -} - -function resolveResolvedApprovalRecord(params: { - manager: ExecApprovalManager; - inputId: string; - client?: GatewayClient | null; - exposeAmbiguousPrefixError?: boolean; -}): ApprovalRecordLookupResult { - return resolveApprovalRecordForState(params, "resolved"); -} - -function resolveApprovalRecordForState( - params: { - manager: ExecApprovalManager; - inputId: string; - client?: GatewayClient | null; - exposeAmbiguousPrefixError?: boolean; - }, - expectedState: "pending" | "resolved", -): ApprovalRecordLookupResult { - const resolvedId = params.manager.lookupApprovalId(params.inputId, { - includeResolved: expectedState === "resolved", - filter: (record) => - isApprovalRecordVisibleToClient({ - record, - client: params.client ?? null, - }), - }); - if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") { - return { - ok: false, - response: resolvePendingApprovalLookupError({ - resolvedId, - exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError, - }), - }; - } - const snapshot = params.manager.getSnapshot(resolvedId.id); - const isResolved = snapshot?.resolvedAtMs !== undefined; - if (!snapshot || isResolved !== (expectedState === "resolved")) { - return { ok: false, response: "missing" }; - } - return { ok: true, approvalId: resolvedId.id, snapshot }; -} - -export function respondPendingApprovalLookupError(params: { - respond: RespondFn; - response: PendingApprovalLookupError; -}): void { - if (params.response === "missing") { - respondUnknownOrExpiredApproval(params.respond); - return; - } - params.respond(false, undefined, errorShape(params.response.code, params.response.message)); -} - export async function handleApprovalWaitDecision(params: { manager: ExecApprovalManager; inputId: unknown; @@ -629,7 +451,9 @@ function respondRepeatedApprovalResolution( } /** Resolves a pending approval and broadcasts the final decision exactly once. */ -export async function handleApprovalResolve(params: { +export async function handleApprovalResolve< + TPayload extends ExecApprovalRequestPayload | PluginApprovalRequestPayload, +>(params: { approvalKind: "exec" | "plugin"; manager: ExecApprovalManager; inputId: string; @@ -637,6 +461,7 @@ export async function handleApprovalResolve(params: { respond: RespondFn; context: GatewayRequestContext; client: GatewayClient | null; + reviewer?: ApprovalChannelReviewer; exposeAmbiguousPrefixError?: boolean; validateDecision?: (snapshot: ExecApprovalRecord) => | { @@ -650,6 +475,7 @@ export async function handleApprovalResolve(params: { decision: ExecApprovalDecision; resolvedBy: string | null; snapshot: ExecApprovalRecord; + resolver?: { kind: "channel"; id: string }; }) => boolean; forwardResolved?: (event: ResolvedApprovalEvent) => Promise | void; forwardResolvedErrorLabel?: string; @@ -658,6 +484,20 @@ export async function handleApprovalResolve(params: { errorLabel: string; }>; }): Promise { + const custody = params.reviewer + ? prepareApprovalChannelCustody({ + cfg: params.context.getRuntimeConfig(), + approvalKind: params.approvalKind, + reviewer: params.reviewer, + }) + : null; + if (params.reviewer && !custody) { + respondUnknownOrExpiredApproval(params.respond); + return; + } + const recordFilter = custody + ? (record: ExecApprovalRecord) => custody.authorizes(record) + : undefined; let resolved: ApprovalRecordLookupResult; try { resolved = resolvePendingApprovalRecord({ @@ -665,6 +505,7 @@ export async function handleApprovalResolve(params: { inputId: params.inputId, client: params.client, exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError, + recordFilter, }); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); @@ -678,6 +519,7 @@ export async function handleApprovalResolve(params: { inputId: params.inputId, client: params.client, exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError, + recordFilter, }); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); @@ -707,6 +549,7 @@ export async function handleApprovalResolve(params: { const resolvedBy = params.client?.connect?.client?.displayName ?? params.client?.connect?.client?.id ?? null; + const resolver = custody ? ({ kind: "channel", id: custody.resolverId } as const) : undefined; let ok: boolean; try { ok = params.resolveRecord @@ -715,8 +558,12 @@ export async function handleApprovalResolve(params: { decision: params.decision, resolvedBy, snapshot: resolved.snapshot, + resolver, }) - : params.manager.resolve(resolved.approvalId, params.decision, resolvedBy); + : resolver + ? params.manager.resolveDetailed(resolved.approvalId, params.decision, resolver, resolvedBy) + .outcome === "resolved" + : params.manager.resolve(resolved.approvalId, params.decision, resolvedBy); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); return; diff --git a/src/gateway/server-methods/approval.test.ts b/src/gateway/server-methods/approval.test.ts index aec6b36f81c8..656d0d64769b 100644 --- a/src/gateway/server-methods/approval.test.ts +++ b/src/gateway/server-methods/approval.test.ts @@ -38,6 +38,12 @@ import { cancelRunBoundExecApprovals } from "./approval-run-cancellation.js"; import { createApprovalHandlers } from "./approval.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; +const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("../approval-channel-custody.js", () => ({ + prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock, +})); + const tempDirs: string[] = []; type OperatorApprovalDatabase = Pick; const managersForCleanup: Array<{ @@ -315,6 +321,47 @@ describe("unified approval handlers", () => { ); }); + it("checks live channel custody before the canonical resolution CAS", async () => { + const databaseOptions = createDatabaseOptions(); + const managers = createManagers(databaseOptions); + const pending = registerExec(managers.exec, { + id: "channel-custody-cas", + request: { turnSourceChannel: "telegram", turnSourceAccountId: "ops" }, + reviewerDeviceIds: [], + }); + prepareApprovalChannelCustodyMock.mockReturnValue({ + resolverId: "telegram:ops", + authorizes: (request: { request: ExecApprovalRequestPayload }) => + request.request.turnSourceAccountId === "ops", + }); + const handlers = createApprovalHandlers({ + execApprovalManager: managers.exec, + pluginApprovalManager: managers.plugin, + databaseOptions, + }); + + const response = await invoke({ + handlers, + method: "approval.resolve", + body: { + id: pending.record.id, + kind: "exec", + decision: "deny", + reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" }, + }, + client: createClient({ internal: true }), + }); + + expect(response.result).toMatchObject({ + applied: true, + approval: { status: "denied", decision: "deny" }, + }); + expect(getOperatorApproval({ id: pending.record.id, databaseOptions })?.resolver).toEqual({ + kind: "channel", + id: "telegram:ops", + }); + }); + it("returns mapped terminal history with attribution and a next cursor", async () => { const databaseOptions = createDatabaseOptions(); const managers = createManagers(databaseOptions); diff --git a/src/gateway/server-methods/approval.ts b/src/gateway/server-methods/approval.ts index e635ce883dcf..9c493393aab8 100644 --- a/src/gateway/server-methods/approval.ts +++ b/src/gateway/server-methods/approval.ts @@ -22,6 +22,7 @@ import type { import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js"; import type { SystemAgentApprovalRequestPayload } from "../../infra/system-agent-approvals.js"; import type { OpenClawStateDatabaseOptions } from "../../state/openclaw-state-db.js"; +import { prepareApprovalChannelCustody } from "../approval-channel-custody.js"; import { normalizeControlUiBasePath } from "../control-ui-shared.js"; import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; import { @@ -357,6 +358,13 @@ export function createApprovalHandlers( }, "approval.resolve": async ({ params: rawParams, respond, client, context }) => { + const validParams = validateApprovalResolveParams(rawParams); + const resolveParams = validParams ? (rawParams as ApprovalResolveParams) : null; + const hasReviewer = isRecord(rawParams) && "reviewer" in rawParams; + if (hasReviewer && !resolveParams?.reviewer) { + respondApprovalNotFound(respond); + return; + } const id = readExactApprovalId(rawParams); let record: OperatorApprovalRecord | null; try { @@ -380,6 +388,26 @@ export function createApprovalHandlers( respondApprovalNotFound(respond); return; } + const custody = resolveParams?.reviewer + ? prepareApprovalChannelCustody({ + cfg: context.getRuntimeConfig(), + approvalKind: record.kind === "plugin" ? "plugin" : "exec", + reviewer: resolveParams.reviewer, + }) + : null; + const liveRecord = + record.kind === "exec" + ? params.execApprovalManager.getLiveSnapshot(record.id) + : record.kind === "plugin" + ? params.pluginApprovalManager.getLiveSnapshot(record.id) + : undefined; + if ( + resolveParams?.reviewer && + (!custody || !liveRecord || !custody.authorizes(liveRecord)) + ) { + respondApprovalNotFound(respond); + return; + } if (record.status !== "pending") { // Durable terminal state outlives the process-local waiter. Every later // surface receives the same winner without re-opening execution rights. @@ -394,10 +422,10 @@ export function createApprovalHandlers( respond(true, { applied: false, approval }, undefined); return; } - const resolver = resolveApprovalResolver(client); + const resolver = custody + ? ({ kind: "channel", id: custody.resolverId } as const) + : resolveApprovalResolver(client); const localResolvedBy = resolveLegacyApprovalLabel(client); - const validParams = validateApprovalResolveParams(rawParams); - const resolveParams = validParams ? (rawParams as ApprovalResolveParams) : null; const requestedDecision = resolveParams?.decision ?? null; const decisionAllowed = requestedDecision === "deny" || diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index c320b92e3d67..11e4039895b5 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -441,7 +441,7 @@ export function createExecApprovalHandlers( if (!resolveParams) { return; } - const { inputId, decision } = resolveParams; + const { inputId, decision, reviewer } = resolveParams; let autoReviewResolution = false; await handleApprovalResolve({ approvalKind: "exec", @@ -451,6 +451,7 @@ export function createExecApprovalHandlers( respond, context, client, + reviewer, exposeAmbiguousPrefixError: true, validateDecision: (snapshot) => { const autoReviewIdentity = @@ -481,10 +482,15 @@ export function createExecApprovalHandlers( details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS, }; }, - resolveRecord: ({ approvalId, decision: decisionLocal, resolvedBy }) => - autoReviewResolution - ? manager.resolveAutoReview(approvalId, resolvedBy) - : manager.resolve(approvalId, decisionLocal, resolvedBy), + resolveRecord: ({ approvalId, decision: decisionLocal, resolvedBy, resolver }) => { + if (autoReviewResolution) { + return manager.resolveAutoReview(approvalId, resolvedBy); + } + return resolver + ? manager.resolveDetailed(approvalId, decisionLocal, resolver, resolvedBy).outcome === + "resolved" + : manager.resolve(approvalId, decisionLocal, resolvedBy); + }, forwardResolved: (resolvedEvent) => opts?.forwarder?.handleResolved(resolvedEvent), forwardResolvedErrorLabel: "exec approvals: forward resolve failed", extraResolvedHandlers: opts?.iosPushDelivery?.handleResolved diff --git a/src/gateway/server-methods/plugin-approval.ts b/src/gateway/server-methods/plugin-approval.ts index 05db1f21249b..7c29f0e1a867 100644 --- a/src/gateway/server-methods/plugin-approval.ts +++ b/src/gateway/server-methods/plugin-approval.ts @@ -187,7 +187,7 @@ export function createPluginApprovalHandlers( if (!resolveParams) { return; } - const { inputId, decision } = resolveParams; + const { inputId, decision, reviewer } = resolveParams; await handleApprovalResolve({ approvalKind: "plugin", manager, @@ -196,6 +196,7 @@ export function createPluginApprovalHandlers( respond, context, client, + reviewer, exposeAmbiguousPrefixError: false, validateDecision: (snapshot) => resolveCanonicalPluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision) diff --git a/src/infra/approval-gateway-resolver.test.ts b/src/infra/approval-gateway-resolver.test.ts index 6d1d07b94991..875d3b209f7c 100644 --- a/src/infra/approval-gateway-resolver.test.ts +++ b/src/infra/approval-gateway-resolver.test.ts @@ -38,6 +38,19 @@ function requireFirstMockCall(mock: { mock: { calls: T[][] } }): T[] { return call; } +function withApprovalAccountContext(run: () => T): T { + return withGatewayNativeApprovalRuntime( + { + request: async (method: string, params: Record) => + (await hoisted.clientRequest(method, params)) as TResult, + requestRoute: vi.fn(), + routeCoordinator: {} as never, + subscribe: vi.fn(), + }, + run, + ); +} + describe("resolveApprovalOverGateway", () => { beforeEach(() => { hoisted.clientRequest.mockReset().mockResolvedValue({ @@ -77,6 +90,49 @@ describe("resolveApprovalOverGateway", () => { expect(result).toEqual({ applied: true, approval: recordedApproval }); }); + it("sends complete reviewer facts directly to the canonical owner", async () => { + await expect( + withApprovalAccountContext(() => + resolveApprovalOverGateway({ + cfg: {} as never, + approvalId: "approval-1", + approvalKind: "exec", + decision: "deny", + channel: "telegram", + accountId: "ops", + senderId: "owner", + }), + ), + ).resolves.toEqual({ applied: true, approval: recordedApproval }); + expect(hoisted.clientRequest).toHaveBeenCalledWith("approval.resolve", { + id: "approval-1", + kind: "exec", + decision: "deny", + reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" }, + }); + }); + + it.each([ + { channel: "telegram" }, + { accountId: "ops" }, + { senderId: "owner" }, + { channel: "telegram", accountId: "ops" }, + { channel: "telegram", senderId: "owner" }, + { accountId: "ops", senderId: "owner" }, + ])("rejects partial reviewer identity: %j", async (reviewer) => { + await expect( + resolveApprovalOverGateway({ + cfg: {} as never, + approvalId: "approval-1", + approvalKind: "exec", + decision: "deny", + ...reviewer, + }), + ).rejects.toThrow("channel approval resolution requires channel, account, and sender identity"); + expect(hoisted.clientRequest).not.toHaveBeenCalled(); + expect(hoisted.withOperatorApprovalsGatewayClient).not.toHaveBeenCalled(); + }); + it.each([ ["signal", "Signal"], ["whatsapp", "WhatsApp"], @@ -95,6 +151,7 @@ describe("resolveApprovalOverGateway", () => { approvalKind: "exec", decision: "deny", channel, + accountId: "default", senderId: "owner", }); @@ -114,6 +171,7 @@ describe("resolveApprovalOverGateway", () => { approvalKind: "exec", decision: "deny", channel: "external-chat", + accountId: "default", senderId: "owner", }); @@ -190,6 +248,58 @@ describe("resolveApprovalOverGateway", () => { expect(hoisted.withOperatorApprovalsGatewayClient).not.toHaveBeenCalled(); }); + it("sends channel custody to an injected canonical runtime", async () => { + const injectedRequest = vi.fn(async () => ({ applied: true, approval: recordedApproval })); + const scopedRequest = vi.fn(async (method: string) => { + if (method === "exec.approval.list") { + return [ + { + id: "approval-1", + request: { + command: "printf approval", + turnSourceChannel: "imessage", + turnSourceAccountId: "personal", + }, + }, + ]; + } + return { applied: true, approval: recordedApproval }; + }); + const runtime = { + request: scopedRequest, + requestRoute: vi.fn(), + routeCoordinator: { doesAccountHandleRequest: () => true } as never, + subscribe: vi.fn(), + } satisfies GatewayNativeApprovalRuntime; + + await expect( + withGatewayNativeApprovalRuntime(runtime, () => + resolveApprovalOverGateway({ + cfg: {} as never, + approvalId: "approval-1", + approvalKind: "exec", + decision: "deny", + channel: "imessage", + accountId: "personal", + senderId: "owner", + gatewayRuntime: { request: injectedRequest }, + }), + ), + ).resolves.toEqual({ applied: true, approval: recordedApproval }); + + expect(injectedRequest).toHaveBeenCalledWith( + "approval.resolve", + { + id: "approval-1", + kind: "exec", + decision: "deny", + reviewer: { channel: "imessage", accountId: "personal", senderId: "owner" }, + }, + { clientDisplayName: "iMessage approval (owner)" }, + ); + expect(scopedRequest).not.toHaveBeenCalled(); + }); + it("preserves protocol-valid boundary whitespace in canonical approval ids", async () => { const approvalId = "\uFEFF"; diff --git a/src/infra/approval-gateway-resolver.ts b/src/infra/approval-gateway-resolver.ts index c06c4091e787..d6b9bc2cf2d4 100644 --- a/src/infra/approval-gateway-resolver.ts +++ b/src/infra/approval-gateway-resolver.ts @@ -1,5 +1,6 @@ // Resolves exec and plugin approvals through the gateway client. import type { + ApprovalChannelReviewer, ApprovalDecision, ApprovalKind, ApprovalResolveParams, @@ -18,6 +19,7 @@ type ResolveApprovalOverGatewayBaseParams = { approvalId: string; decision: ApprovalDecision; channel?: string; + accountId?: string | null; senderId?: string | null; gatewayUrl?: string; clientDisplayName?: string; @@ -106,14 +108,23 @@ export async function resolveApprovalOverGateway( if (typeof approvalId !== "string" || !isWellFormedApprovalId(approvalId)) { throw new Error("approval resolution requires an approval id"); } - const senderId = params.senderId?.trim() || "unknown"; + const senderId = params.senderId?.trim(); const channel = params.channel?.trim(); + const accountId = params.accountId?.trim(); + const hasReviewerIdentity = Boolean(channel || accountId || senderId); + if (hasReviewerIdentity && (!channel || !accountId || !senderId)) { + throw new Error("channel approval resolution requires channel, account, and sender identity"); + } + const reviewer: ApprovalChannelReviewer | undefined = + channel && accountId && senderId ? { channel, accountId, senderId } : undefined; // Channel manifests own operator-facing labels; using their generated metadata // keeps approval clients aligned without importing plugin runtime or hardcoding ids. const channelLabel = channel ? (findChatChannelLabel(channel) ?? channel) : undefined; const clientDisplayName = params.clientDisplayName ?? - (channelLabel ? `${channelLabel} approval (${senderId})` : `Approval (${senderId})`); + (channelLabel + ? `${channelLabel} approval (${senderId ?? "unknown"})` + : `Approval (${senderId ?? "unknown"})`); const canonicalGatewayRuntime = (params as CanonicalResolveApprovalOverGatewayParams) .gatewayRuntime; @@ -124,6 +135,7 @@ export async function resolveApprovalOverGateway( id: approvalId, kind: canonicalKind, decision: params.decision, + ...(reviewer ? { reviewer } : {}), }, { clientDisplayName }, ); @@ -140,6 +152,7 @@ export async function resolveApprovalOverGateway( id: approvalId, kind: canonicalKind, decision: params.decision, + ...(reviewer ? { reviewer } : {}), }; return await gatewayClient.request("approval.resolve", resolveParams); } @@ -150,6 +163,7 @@ export async function resolveApprovalOverGateway( await gatewayClient.request(method, { id: approvalId, decision: params.decision, + ...(reviewer ? { reviewer } : {}), }); }; if (legacyMethod === "plugin" || (!legacyMethod && approvalId.startsWith("plugin:"))) { diff --git a/src/infra/approval-native-route-coordinator.test.ts b/src/infra/approval-native-route-coordinator.test.ts index fe02be6ac7dc..d87a004bf14d 100644 --- a/src/infra/approval-native-route-coordinator.test.ts +++ b/src/infra/approval-native-route-coordinator.test.ts @@ -6,11 +6,18 @@ import { } from "./approval-native-route-coordinator.js"; const approvalRouteReporters: Array> = []; +const defaultRouteSelector = { + shouldHandle: () => true, + classifyRoute: () => "unbound" as const, +}; function createApprovalNativeRouteReporter( - params: Parameters[0], + params: Omit< + Parameters[0], + "shouldHandle" | "classifyRoute" + >, ) { - const reporter = createApprovalNativeRouteReporterRaw(params); + const reporter = createApprovalNativeRouteReporterRaw({ ...params, ...defaultRouteSelector }); approvalRouteReporters.push(reporter); return reporter; } @@ -28,16 +35,200 @@ function createGatewayRequestMock() { } describe("createApprovalNativeRouteReporter", () => { + it("keeps the local approval route visible when an unbound request has multiple runtimes", () => { + const coordinator = createApprovalNativeRouteCoordinator(); + const first = coordinator.createReporter({ + ...defaultRouteSelector, + handledKinds: new Set(["exec"]), + channel: "telegram", + accountId: "default", + requestGateway: createGatewayRequestMock(), + }); + const second = coordinator.createReporter({ + ...defaultRouteSelector, + handledKinds: new Set(["exec"]), + channel: "telegram", + accountId: "ops", + requestGateway: createGatewayRequestMock(), + }); + first.start(); + second.start(); + + expect(coordinator.hasActiveRuntime({ approvalKind: "exec", channel: "telegram" })).toBe(false); + expect( + coordinator.hasActiveRuntime({ + approvalKind: "exec", + channel: "telegram", + accountId: "ops", + }), + ).toBe(true); + coordinator.close(); + }); + + it("selects the sole eligible runtime for an unbound request", () => { + const coordinator = createApprovalNativeRouteCoordinator(); + const requestGateway = createGatewayRequestMock(); + const createReporter = (accountId: string, eligible: boolean) => + coordinator.createReporter({ + handledKinds: new Set(["exec"]), + channel: "telegram", + accountId, + requestGateway, + shouldHandle: () => eligible, + classifyRoute: () => "unbound", + }); + const defaultReporter = createReporter("default", true); + const opsReporter = createReporter("ops", false); + defaultReporter.start(); + opsReporter.start(); + const request = { + id: "approval-filtered", + request: { command: "echo hi", turnSourceChannel: "telegram" }, + createdAtMs: 0, + expiresAtMs: Date.now() + 60_000, + } as const; + + expect(defaultReporter.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "selected", + }); + expect(opsReporter.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "ineligible", + }); + coordinator.close(); + }); + + it("keeps each channel's sole eligible runtime independent", () => { + const coordinator = createApprovalNativeRouteCoordinator(); + const requestGateway = createGatewayRequestMock(); + const createReporter = (channel: string) => + coordinator.createReporter({ + ...defaultRouteSelector, + handledKinds: new Set(["exec"]), + channel, + accountId: "default", + requestGateway, + }); + const telegramReporter = createReporter("telegram"); + const matrixReporter = createReporter("matrix"); + telegramReporter.start(); + matrixReporter.start(); + const request = { + id: "approval-two-channels", + request: { command: "echo hi" }, + createdAtMs: 0, + expiresAtMs: Date.now() + 60_000, + } as const; + + expect(telegramReporter.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "selected", + }); + expect(matrixReporter.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "selected", + }); + coordinator.close(); + }); + + it("fails an unbound multi-account route visibly and keeps the owner snapshot sticky", async () => { + const coordinator = createApprovalNativeRouteCoordinator(); + const requestGateway = createGatewayRequestMock(); + const createReporter = (accountId: string) => + coordinator.createReporter({ + ...defaultRouteSelector, + handledKinds: new Set(["exec"]), + channel: "telegram", + accountId, + requestGateway, + }); + const first = createReporter("default"); + const second = createReporter("ops"); + first.start(); + second.start(); + const request = { + id: "deadbeef-1234-4567-89ab-cdef01234567", + request: { + command: "echo hi", + turnSourceChannel: "telegram", + turnSourceTo: "chat:123", + }, + createdAtMs: 0, + expiresAtMs: Date.now() + 60_000, + } as const; + + expect(first.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "ambiguous-owner", + }); + expect(second.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "ambiguous-owner", + }); + await first.reportSkipped({ approvalKind: "exec", request, reason: "ambiguous-owner" }); + await second.reportSkipped({ approvalKind: "exec", request, reason: "ambiguous-owner" }); + + expect(requestGateway).toHaveBeenCalledTimes(1); + expect(requestGateway).toHaveBeenCalledWith( + "send", + expect.objectContaining({ + channel: "telegram", + to: "chat:123", + message: + "Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it.", + }), + ); + expect(requestGateway).not.toHaveBeenCalledWith( + "send", + expect.objectContaining({ message: expect.stringContaining("/approve") }), + ); + + const late = createReporter("late"); + late.start(); + expect(late.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "ineligible" }); + coordinator.close(); + }); + + it("selects every eligible explicit owner and no unrelated account", () => { + const coordinator = createApprovalNativeRouteCoordinator(); + const requestGateway = createGatewayRequestMock(); + const createReporter = (accountId: string, eligible: boolean) => + coordinator.createReporter({ + handledKinds: new Set(["exec"]), + channel: "telegram", + accountId, + requestGateway, + shouldHandle: () => eligible, + classifyRoute: () => "bound-or-explicit", + }); + const first = createReporter("default", true); + const second = createReporter("ops", true); + const unrelated = createReporter("other", false); + first.start(); + second.start(); + unrelated.start(); + const request = { + id: "approval-explicit-owners", + request: { command: "echo hi" }, + createdAtMs: 0, + expiresAtMs: Date.now() + 60_000, + } as const; + + expect(first.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "selected" }); + expect(second.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "selected" }); + expect(unrelated.selectRequest({ approvalKind: "exec", request })).toEqual({ + kind: "ineligible", + }); + coordinator.close(); + }); + it("isolates active routes and cleanup between Gateway instances", () => { const first = createApprovalNativeRouteCoordinator(); const second = createApprovalNativeRouteCoordinator(); const firstReporter = first.createReporter({ + ...defaultRouteSelector, handledKinds: new Set(["exec"]), channel: "telegram", accountId: "default", requestGateway: createGatewayRequestMock(), }); const secondReporter = second.createReporter({ + ...defaultRouteSelector, handledKinds: new Set(["exec"]), channel: "discord", accountId: "default", @@ -72,6 +263,7 @@ describe("createApprovalNativeRouteReporter", () => { const coordinator = createApprovalNativeRouteCoordinator(); const requestGateway = createGatewayRequestMock(); const reporter = coordinator.createReporter({ + ...defaultRouteSelector, handledKinds: new Set(["exec"]), channel: "telegram", accountId: "default", @@ -91,18 +283,19 @@ describe("createApprovalNativeRouteReporter", () => { reporter.start(); coordinator.close(); reporter.start(); - reporter.observeRequest({ approvalKind: "exec", request }); - await reporter.reportSkipped({ approvalKind: "exec", request }); + reporter.selectRequest({ approvalKind: "exec", request }); + await reporter.reportSkipped({ approvalKind: "exec", request, reason: "ineligible" }); const lateReporter = coordinator.createReporter({ + ...defaultRouteSelector, handledKinds: new Set(["exec"]), channel: "telegram", accountId: "default", requestGateway, }); lateReporter.start(); - lateReporter.observeRequest({ approvalKind: "exec", request }); - await lateReporter.reportSkipped({ approvalKind: "exec", request }); + lateReporter.selectRequest({ approvalKind: "exec", request }); + await lateReporter.reportSkipped({ approvalKind: "exec", request, reason: "ineligible" }); expect(coordinator.hasActiveRuntime({ approvalKind: "exec", channel: "telegram" })).toBe(false); expect(requestGateway).not.toHaveBeenCalled(); @@ -123,7 +316,7 @@ describe("createApprovalNativeRouteReporter", () => { }); reporter.start(); - reporter.observeRequest({ + reporter.selectRequest({ approvalKind: "exec", request: { id: "approval-long", @@ -137,13 +330,9 @@ describe("createApprovalNativeRouteReporter", () => { }, }); - expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - const cleanupCall = setTimeoutSpy.mock.calls[0]; - if (cleanupCall === undefined) { - throw new Error("expected cleanup timeout call"); - } - const [cleanupCallback, cleanupDelayMs] = cleanupCall; - expect(cleanupDelayMs).toBe(5 * 60_000); + const cleanupCall = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5 * 60_000); + expect(cleanupCall).toBeDefined(); + const [cleanupCallback] = cleanupCall ?? []; expect(cleanupCallback).toBeTypeOf("function"); } finally { vi.useRealTimers(); @@ -174,7 +363,7 @@ describe("createApprovalNativeRouteReporter", () => { requestGateway, }); reporter.start(); - reporter.observeRequest({ + reporter.selectRequest({ approvalKind: "exec", request, }); @@ -252,11 +441,11 @@ describe("createApprovalNativeRouteReporter", () => { originReporter.start(); otherReporter.start(); - originReporter.observeRequest({ + originReporter.selectRequest({ approvalKind: "exec", request, }); - otherReporter.observeRequest({ + otherReporter.selectRequest({ approvalKind: "exec", request, }); @@ -336,7 +525,7 @@ describe("createApprovalNativeRouteReporter", () => { requestGateway, }); reporter.start(); - reporter.observeRequest({ + reporter.selectRequest({ approvalKind: "exec", request, }); diff --git a/src/infra/approval-native-route-coordinator.ts b/src/infra/approval-native-route-coordinator.ts index 834e9b591658..3522d5976543 100644 --- a/src/infra/approval-native-route-coordinator.ts +++ b/src/infra/approval-native-route-coordinator.ts @@ -1,4 +1,3 @@ -import { expectDefined } from "@openclaw/normalization-core"; // Coordinates native approval delivery routing and notices. import { normalizeLowercaseStringOrEmpty, @@ -10,10 +9,12 @@ import type { } from "./approval-native-delivery.js"; import { describeApprovalDeliveryDestination, + resolveAmbiguousApprovalRouteNoticeText, resolveApprovalDeliveryFailedNoticeText, resolveApprovalRoutedElsewhereNoticeText, } from "./approval-native-route-notice.js"; import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js"; +import type { ApprovalRequestChannelRouteClass } from "./approval-request-account-binding.js"; import type { ChannelApprovalKind } from "./approval-types.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; @@ -32,8 +33,12 @@ type ApprovalRouteRuntimeRecord = { channelLabel?: string; accountId?: string | null; requestGateway: GatewayRequestFn; + shouldHandle: (request: ApprovalRequest) => boolean; + classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass; }; +type ApprovalRouteSkipReason = "ambiguous-owner" | "ineligible" | "owner-unavailable"; + type ApprovalRouteReport = { runtimeId: string; request: ApprovalRequest; @@ -43,15 +48,24 @@ type ApprovalRouteReport = { deliveryPlan: ChannelApprovalNativeDeliveryPlan; deliveredTargets: readonly ChannelApprovalNativePlannedTarget[]; requestGateway: GatewayRequestFn; + skipReason?: ApprovalRouteSkipReason; }; type PendingApprovalRouteNotice = { request: ApprovalRequest; approvalKind: ChannelApprovalKind; - expectedRuntimeIds: Set; reports: Map; - cleanupTimeout: NodeJS.Timeout | null; - finalized: boolean; + cleanupTimeout: NodeJS.Timeout; +}; + +type ApprovalRouteSelectionVerdict = + | { kind: "selected" } + | { kind: ApprovalRouteSkipReason } + | { kind: "selector-error"; error: unknown }; + +type ApprovalRouteSelection = { + verdicts: Map; + cleanupTimeout: NodeJS.Timeout; }; type RouteNoticeTarget = { @@ -64,6 +78,7 @@ type RouteNoticeTarget = { type ApprovalNativeRouteCoordinatorState = { activeRuntimes: Map; pendingNotices: Map; + selections: Map; runtimeSeq: number; closed: boolean; }; @@ -72,11 +87,115 @@ function createApprovalNativeRouteCoordinatorState(): ApprovalNativeRouteCoordin return { activeRuntimes: new Map(), pendingNotices: new Map(), + selections: new Map(), runtimeSeq: 0, closed: false, }; } +function clearApprovalRouteSelection( + state: ApprovalNativeRouteCoordinatorState, + approvalId: string, +): void { + const selection = state.selections.get(approvalId); + if (!selection) { + return; + } + state.selections.delete(approvalId); + clearTimeout(selection.cleanupTimeout); +} + +function routeGroupKey(runtime: ApprovalRouteRuntimeRecord): string { + return normalizeChannel(runtime.channel) || runtime.runtimeId; +} + +function createApprovalRouteSelection( + state: ApprovalNativeRouteCoordinatorState, + params: { request: ApprovalRequest; approvalKind: ChannelApprovalKind }, +): ApprovalRouteSelection { + const runtimes = Array.from(state.activeRuntimes.values()).filter((runtime) => + runtime.handledKinds.has(params.approvalKind), + ); + const verdicts = new Map(); + const groups = new Map(); + for (const runtime of runtimes) { + const key = routeGroupKey(runtime); + groups.set(key, [...(groups.get(key) ?? []), runtime]); + } + + const selectedRuntimeIds = new Set(); + for (const group of groups.values()) { + const candidates: ApprovalRouteRuntimeRecord[] = []; + for (const runtime of group) { + try { + if (runtime.shouldHandle(params.request)) { + candidates.push(runtime); + } + } catch (error) { + verdicts.set(runtime.runtimeId, { kind: "selector-error", error }); + } + } + let routeClass: ApprovalRequestChannelRouteClass; + try { + routeClass = group[0]?.classifyRoute(params.request) ?? "unbound"; + } catch (error) { + for (const runtime of group) { + verdicts.set(runtime.runtimeId, { kind: "selector-error", error }); + } + continue; + } + if (routeClass === "bound-or-explicit") { + if (candidates.length === 0) { + for (const runtime of group) { + if (!verdicts.has(runtime.runtimeId)) { + verdicts.set(runtime.runtimeId, { kind: "owner-unavailable" }); + } + } + continue; + } + for (const runtime of candidates) { + selectedRuntimeIds.add(runtime.runtimeId); + } + } else if (routeClass === "unbound" && candidates.length === 1) { + const [candidate] = candidates; + if (candidate) { + selectedRuntimeIds.add(candidate.runtimeId); + } + } else if (routeClass === "unbound" && candidates.length > 1) { + for (const runtime of candidates) { + verdicts.set(runtime.runtimeId, { kind: "ambiguous-owner" }); + } + } + } + + for (const runtime of runtimes) { + if (selectedRuntimeIds.has(runtime.runtimeId)) { + verdicts.set(runtime.runtimeId, { kind: "selected" }); + } else if (!verdicts.has(runtime.runtimeId)) { + verdicts.set(runtime.runtimeId, { kind: "ineligible" }); + } + } + + const timeoutMs = Math.min(Math.max(0, params.request.expiresAtMs - Date.now()), 0x7fffffff); + const cleanupTimeout = setTimeout(() => { + clearApprovalRouteSelection(state, params.request.id); + }, timeoutMs); + cleanupTimeout.unref?.(); + const selection: ApprovalRouteSelection = { + verdicts, + cleanupTimeout, + }; + state.selections.set(params.request.id, selection); + return selection; +} + +function resolveApprovalRouteSelection( + state: ApprovalNativeRouteCoordinatorState, + params: { request: ApprovalRequest; approvalKind: ChannelApprovalKind }, +): ApprovalRouteSelection { + return state.selections.get(params.request.id) ?? createApprovalRouteSelection(state, params); +} + const defaultCoordinatorState = createApprovalNativeRouteCoordinatorState(); const MAX_APPROVAL_ROUTE_NOTICE_TTL_MS = 5 * 60_000; @@ -93,9 +212,7 @@ function clearPendingApprovalRouteNotice( return; } state.pendingNotices.delete(approvalId); - if (entry.cleanupTimeout) { - clearTimeout(entry.cleanupTimeout); - } + clearTimeout(entry.cleanupTimeout); } function createPendingApprovalRouteNotice( @@ -103,7 +220,6 @@ function createPendingApprovalRouteNotice( params: { request: ApprovalRequest; approvalKind: ChannelApprovalKind; - expectedRuntimeIds?: Iterable; }, ): PendingApprovalRouteNotice { const timeoutMs = Math.min( @@ -111,19 +227,14 @@ function createPendingApprovalRouteNotice( MAX_APPROVAL_ROUTE_NOTICE_TTL_MS, ); const cleanupTimeout = setTimeout(() => { - clearPendingApprovalRouteNotice(state, params.request.id); + void maybeFinalizeApprovalRouteNotice(state, params.request.id, { force: true }); }, timeoutMs); cleanupTimeout.unref?.(); return { request: params.request, approvalKind: params.approvalKind, - // Snapshot siblings at first observation time so already-running runtimes - // can still aggregate one notice, while late-starting runtimes that cannot - // replay old gateway events never block the quorum. - expectedRuntimeIds: new Set(params.expectedRuntimeIds ?? []), reports: new Map(), cleanupTimeout, - finalized: false, }; } @@ -192,6 +303,7 @@ function resolveApprovalRouteNotice(params: { approvalKind: ChannelApprovalKind; request: ApprovalRequest; reports: readonly ApprovalRouteReport[]; + missingSelectedRuntime: boolean; }): { requestGateway: GatewayRequestFn; target: RouteNoticeTarget; text: string } | null { const explicitTarget = resolveRouteNoticeTargetFromRequest(params.request); const originChannel = normalizeChannel( @@ -215,17 +327,33 @@ function resolveApprovalRouteNotice(params: { } const originAccountId = normalizeOptionalString(target.accountId); const deliveredAnyTarget = params.reports.some((report) => report.deliveredTargets.length > 0); - if (!deliveredAnyTarget && params.reports.some(hasPlannedNativeTargets)) { + const ambiguousOwner = params.reports.some((report) => report.skipReason === "ambiguous-owner"); + const requiresManualFallback = + ambiguousOwner || params.reports.some((report) => report.skipReason === "owner-unavailable"); + if ( + !deliveredAnyTarget && + (params.reports.some(hasPlannedNativeTargets) || + requiresManualFallback || + params.missingSelectedRuntime) + ) { + const requestGateway = + params.reports.find((report) => params.state.activeRuntimes.has(report.runtimeId)) + ?.requestGateway ?? + params.reports[0]?.requestGateway ?? + Array.from(params.state.activeRuntimes.values())[0]?.requestGateway; + if (!requestGateway) { + return null; + } return { - requestGateway: - params.reports.find((report) => params.state.activeRuntimes.has(report.runtimeId)) - ?.requestGateway ?? expectDefined(params.reports[0], "reports entry at 0").requestGateway, + requestGateway, target, - text: resolveApprovalDeliveryFailedNoticeText({ - approvalId: params.request.id, - approvalKind: params.approvalKind, - allowedDecisions: readAllowedDecisionStrings(params.request), - }), + text: ambiguousOwner + ? resolveAmbiguousApprovalRouteNoticeText() + : resolveApprovalDeliveryFailedNoticeText({ + approvalId: params.request.id, + approvalKind: params.approvalKind, + allowedDecisions: readAllowedDecisionStrings(params.request), + }), }; } @@ -308,7 +436,7 @@ function hasActiveApprovalNativeRouteRuntimeForState( ): boolean { const channel = normalizeChannel(params.channel); const accountId = normalizeOptionalString(params.accountId); - return Array.from(state.activeRuntimes.values()).some((runtime) => { + const matchingRuntimes = Array.from(state.activeRuntimes.values()).filter((runtime) => { if (!runtime.handledKinds.has(params.approvalKind)) { return false; } @@ -320,30 +448,43 @@ function hasActiveApprovalNativeRouteRuntimeForState( accountId === undefined || runtimeAccountId === undefined || runtimeAccountId === accountId ); }); + return accountId === undefined ? matchingRuntimes.length === 1 : matchingRuntimes.length > 0; } async function maybeFinalizeApprovalRouteNotice( state: ApprovalNativeRouteCoordinatorState, approvalId: string, + options?: { force?: boolean }, ): Promise { const entry = state.pendingNotices.get(approvalId); - if (!entry || entry.finalized) { + if (!entry) { return; } - for (const runtimeId of entry.expectedRuntimeIds) { - if (!entry.reports.has(runtimeId)) { - return; + const selection = state.selections.get(approvalId); + if (!selection) { + return; + } + if (!options?.force) { + for (const runtimeId of selection.verdicts.keys()) { + if (!entry.reports.has(runtimeId)) { + return; + } } } + const missingSelectedRuntime = Array.from(selection.verdicts).some( + ([runtimeId, verdict]) => verdict.kind === "selected" && !entry.reports.has(runtimeId), + ); + if (!options?.force && missingSelectedRuntime) { + return; + } - entry.finalized = true; - // Only runtimes observed with the request can block finalization; later runtimes must not delay it. const reports = Array.from(entry.reports.values()); const notice = resolveApprovalRouteNotice({ state, approvalKind: entry.approvalKind, request: entry.request, reports, + missingSelectedRuntime, }); clearPendingApprovalRouteNotice(state, approvalId); if (!notice) { @@ -371,6 +512,8 @@ export function createApprovalNativeRouteReporter(params: { channelLabel?: string; accountId?: string | null; requestGateway: GatewayRequestFn; + shouldHandle: (request: ApprovalRequest) => boolean; + classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass; }) { return createApprovalNativeRouteReporterForState(defaultCoordinatorState, params); } @@ -383,6 +526,8 @@ function createApprovalNativeRouteReporterForState( channelLabel?: string; accountId?: string | null; requestGateway: GatewayRequestFn; + shouldHandle: (request: ApprovalRequest) => boolean; + classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass; }, ) { const runtimeId = `native-approval-route:${++state.runtimeSeq}`; @@ -393,18 +538,21 @@ function createApprovalNativeRouteReporterForState( request: ApprovalRequest; deliveryPlan: ChannelApprovalNativeDeliveryPlan; deliveredTargets: readonly ChannelApprovalNativePlannedTarget[]; + skipReason?: ApprovalRouteSkipReason; }): Promise => { if (state.closed || !registered || !params.handledKinds.has(payload.approvalKind)) { return; } + const selection = resolveApprovalRouteSelection(state, payload); + if (!selection.verdicts.has(runtimeId)) { + return; + } const entry = state.pendingNotices.get(payload.request.id) ?? createPendingApprovalRouteNotice(state, { request: payload.request, approvalKind: payload.approvalKind, - expectedRuntimeIds: [runtimeId], }); - entry.expectedRuntimeIds.add(runtimeId); entry.reports.set(runtimeId, { runtimeId, request: payload.request, @@ -414,27 +562,38 @@ function createApprovalNativeRouteReporterForState( deliveryPlan: payload.deliveryPlan, deliveredTargets: payload.deliveredTargets, requestGateway: params.requestGateway, + skipReason: payload.skipReason, }); state.pendingNotices.set(payload.request.id, entry); await maybeFinalizeApprovalRouteNotice(state, payload.request.id); }; return { - observeRequest(payload: { approvalKind: ChannelApprovalKind; request: ApprovalRequest }): void { - if (state.closed || !registered || !params.handledKinds.has(payload.approvalKind)) { - return; + selectRequest(payload: { + approvalKind: ChannelApprovalKind; + request: ApprovalRequest; + }): ApprovalRouteSelectionVerdict { + if (state.closed || !params.handledKinds.has(payload.approvalKind)) { + return { kind: "ineligible" }; } + if (!registered) { + try { + return params.shouldHandle(payload.request) + ? { kind: "selected" } + : { kind: "ineligible" }; + } catch (error) { + return { kind: "selector-error", error }; + } + } + const selection = resolveApprovalRouteSelection(state, payload); const entry = state.pendingNotices.get(payload.request.id) ?? createPendingApprovalRouteNotice(state, { request: payload.request, approvalKind: payload.approvalKind, - expectedRuntimeIds: Array.from(state.activeRuntimes.values()) - .filter((runtime) => runtime.handledKinds.has(payload.approvalKind)) - .map((runtime) => runtime.runtimeId), }); - entry.expectedRuntimeIds.add(runtimeId); state.pendingNotices.set(payload.request.id, entry); + return selection.verdicts.get(runtimeId) ?? { kind: "ineligible" }; }, start(): void { if (state.closed || registered) { @@ -447,12 +606,15 @@ function createApprovalNativeRouteReporterForState( channelLabel: params.channelLabel, accountId: params.accountId, requestGateway: params.requestGateway, + shouldHandle: params.shouldHandle, + classifyRoute: params.classifyRoute, }); registered = true; }, async reportSkipped(paramsValue: { approvalKind: ChannelApprovalKind; request: ApprovalRequest; + reason: ApprovalRouteSkipReason; }): Promise { await report({ approvalKind: paramsValue.approvalKind, @@ -463,6 +625,7 @@ function createApprovalNativeRouteReporterForState( notifyOriginWhenDmOnly: false, }, deliveredTargets: [], + skipReason: paramsValue.reason, }); }, async reportDelivery(paramsLocal: { @@ -473,20 +636,31 @@ function createApprovalNativeRouteReporterForState( }): Promise { await report(paramsLocal); }, + completeRequest(approvalId: string): void { + clearApprovalRouteSelection(state, approvalId); + clearPendingApprovalRouteNotice(state, approvalId); + }, async stop(): Promise { if (!registered) { return; } + for (const entry of Array.from(state.pendingNotices.values())) { + const selection = state.selections.get(entry.request.id); + if (selection?.verdicts.has(runtimeId) && !entry.reports.has(runtimeId)) { + await report({ + request: entry.request, + approvalKind: entry.approvalKind, + deliveryPlan: { targets: [], originTarget: null, notifyOriginWhenDmOnly: false }, + deliveredTargets: [], + skipReason: + selection.verdicts.get(runtimeId)?.kind === "selected" + ? "owner-unavailable" + : "ineligible", + }); + } + } registered = false; state.activeRuntimes.delete(runtimeId); - for (const entry of state.pendingNotices.values()) { - entry.expectedRuntimeIds.delete(runtimeId); - if (entry.expectedRuntimeIds.size === 0) { - clearPendingApprovalRouteNotice(state, entry.request.id); - continue; - } - await maybeFinalizeApprovalRouteNotice(state, entry.request.id); - } }, }; } @@ -510,6 +684,9 @@ export function createApprovalNativeRouteCoordinator(): ApprovalNativeRouteCoord for (const approvalId of Array.from(state.pendingNotices.keys())) { clearPendingApprovalRouteNotice(state, approvalId); } + for (const approvalId of Array.from(state.selections.keys())) { + clearApprovalRouteSelection(state, approvalId); + } state.activeRuntimes.clear(); }, }; diff --git a/src/infra/approval-native-route-notice.test.ts b/src/infra/approval-native-route-notice.test.ts index 73df045dbe06..0761c81556dc 100644 --- a/src/infra/approval-native-route-notice.test.ts +++ b/src/infra/approval-native-route-notice.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it } from "vitest"; import { describeApprovalDeliveryDestination, + resolveAmbiguousApprovalRouteNoticeText, resolveApprovalRoutedElsewhereNoticeText, } from "./approval-native-route-notice.js"; +it("routes ambiguous ownership to an account-independent approval surface", () => { + expect(resolveAmbiguousApprovalRouteNoticeText()).toBe( + "Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it.", + ); +}); + describe("describeApprovalDeliveryDestination", () => { it("labels approver-DM-only delivery as channel DMs", () => { expect( diff --git a/src/infra/approval-native-route-notice.ts b/src/infra/approval-native-route-notice.ts index 6ac7789a02da..b8249ef7363a 100644 --- a/src/infra/approval-native-route-notice.ts +++ b/src/infra/approval-native-route-notice.ts @@ -29,6 +29,11 @@ export function resolveApprovalRoutedElsewhereNoticeText( )}, not this chat.`; } +/** Builds the recovery notice when no channel account uniquely owns the approval. */ +export function resolveAmbiguousApprovalRouteNoticeText(): string { + return "Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it."; +} + /** Builds the fallback slash-command notice when native approval delivery fails. */ export function resolveApprovalDeliveryFailedNoticeText(params: { approvalId: string; diff --git a/src/infra/approval-native-runtime.ts b/src/infra/approval-native-runtime.ts index 896b50a486a9..77859c3b6b65 100644 --- a/src/infra/approval-native-runtime.ts +++ b/src/infra/approval-native-runtime.ts @@ -14,6 +14,7 @@ import type { ChannelNativeApprovalTransportSpec, PreparedChannelNativeApprovalTarget, } from "./approval-native-runtime-types.js"; +import { classifyApprovalRequestChannelRoute } from "./approval-request-account-binding.js"; import { resolveApprovalRequestKind, type ChannelApprovalKind } from "./approval-types.js"; import { createExecApprovalChannelRuntime, @@ -203,6 +204,13 @@ export function createChannelNativeApprovalRuntime< channel: adapter.channel, channelLabel: adapter.channelLabel, accountId: adapter.accountId, + shouldHandle: (request) => adapter.shouldHandle(request as TRequest), + classifyRoute: (request) => + classifyApprovalRequestChannelRoute({ + cfg: adapter.cfg, + request, + channel: adapter.channel ?? "", + }), requestGateway: async (method: string, params: Record): Promise => { if (gatewayRuntime) { if (method !== "send") { @@ -231,31 +239,44 @@ export function createChannelNativeApprovalRuntime< isConfigured: adapter.isConfigured, shouldHandle: (request) => { const approvalKind = resolveApprovalKind(request); - routeReporter.observeRequest({ + const selection = routeReporter.selectRequest({ approvalKind, request, }); - let shouldHandle: boolean; - try { - shouldHandle = adapter.shouldHandle(request); - } catch (error) { + if (selection.kind === "selected") { + return true; + } + if (selection.kind === "selector-error") { void routeReporter.reportSkipped({ approvalKind, request, + reason: "ineligible", }); - throw error; - } - if (shouldHandle) { - return shouldHandle; + throw selection.error; } void routeReporter.reportSkipped({ approvalKind, request, + reason: selection.kind, }); return false; }, - finalizeResolved: adapter.finalizeResolved, - finalizeExpired: adapter.finalizeExpired, + finalizeResolved: async (params) => { + try { + await adapter.finalizeResolved(params); + } finally { + routeReporter.completeRequest(params.request.id); + } + }, + finalizeExpired: adapter.finalizeExpired + ? async (params) => { + try { + await adapter.finalizeExpired?.(params); + } finally { + routeReporter.completeRequest(params.request.id); + } + } + : undefined, onStopped: adapter.onStopped, beforeGatewayClientStart: () => { routeReporter.start(); @@ -356,8 +377,8 @@ export function createChannelNativeApprovalRuntime< } }, async stop() { - await routeReporter.stop(); await runtime.stop(); + await routeReporter.stop(); }, }; } diff --git a/src/infra/approval-request-account-binding.ts b/src/infra/approval-request-account-binding.ts index 7c104c39948d..c3160ffa78e0 100644 --- a/src/infra/approval-request-account-binding.ts +++ b/src/infra/approval-request-account-binding.ts @@ -11,10 +11,93 @@ import { sessionDeliveryOrigin, } from "../utils/delivery-context.shared.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; +import { matchesApprovalRequestFilters } from "./approval-request-filters.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; -type ApprovalRequestLike = ExecApprovalRequest | PluginApprovalRequest; +export type ApprovalRequestLike = { + id: string; + request: ExecApprovalRequest["request"] | PluginApprovalRequest["request"]; + createdAtMs: number; + expiresAtMs: number; +}; + +function resolveApprovalForwardAccountIds(params: { + cfg: OpenClawConfig; + request: ApprovalRequestLike; + channel?: string | null; + defaultAccountId?: string | null; +}): string[] { + const forwarding = + "command" in params.request.request ? params.cfg.approvals?.exec : params.cfg.approvals?.plugin; + const channel = normalizeOptionalChannel(params.channel); + if (!forwarding?.enabled || (forwarding.mode !== "targets" && forwarding.mode !== "both")) { + return []; + } + if ( + !matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: forwarding.agentFilter, + sessionFilter: forwarding.sessionFilter, + }) + ) { + return []; + } + const accountIds = (forwarding.targets ?? []).flatMap((target) => { + if (normalizeOptionalChannel(target.channel) !== channel) { + return []; + } + const accountId = normalizeOptionalAccountId(target.accountId ?? params.defaultAccountId); + return accountId ? [accountId] : []; + }); + return accountIds; +} + +function hasApprovalForwardTarget(params: { + cfg: OpenClawConfig; + request: ApprovalRequestLike; + channel?: string | null; +}): boolean { + const forwarding = + "command" in params.request.request ? params.cfg.approvals?.exec : params.cfg.approvals?.plugin; + if ( + !forwarding?.enabled || + (forwarding.mode !== "targets" && forwarding.mode !== "both") || + !matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: forwarding.agentFilter, + sessionFilter: forwarding.sessionFilter, + }) + ) { + return false; + } + const channel = normalizeOptionalChannel(params.channel); + return (forwarding.targets ?? []).some( + (target) => normalizeOptionalChannel(target.channel) === channel, + ); +} + +export type ApprovalRequestChannelRouteClass = "bound-or-explicit" | "unbound"; + +/** Classifies whether native delivery has named channel-account owners. */ +export function classifyApprovalRequestChannelRoute(params: { + cfg: OpenClawConfig; + request: ApprovalRequestLike; + channel: string; + defaultAccountId?: string | null; +}): ApprovalRequestChannelRouteClass { + const expectedChannel = normalizeOptionalChannel(params.channel); + if (!expectedChannel) { + return "unbound"; + } + if (resolveApprovalRequestChannelAccountId(params)) { + return "bound-or-explicit"; + } + if (hasApprovalForwardTarget(params)) { + return "bound-or-explicit"; + } + return "unbound"; +} type ApprovalRequestSessionBinding = { channel?: string; @@ -152,3 +235,35 @@ export function doesApprovalRequestMatchChannelAccount(params: { const boundAccountId = sessionBinding?.accountId; return !expectedAccountId || !boundAccountId || expectedAccountId === boundAccountId; } + +/** Selects the one channel account that owns a native approval request. */ +export function doesApprovalRequestSelectChannelAccount(params: { + cfg: OpenClawConfig; + request: ApprovalRequestLike; + channel: string; + accountId?: string | null; + defaultAccountId: string; + eligibleAccountIds: readonly string[]; +}): boolean { + const accountId = + normalizeOptionalAccountId(params.accountId) ?? + normalizeOptionalAccountId(params.defaultAccountId); + if (!accountId) { + return false; + } + const boundAccountId = resolveApprovalRequestChannelAccountId(params); + if (accountId === normalizeOptionalAccountId(boundAccountId)) { + return true; + } + const forwardAccountIds = resolveApprovalForwardAccountIds(params); + if (forwardAccountIds.includes(accountId)) { + return true; + } + if (boundAccountId || forwardAccountIds.length > 0) { + return false; + } + const eligibleAccountIds = params.eligibleAccountIds + .map(normalizeOptionalAccountId) + .filter((candidate): candidate is string => Boolean(candidate)); + return eligibleAccountIds.length === 1 && eligibleAccountIds[0] === accountId; +} diff --git a/src/infra/exec-approval-session-target.test.ts b/src/infra/exec-approval-session-target.test.ts index 8e84c4e726b6..ca2fd0b0f147 100644 --- a/src/infra/exec-approval-session-target.test.ts +++ b/src/infra/exec-approval-session-target.test.ts @@ -13,6 +13,7 @@ import { import { withTempDir } from "../test-helpers/temp-dir.js"; import { doesApprovalRequestMatchChannelAccount, + doesApprovalRequestSelectChannelAccount, resolveApprovalRequestAccountId, resolveApprovalRequestChannelAccountId, } from "./approval-request-account-binding.js"; @@ -65,6 +66,122 @@ const baseRequest: ExecApprovalRequest = { expiresAtMs: 6000, }; +describe("native approval account selection", () => { + it("selects only the sole eligible account when no owner is recorded", () => { + expect( + doesApprovalRequestSelectChannelAccount({ + cfg: {}, + request: baseRequest, + channel: "telegram", + accountId: "default", + defaultAccountId: "default", + eligibleAccountIds: ["default"], + }), + ).toBe(true); + expect( + doesApprovalRequestSelectChannelAccount({ + cfg: {}, + request: baseRequest, + channel: "telegram", + accountId: "default", + defaultAccountId: "default", + eligibleAccountIds: ["default", "ops"], + }), + ).toBe(false); + }); + + it("selects the recorded account even when several accounts are eligible", () => { + const request = buildRequest({ + turnSourceChannel: "telegram", + turnSourceAccountId: "ops", + }); + expect( + doesApprovalRequestSelectChannelAccount({ + cfg: {}, + request, + channel: "telegram", + accountId: "ops", + defaultAccountId: "default", + eligibleAccountIds: ["default", "ops"], + }), + ).toBe(true); + expect( + doesApprovalRequestSelectChannelAccount({ + cfg: {}, + request, + channel: "telegram", + accountId: "default", + defaultAccountId: "default", + eligibleAccountIds: ["default", "ops"], + }), + ).toBe(false); + }); + + it("maps unscoped explicit targets to default and preserves scoped targets", () => { + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [ + { channel: "telegram", to: "owner" }, + { channel: "telegram", to: "ops-owner", accountId: "ops" }, + ], + }, + }, + } as OpenClawConfig; + for (const [accountId, selected] of [ + ["default", true], + ["ops", true], + ["other", false], + ] as const) { + expect( + doesApprovalRequestSelectChannelAccount({ + cfg, + request: baseRequest, + channel: "telegram", + accountId, + defaultAccountId: "default", + eligibleAccountIds: ["default", "ops", "other"], + }), + ).toBe(selected); + } + }); + + it("selects the source account and explicit targets in both mode", () => { + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "both", + targets: [{ channel: "telegram", accountId: "audit" }], + }, + }, + } as OpenClawConfig; + const request = buildRequest({ + turnSourceChannel: "telegram", + turnSourceAccountId: "ops", + }); + + for (const [accountId, selected] of [ + ["ops", true], + ["audit", true], + ["other", false], + ] as const) { + expect( + doesApprovalRequestSelectChannelAccount({ + cfg, + request, + channel: "telegram", + accountId, + defaultAccountId: "default", + eligibleAccountIds: ["ops", "audit", "other"], + }), + ).toBe(selected); + } + }); +}); + type SessionEntryFixture = Partial & { origin?: SessionOrigin; lastChannel?: string; diff --git a/src/plugin-sdk/approval-native-helpers.test.ts b/src/plugin-sdk/approval-native-helpers.test.ts index e92de364a635..9e9e5e7bd449 100644 --- a/src/plugin-sdk/approval-native-helpers.test.ts +++ b/src/plugin-sdk/approval-native-helpers.test.ts @@ -154,6 +154,31 @@ describe("createNativeApprovalMessagingTargetResolvers", () => { }); describe("createNativeApprovalChannelRouteGates", () => { + it("rejects an unbound session route when multiple accounts are eligible", () => { + const cfg = { + approvals: { exec: { enabled: true, mode: "session" } }, + } satisfies OpenClawConfig; + const request = { + ...matrixExecRequest, + request: { ...matrixExecRequest.request, turnSourceAccountId: undefined }, + }; + + for (const accountId of ["default", "ops"]) { + expect( + createMatrixRouteGates({ + accountIds: ["default", "ops"], + enabledAccounts: ["default", "ops"], + }).shouldHandleApprovalRequest({ cfg, accountId, request }), + ).toBe(false); + } + expect( + createMatrixRouteGates({ + accountIds: ["default", "ops"], + enabledAccounts: ["ops"], + }).shouldHandleApprovalRequest({ cfg, accountId: "ops", request }), + ).toBe(true); + }); + it("separates session-native and explicit target routing by approval family", () => { const gates = createMatrixRouteGates(); const cfg = { diff --git a/src/plugin-sdk/approval-native-helpers.ts b/src/plugin-sdk/approval-native-helpers.ts index 0eb0d82338da..b709b22a6096 100644 --- a/src/plugin-sdk/approval-native-helpers.ts +++ b/src/plugin-sdk/approval-native-helpers.ts @@ -7,7 +7,7 @@ import type { ExecApprovalForwardingConfig, ExecApprovalForwardingMode, } from "../config/types.approvals.js"; -import { doesApprovalRequestMatchChannelAccount } from "../infra/approval-request-account-binding.js"; +import { doesApprovalRequestSelectChannelAccount } from "../infra/approval-request-account-binding.js"; import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js"; import { getExecApprovalReplyMetadata, @@ -545,16 +545,6 @@ function isSessionApprovalEligibleViaForwarding( if (!matchesForwardingFilters({ config: forwarding.config, request: params.request })) { return false; } - if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, - channel: params.channel, - accountId: params.accountId, - }) - ) { - return false; - } return params.hasOriginOrSessionTarget({ cfg: params.cfg, accountId: params.accountId, @@ -695,21 +685,7 @@ export function createNativeApprovalChannelRouteGates - params.isTransportEnabled({ - cfg: input.cfg, - accountId: candidateAccountId, - }), - ) - .map((candidateAccountId) => normalizeAccountId(candidateAccountId)); - // Unscoped targets are safe for a non-default account only when exactly - // one enabled account can receive them; otherwise they would be ambiguous. - return enabledAccountIds.length === 1 && enabledAccountIds[0] === normalizedAccountId; + return normalizedAccountId === defaultAccountId; }; const hasMatchingChannelTarget = (input: { @@ -797,6 +773,22 @@ export function createNativeApprovalChannelRouteGates { + const accountId = input.accountId ?? params.resolveDefaultAccountId(input.cfg); + const eligibleAccountIds = params.isTransportEnabled({ cfg: input.cfg, accountId }) + ? [accountId] + : []; + if ( + !doesApprovalRequestSelectChannelAccount({ + cfg: input.cfg, + request: input.request, + channel: params.channel, + accountId: input.accountId, + defaultAccountId: params.resolveDefaultAccountId(input.cfg), + eligibleAccountIds, + }) + ) { + return false; + } return isSessionApprovalEligibleViaForwarding({ ...input, channel: params.channel, diff --git a/src/plugin-sdk/approval-native-runtime.ts b/src/plugin-sdk/approval-native-runtime.ts index 0ac3d382cad0..0a8967891af4 100644 --- a/src/plugin-sdk/approval-native-runtime.ts +++ b/src/plugin-sdk/approval-native-runtime.ts @@ -26,6 +26,7 @@ export { export { buildChannelApprovalNativeTargetKey } from "../infra/approval-native-target-key.js"; export { doesApprovalRequestMatchChannelAccount, + doesApprovalRequestSelectChannelAccount, resolveApprovalRequestAccountId, resolveApprovalRequestChannelAccountId, } from "../infra/approval-request-account-binding.js";