From c87c8e66bf7d30aebce1926084689de81964084f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 1 Apr 2026 17:10:25 -0400 Subject: [PATCH] Refactor channel approval capability seams (#58634) Merged via squash. Prepared head SHA: c9ad4e470695d20f1ad91d04fe4dbf7f8c69bbb1 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 3 + docs/.generated/plugin-sdk-api-baseline.json | 61 +++-- docs/.generated/plugin-sdk-api-baseline.jsonl | 39 +-- docs/plugins/architecture.md | 5 + docs/plugins/sdk-channel-plugins.md | 12 +- docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-overview.md | 2 +- docs/tools/exec-approvals.md | 5 + docs/tools/exec.md | 2 + .../discord/src/approval-native.test.ts | 30 ++- extensions/discord/src/approval-native.ts | 107 ++++++-- extensions/discord/src/channel.ts | 8 +- .../discord/src/monitor/exec-approvals.ts | 160 +++++------- extensions/slack/src/approval-native.test.ts | 20 +- extensions/slack/src/approval-native.ts | 69 +++-- extensions/slack/src/channel.ts | 8 +- extensions/slack/src/exec-approvals.ts | 139 +++------- .../slack/src/monitor/exec-approvals.ts | 121 +++++---- extensions/telegram/src/approval-native.ts | 71 +++-- extensions/telegram/src/channel.test.ts | 32 +++ extensions/telegram/src/channel.ts | 8 +- .../src/exec-approvals-handler.test.ts | 28 ++ .../telegram/src/exec-approvals-handler.ts | 220 ++++++---------- .../telegram/src/exec-approvals.test.ts | 24 ++ extensions/telegram/src/exec-approvals.ts | 114 ++++---- extensions/telegram/test-support.ts | 38 +-- src/auto-reply/reply/commands-approve.ts | 145 ++++++----- src/auto-reply/reply/commands.test.ts | 88 ++++++- src/channels/plugins/approvals.test.ts | 75 ++++++ src/channels/plugins/approvals.ts | 62 ++++- src/channels/plugins/index.ts | 2 +- src/channels/plugins/types.adapters.ts | 5 + src/channels/plugins/types.plugin.ts | 2 + src/channels/plugins/types.ts | 1 + src/infra/approval-native-runtime.test.ts | 153 ++++++++++- src/infra/approval-native-runtime.ts | 201 ++++++++++++++ src/infra/channel-approval-auth.test.ts | 33 ++- src/infra/channel-approval-auth.ts | 8 +- src/infra/exec-approval-surface.test.ts | 65 ++++- src/infra/exec-approval-surface.ts | 14 +- .../approval-client-helpers.test.ts | 141 ++++++++++ src/plugin-sdk/approval-client-helpers.ts | 153 +++++++++++ .../approval-delivery-helpers.test.ts | 147 ++++++++++- src/plugin-sdk/approval-delivery-helpers.ts | 246 +++++++++++------- .../approval-native-helpers.test.ts | 110 ++++++++ src/plugin-sdk/approval-native-helpers.ts | 78 ++++++ src/plugin-sdk/approval-runtime.ts | 16 +- src/plugin-sdk/channel-contract.ts | 2 + 48 files changed, 2214 insertions(+), 861 deletions(-) create mode 100644 src/channels/plugins/approvals.test.ts create mode 100644 src/plugin-sdk/approval-client-helpers.test.ts create mode 100644 src/plugin-sdk/approval-client-helpers.ts create mode 100644 src/plugin-sdk/approval-native-helpers.test.ts create mode 100644 src/plugin-sdk/approval-native-helpers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9df539bf48..171a8b00ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Cron/tool schemas: keep cron tool schemas strict-model-friendly while still preserving `failureAlert=false`, nullable `agentId`/`sessionKey`, and flattened add/update recovery for the newly exposed cron job fields. (#55043) Thanks @brunolorente. - BlueBubbles/config: accept `enrichGroupParticipantsFromContacts` in the core strict config schema so gateways no longer fail validation or startup when the BlueBubbles plugin writes that field. (#56889) Thanks @zqchris. - Agents/failover: classify AbortError and stream-abort messages as timeout so Ollama NDJSON stream aborts stop showing `reason=unknown` in model fallback logs. (#58324) Thanks @yelog +- Exec approvals: route Slack, Discord, and Telegram approvals through the shared channel approval-capability path so native approval auth, delivery, and `/approve` handling stay aligned across channels while preserving Telegram session-key agent filtering. (#58634) thanks @gumadeiras ## 2026.4.2 @@ -86,6 +87,8 @@ Docs: https://docs.openclaw.ai - Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus - Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan. +### Fixes + ## 2026.3.31 ### Breaking diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index 0578f9bd27c..3f00057f741 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -91,7 +91,7 @@ "exportName": "ChannelConfigSchema", "kind": "type", "source": { - "line": 69, + "line": 70, "path": "src/channels/plugins/types.plugin.ts" } }, @@ -100,7 +100,7 @@ "exportName": "ChannelConfigUiHint", "kind": "type", "source": { - "line": 38, + "line": 39, "path": "src/channels/plugins/types.plugin.ts" } }, @@ -109,7 +109,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 653, + "line": 658, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -118,7 +118,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 658, + "line": 663, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -127,7 +127,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 674, + "line": 679, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -181,7 +181,7 @@ "exportName": "ChannelPlugin", "kind": "type", "source": { - "line": 77, + "line": 78, "path": "src/channels/plugins/types.plugin.ts" } }, @@ -1201,12 +1201,30 @@ "path": "src/channels/plugins/types.core.ts" } }, + { + "declaration": "export type ChannelApprovalAdapter = ChannelApprovalAdapter;", + "exportName": "ChannelApprovalAdapter", + "kind": "type", + "source": { + "line": 596, + "path": "src/channels/plugins/types.adapters.ts" + } + }, + { + "declaration": "export type ChannelApprovalCapability = ChannelApprovalCapability;", + "exportName": "ChannelApprovalCapability", + "kind": "type", + "source": { + "line": 591, + "path": "src/channels/plugins/types.adapters.ts" + } + }, { "declaration": "export type ChannelCommandConversationContext = ChannelCommandConversationContext;", "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 662, + "line": 667, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1824,7 +1842,7 @@ "exportName": "ChannelAllowlistAdapter", "kind": "type", "source": { - "line": 597, + "line": 602, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1832,6 +1850,15 @@ "declaration": "export type ChannelApprovalAdapter = ChannelApprovalAdapter;", "exportName": "ChannelApprovalAdapter", "kind": "type", + "source": { + "line": 596, + "path": "src/channels/plugins/types.adapters.ts" + } + }, + { + "declaration": "export type ChannelApprovalCapability = ChannelApprovalCapability;", + "exportName": "ChannelApprovalCapability", + "kind": "type", "source": { "line": 591, "path": "src/channels/plugins/types.adapters.ts" @@ -1914,7 +1941,7 @@ "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 662, + "line": 667, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1932,7 +1959,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 653, + "line": 658, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1941,7 +1968,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 658, + "line": 663, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1950,7 +1977,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 674, + "line": 679, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1959,7 +1986,7 @@ "exportName": "ChannelConversationBindingSupport", "kind": "type", "source": { - "line": 690, + "line": 695, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2265,7 +2292,7 @@ "exportName": "ChannelPlugin", "kind": "type", "source": { - "line": 77, + "line": 78, "path": "src/channels/plugins/types.plugin.ts" } }, @@ -2319,7 +2346,7 @@ "exportName": "ChannelSecurityAdapter", "kind": "type", "source": { - "line": 721, + "line": 726, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3729,7 +3756,7 @@ "exportName": "ChannelConfigUiHint", "kind": "type", "source": { - "line": 38, + "line": 39, "path": "src/channels/plugins/types.plugin.ts" } }, @@ -3774,7 +3801,7 @@ "exportName": "ChannelPlugin", "kind": "type", "source": { - "line": 77, + "line": 78, "path": "src/channels/plugins/types.plugin.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 75834d0c225..181e8d40812 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -8,17 +8,17 @@ {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"index","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"index","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":24,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":233,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":69,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":653,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":658,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":70,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":39,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":658,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":663,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":679,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":271,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"index","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionName = \"send\" | \"broadcast\" | \"poll\" | \"poll-vote\" | \"react\" | \"reactions\" | \"read\" | \"edit\" | \"unsend\" | \"reply\" | \"sendWithEffect\" | \"renameGroup\" | \"setGroupIcon\" | \"addParticipant\" | \"removeParticipant\" | \"leaveGroup\" | \"sendAttachment\" | \"delete\" | \"pin\" | \"unpin\" | \"list-pins\" | \"permissions\" | \"thread-create\" | \"thread-list\" | \"thread-reply\" | \"search\" | \"sticker\" | \"sticker-search\" | \"member-info\" | \"role-info\" | \"emoji-list\" | \"emoji-upload\" | \"sticker-upload\" | \"role-add\" | \"role-remove\" | \"channel-info\" | \"channel-list\" | \"channel-create\" | \"channel-edit\" | \"channel-delete\" | \"channel-move\" | \"category-create\" | \"category-edit\" | \"category-delete\" | \"topic-create\" | \"topic-edit\" | \"voice-status\" | \"event-list\" | \"event-create\" | \"timeout\" | \"kick\" | \"ban\" | \"set-profile\" | \"set-presence\" | \"download-file\" | \"upload-file\";","entrypoint":"index","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"} -{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"index","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"index","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":78,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"index","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"index","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":64,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"index","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"} @@ -131,7 +131,9 @@ {"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":605,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-contract","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":147,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-contract","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":662,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-contract","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":596,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-contract","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":591,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":667,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-contract","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":219,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-contract","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -199,8 +201,9 @@ {"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-runtime","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"channel-runtime","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":24,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":597,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":591,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":602,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":596,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-runtime","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":591,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelApprovalForwardTarget = ChannelApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":39,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelApprovalInitiatingSurfaceState = ChannelActionAvailabilityState;","entrypoint":"channel-runtime","exportName":"ChannelApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":37,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -209,12 +212,12 @@ {"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":492,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":662,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":667,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":98,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":653,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":658,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConversationBindingSupport = ChannelConversationBindingSupport;","entrypoint":"channel-runtime","exportName":"ChannelConversationBindingSupport","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":690,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":658,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":663,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":679,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConversationBindingSupport = ChannelConversationBindingSupport;","entrypoint":"channel-runtime","exportName":"ChannelConversationBindingSupport","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":695,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":454,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":507,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":505,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -248,13 +251,13 @@ {"declaration":"export type ChannelOutboundTargetMode = ChannelOutboundTargetMode;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetMode","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelOutboundTargetRef = ChannelOutboundTargetRef;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":164,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":368,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-runtime","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-runtime","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":78,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":587,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelPollResult = ChannelPollResult;","entrypoint":"channel-runtime","exportName":"ChannelPollResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":465,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":475,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":467,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":721,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":726,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelSecurityContext = ChannelSecurityContext;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":257,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":248,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -410,12 +413,12 @@ {"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"} {"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/infra/secret-file.ts"} {"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"core","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"} -{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"core","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"core","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":39,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":398,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":312,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"../channels/plugins/types.core.js\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":159,"sourcePath":"src/plugin-sdk/core.ts"} -{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":78,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"} {"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/gateway/server-methods/types.ts"} {"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1279,"sourcePath":"src/plugins/types.ts"} diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index ac574fb9b5d..27c76c66c30 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -981,6 +981,7 @@ authoring plugins: `openclaw/plugin-sdk/allow-from`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, + `openclaw/plugin-sdk/approval-runtime`, `openclaw/plugin-sdk/config-runtime`, `openclaw/plugin-sdk/infra-runtime`, `openclaw/plugin-sdk/agent-runtime`, @@ -990,6 +991,10 @@ authoring plugins: `openclaw/plugin-sdk/status-helpers`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. +- Approval-specific channel seams should prefer one `approvalCapability` + contract on the plugin. Core then reads approval auth, delivery, render, and + native-routing behavior through that one capability instead of mixing + approval behavior into unrelated plugin fields. - `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim. New code should import the narrower primitives instead. - Bundled extension internals remain private. External plugins should use only diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index a8b7baaa5a2..9bd4dc850c3 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -59,13 +59,17 @@ omits them. Most channel plugins do not need approval-specific code. - Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery. -- Use `auth.authorizeActorAction` or `auth.getActionAvailabilityState` only when approval auth differs from normal chat auth. +- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior. +- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam. - Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery. -- Use `approvals.delivery` only for native approval routing or fallback suppression. -- Use `approvals.render` only when a channel truly needs custom approval payloads instead of the shared renderer. +- Use `approvalCapability.delivery` only for native approval routing or fallback suppression. +- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer. - If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic. +- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription. +- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core. +- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin. -For Slack, Matrix, Microsoft Teams, and similar chat channels, the default path is usually enough: core handles approvals and the plugin just exposes normal outbound and auth capabilities. +Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle. ## Walkthrough diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index a14e509bc55..94ea8f6f4e6 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -127,7 +127,7 @@ is a small, self-contained module with a clear purpose and documented contract. | `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | | `plugin-sdk/channel-send-result` | Send result types | Reply result types | | `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | - | `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload and reply helpers | + | `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers | | `plugin-sdk/collection-runtime` | Bounded cache helpers | `pruneMapToMaxSize` | | `plugin-sdk/diagnostic-runtime` | Diagnostic gating helpers | `isDiagnosticFlagEnabled`, `isDiagnosticsEnabled` | | `plugin-sdk/error-runtime` | Error formatting helpers | `formatUncaughtError`, error graph helpers | diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 4e05ef942cf..a1ccc6846d8 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -91,7 +91,7 @@ subpaths is in `scripts/lib/plugin-sdk-entrypoints.json`. | --- | --- | | `plugin-sdk/runtime-store` | `createPluginRuntimeStore` | | `plugin-sdk/config-runtime` | Config load/write helpers | - | `plugin-sdk/approval-runtime` | Exec and plugin approval helpers | + | `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers | | `plugin-sdk/infra-runtime` | System event/heartbeat helpers | | `plugin-sdk/collection-runtime` | Small bounded cache helpers | | `plugin-sdk/diagnostic-runtime` | Diagnostic flag and event helpers | diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0063f0034d1..9d8f61e0059 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -14,6 +14,9 @@ commands on a real host (`gateway` or `node`). Think of it like a safety interlo commands are allowed only when policy + allowlist + (optional) user approval all agree. Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals). Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used. +Host exec also uses the local approvals state on that machine. A host-local +`ask: "always"` in `~/.openclaw/exec-approvals.json` keeps prompting even if +session or config defaults request `ask: "on-miss"`. If the companion app UI is **not available**, any request that requires a prompt is resolved by the **ask fallback** (default: deny). @@ -98,6 +101,7 @@ Example schema: - **off**: never prompt. - **on-miss**: prompt only when allowlist does not match. - **always**: prompt on every command. +- `allow-always` durable trust does not suppress prompts when effective ask mode is `always` ### Ask fallback (`askFallback`) @@ -132,6 +136,7 @@ Allowlists are **per agent**. If multiple agents exist, switch which agent you editing in the macOS app. Patterns are **case-insensitive glob matches**. Patterns should resolve to **binary paths** (basename-only entries are ignored). Legacy `agents.default` entries are migrated to `agents.main` on load. +Shell chains such as `echo ok && pwd` still need every top-level segment to satisfy allowlist rules. Examples: diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 88694358b2a..6921068c28b 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -132,6 +132,8 @@ Manual allowlist enforcement matches **resolved binary paths only** (no basename allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in allowlist mode unless every top-level segment satisfies the allowlist (including safe bins). Redirections remain unsupported. +Durable `allow-always` trust does not bypass that rule: a chained command still requires every +top-level segment to match. `autoAllowSkills` is a separate convenience path in exec approvals. It is not the same as manual path allowlist entries. For strict explicit trust, keep `autoAllowSkills` disabled. diff --git a/extensions/discord/src/approval-native.test.ts b/extensions/discord/src/approval-native.test.ts index 6c8b3517bb2..9d5c8fc0e5e 100644 --- a/extensions/discord/src/approval-native.test.ts +++ b/extensions/discord/src/approval-native.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js"; -import { createDiscordNativeApprovalAdapter } from "./approval-native.js"; +import { + createDiscordNativeApprovalAdapter, + shouldHandleDiscordApprovalRequest, +} from "./approval-native.js"; const STORE_PATH = path.join(os.tmpdir(), "openclaw-discord-approval-native-test.json"); @@ -13,6 +16,31 @@ function writeStore(store: Record) { } describe("createDiscordNativeApprovalAdapter", () => { + it("honors ownerAllowFrom fallback when gating approval requests", () => { + expect( + shouldHandleDiscordApprovalRequest({ + cfg: { + commands: { + ownerAllowFrom: ["discord:123"], + }, + } as never, + accountId: "main", + configOverride: { enabled: true } as never, + request: { + id: "approval-1", + request: { + command: "pwd", + turnSourceChannel: "discord", + turnSourceTo: "channel:123456789", + turnSourceAccountId: "main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }, + }), + ).toBe(true); + }); + it("normalizes prefixed turn-source channel ids", async () => { const adapter = createDiscordNativeApprovalAdapter(); diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts index 0b5ed4bf6ce..d507e3f3e3e 100644 --- a/extensions/discord/src/approval-native.ts +++ b/extensions/discord/src/approval-native.ts @@ -1,6 +1,10 @@ import { - createApproverRestrictedNativeApprovalAdapter, - resolveApprovalRequestOriginTarget, + createChannelApproverDmTargetResolver, + createChannelNativeOriginTargetResolver, + createApproverRestrictedNativeApprovalCapability, + splitChannelApprovalCapability, + doesApprovalRequestMatchChannelAccount, + matchesApprovalRequestFilters, } from "openclaw/plugin-sdk/approval-runtime"; import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; @@ -47,18 +51,55 @@ function normalizeDiscordOriginChannelId(value?: string | null): string | null { return /^\d+$/.test(trimmed) ? trimmed : null; } -function resolveDiscordOriginTarget(params: { +export function shouldHandleDiscordApprovalRequest(params: { cfg: OpenClawConfig; accountId?: string | null; request: ApprovalRequest; -}) { - const sessionKind = extractDiscordSessionKind(params.request.request.sessionKey?.trim() || null); - return resolveApprovalRequestOriginTarget({ + configOverride?: DiscordExecApprovalConfig | null; +}): boolean { + const config = + params.configOverride ?? + resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).config.execApprovals; + const approvers = getDiscordExecApprovalApprovers({ cfg: params.cfg, - request: params.request, - channel: "discord", accountId: params.accountId, + configOverride: params.configOverride, + }); + if ( + !doesApprovalRequestMatchChannelAccount({ + cfg: params.cfg, + request: params.request, + channel: "discord", + accountId: params.accountId, + }) + ) { + return false; + } + if (!config) { + return true; + } + if (!config.enabled || approvers.length === 0) { + return false; + } + return matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config.agentFilter, + sessionFilter: config.sessionFilter, + }); +} + +function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalConfig | null) { + return createChannelNativeOriginTargetResolver({ + channel: "discord", + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleDiscordApprovalRequest({ + cfg, + accountId, + request, + configOverride, + }), resolveTurnSourceTarget: (request) => { + const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null); const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || ""; const rawTurnSourceTo = request.request.turnSourceTo?.trim() || ""; const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo); @@ -70,7 +111,8 @@ function resolveDiscordOriginTarget(params: { ? { to: turnSourceTo } : null; }, - resolveSessionTarget: (sessionTarget) => { + resolveSessionTarget: (sessionTarget, request) => { + const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null); if (sessionKind === "dm") { return null; } @@ -79,6 +121,7 @@ function resolveDiscordOriginTarget(params: { }, targetsMatch: (a, b) => a.to === b.to, resolveFallbackTarget: (request) => { + const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null); if (sessionKind === "dm") { return null; } @@ -88,22 +131,23 @@ function resolveDiscordOriginTarget(params: { }); } -function resolveDiscordApproverDmTargets(params: { - cfg: OpenClawConfig; - accountId?: string | null; - configOverride?: DiscordExecApprovalConfig | null; -}) { - return getDiscordExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - configOverride: params.configOverride, - }).map((approver) => ({ to: String(approver) })); +function createDiscordApproverDmTargetResolver(configOverride?: DiscordExecApprovalConfig | null) { + return createChannelApproverDmTargetResolver({ + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleDiscordApprovalRequest({ + cfg, + accountId, + request, + configOverride, + }), + resolveApprovers: ({ cfg, accountId }) => + getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }), + mapApprover: (approver) => ({ to: String(approver) }), + }); } -export function createDiscordNativeApprovalAdapter( - configOverride?: DiscordExecApprovalConfig | null, -) { - return createApproverRestrictedNativeApprovalAdapter({ +export function createDiscordApprovalCapability(configOverride?: DiscordExecApprovalConfig | null) { + return createApproverRestrictedNativeApprovalCapability({ channel: "discord", channelLabel: "Discord", listAccountIds: listDiscordAccountIds, @@ -117,12 +161,19 @@ export function createDiscordNativeApprovalAdapter( configOverride?.target ?? resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? "dm", - resolveOriginTarget: ({ cfg, accountId, request }) => - resolveDiscordOriginTarget({ cfg, accountId, request }), - resolveApproverDmTargets: ({ cfg, accountId }) => - resolveDiscordApproverDmTargets({ cfg, accountId, configOverride }), + resolveOriginTarget: createDiscordOriginTargetResolver(configOverride), + resolveApproverDmTargets: createDiscordApproverDmTargetResolver(configOverride), notifyOriginWhenDmOnly: true, }); } -export const discordNativeApprovalAdapter = createDiscordNativeApprovalAdapter(); +export function createDiscordNativeApprovalAdapter( + configOverride?: DiscordExecApprovalConfig | null, +) { + return splitChannelApprovalCapability(createDiscordApprovalCapability(configOverride)); +} + +export const discordApprovalCapability = createDiscordApprovalCapability(); + +export const discordNativeApprovalAdapter = + splitChannelApprovalCapability(discordApprovalCapability); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0eb48bd95cd..8c7f5437676 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -27,7 +27,7 @@ import { resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; -import { discordNativeApprovalAdapter } from "./approval-native.js"; +import { discordApprovalCapability } from "./approval-native.js"; import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js"; import { listDiscordDirectoryGroupsFromConfig, @@ -325,11 +325,7 @@ export const discordPlugin: ChannelPlugin hint: "", }, }, - auth: discordNativeApprovalAdapter.auth, - approvals: { - delivery: discordNativeApprovalAdapter.delivery, - native: discordNativeApprovalAdapter.native, - }, + approvalCapability: discordApprovalCapability, directory: createChannelDirectoryAdapter({ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 2b079a1eec6..96c2fd3dc0f 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,13 +10,10 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; import { - createExecApprovalChannelRuntime, - deliverApprovalRequestViaChannelNativePlan, - doesApprovalRequestMatchChannelAccount, + createChannelNativeApprovalRuntime, type ExecApprovalChannelRuntime, } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalActionDescriptors } from "openclaw/plugin-sdk/infra-runtime"; @@ -32,7 +29,11 @@ import type { } from "openclaw/plugin-sdk/infra-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; -import { createDiscordNativeApprovalAdapter } from "../approval-native.js"; +import { + createDiscordNativeApprovalAdapter, + createDiscordApprovalCapability, + shouldHandleDiscordApprovalRequest, +} from "../approval-native.js"; import { getDiscordExecApprovalApprovers } from "../exec-approvals.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -61,6 +62,9 @@ type PendingApproval = { discordChannelId: string; timeoutId?: NodeJS.Timeout; }; +type DiscordPendingDelivery = { + body: ReturnType; +}; type PreparedDeliveryTarget = { discordChannelId: string; recipientUserId?: string; @@ -445,98 +449,47 @@ export class DiscordExecApprovalHandler { constructor(opts: DiscordExecApprovalHandlerOpts) { this.opts = opts; - this.runtime = createExecApprovalChannelRuntime< + this.runtime = createChannelNativeApprovalRuntime< PendingApproval, + PreparedDeliveryTarget, + DiscordPendingDelivery, ApprovalRequest, ApprovalResolved >({ label: "discord/exec-approvals", clientDisplayName: "Discord Exec Approvals", cfg: this.opts.cfg, + accountId: this.opts.accountId, gatewayUrl: this.opts.gatewayUrl, eventKinds: ["exec", "plugin"], + nativeAdapter: createDiscordApprovalCapability(this.opts.config).native, isConfigured: () => Boolean(this.opts.config.enabled && this.getApprovers().length > 0), shouldHandle: (request) => this.shouldHandle(request), - deliverRequested: async (request) => await this.deliverRequested(request), - finalizeResolved: async ({ request, resolved, entries }) => { - await this.finalizeResolved(request, resolved, entries); + buildPendingContent: ({ request }) => { + const actionRow = new ExecApprovalActionRow(request.id); + const container = isPluginApprovalRequest(request) + ? createPluginApprovalRequestContainer({ + request, + cfg: this.opts.cfg, + accountId: this.opts.accountId, + actionRow, + }) + : createExecApprovalRequestContainer({ + request, + cfg: this.opts.cfg, + accountId: this.opts.accountId, + actionRow, + }); + const payload = buildExecApprovalPayload(container); + return { + body: stripUndefinedFields(serializePayload(payload)), + }; }, - finalizeExpired: async ({ request, entries }) => { - await this.finalizeExpired(request, entries); - }, - }); - } - - shouldHandle(request: ApprovalRequest): boolean { - const config = this.opts.config; - if (!config.enabled) { - return false; - } - if (this.getApprovers().length === 0) { - return false; - } - - if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: this.opts.cfg, - request, - channel: "discord", - accountId: this.opts.accountId, - }) - ) { - return false; - } - - return matchesApprovalRequestFilters({ - request: request.request, - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, - }); - } - - async start(): Promise { - await this.runtime.start(); - } - - async stop(): Promise { - await this.runtime.stop(); - } - - private async deliverRequested(request: ApprovalRequest): Promise { - const { rest, request: discordRequest } = createDiscordClient( - { token: this.opts.token, accountId: this.opts.accountId }, - this.opts.cfg, - ); - - const actionRow = new ExecApprovalActionRow(request.id); - const container = isPluginApprovalRequest(request) - ? createPluginApprovalRequestContainer({ - request, - cfg: this.opts.cfg, - accountId: this.opts.accountId, - actionRow, - }) - : createExecApprovalRequestContainer({ - request, - cfg: this.opts.cfg, - accountId: this.opts.accountId, - actionRow, - }); - const payload = buildExecApprovalPayload(container); - const body = stripUndefinedFields(serializePayload(payload)); - const approvalKind: ApprovalKind = isPluginApprovalRequest(request) ? "plugin" : "exec"; - const nativeApprovalAdapter = createDiscordNativeApprovalAdapter(this.opts.config); - return await deliverApprovalRequestViaChannelNativePlan< - PreparedDeliveryTarget, - PendingApproval, - ApprovalRequest - >({ - cfg: this.opts.cfg, - accountId: this.opts.accountId, - approvalKind, - request, - adapter: nativeApprovalAdapter.native, sendOriginNotice: async ({ originTarget }) => { + const { rest, request: discordRequest } = createDiscordClient( + { token: this.opts.token, accountId: this.opts.accountId }, + this.opts.cfg, + ); await discordRequest( () => rest.post(Routes.channelMessages(originTarget.to), { @@ -546,6 +499,10 @@ export class DiscordExecApprovalHandler { ); }, prepareTarget: async ({ plannedTarget }) => { + const { rest, request: discordRequest } = createDiscordClient( + { token: this.opts.token, accountId: this.opts.accountId }, + this.opts.cfg, + ); if (plannedTarget.surface === "origin") { return { dedupeKey: plannedTarget.target.to, @@ -577,11 +534,15 @@ export class DiscordExecApprovalHandler { }, }; }, - deliverTarget: async ({ plannedTarget, preparedTarget }) => { + deliverTarget: async ({ plannedTarget, preparedTarget, pendingContent, request }) => { + const { rest, request: discordRequest } = createDiscordClient( + { token: this.opts.token, accountId: this.opts.accountId }, + this.opts.cfg, + ); const message = (await discordRequest( () => rest.post(Routes.channelMessages(preparedTarget.discordChannelId), { - body, + body: pendingContent.body, }) as Promise<{ id: string; channel_id: string }>, plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval", )) as { id: string; channel_id: string }; @@ -605,12 +566,12 @@ export class DiscordExecApprovalHandler { onOriginNoticeError: ({ error }) => { logError(`discord exec approvals: failed to send DM redirect notice: ${String(error)}`); }, - onDuplicateSkipped: ({ preparedTarget }) => { + onDuplicateSkipped: ({ preparedTarget, request }) => { logDebug( `discord exec approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`, ); }, - onDelivered: ({ plannedTarget, preparedTarget }) => { + onDelivered: ({ plannedTarget, preparedTarget, request }) => { if (plannedTarget.surface === "origin") { logDebug( `discord exec approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`, @@ -630,9 +591,32 @@ export class DiscordExecApprovalHandler { `discord exec approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`, ); }, + finalizeResolved: async ({ request, resolved, entries }) => { + await this.finalizeResolved(request, resolved, entries); + }, + finalizeExpired: async ({ request, entries }) => { + await this.finalizeExpired(request, entries); + }, }); } + shouldHandle(request: ApprovalRequest): boolean { + return shouldHandleDiscordApprovalRequest({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + configOverride: this.opts.config, + }); + } + + async start(): Promise { + await this.runtime.start(); + } + + async stop(): Promise { + await this.runtime.stop(); + } + async handleApprovalRequested(request: ApprovalRequest): Promise { await this.runtime.handleRequested(request); } diff --git a/extensions/slack/src/approval-native.test.ts b/extensions/slack/src/approval-native.test.ts index 189a757597a..23124b68b30 100644 --- a/extensions/slack/src/approval-native.test.ts +++ b/extensions/slack/src/approval-native.test.ts @@ -258,7 +258,7 @@ describe("slack native approval adapter", () => { }); it("suppresses generic slack fallback only for slack-originated approvals", () => { - const shouldSuppress = slackNativeApprovalAdapter.delivery.shouldSuppressForwardingFallback; + const shouldSuppress = slackNativeApprovalAdapter.delivery?.shouldSuppressForwardingFallback; if (!shouldSuppress) { throw new Error("slack native delivery suppression unavailable"); } @@ -266,12 +266,16 @@ describe("slack native approval adapter", () => { expect( shouldSuppress({ cfg: buildConfig(), - target: { channel: "slack", accountId: "default" }, + target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" }, request: { + id: "approval-1", request: { + command: "echo hi", turnSourceChannel: "slack", turnSourceAccountId: "default", }, + createdAtMs: 0, + expiresAtMs: 1_000, }, }), ).toBe(true); @@ -279,12 +283,16 @@ describe("slack native approval adapter", () => { expect( shouldSuppress({ cfg: buildConfig(), - target: { channel: "slack", accountId: "default" }, + target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" }, request: { + id: "approval-1", request: { + command: "echo hi", turnSourceChannel: "discord", turnSourceAccountId: "default", }, + createdAtMs: 0, + expiresAtMs: 1_000, }, }), ).toBe(false); @@ -301,7 +309,7 @@ describe("slack native approval adapter", () => { }); expect( - slackNativeApprovalAdapter.auth.authorizeActorAction({ + slackNativeApprovalAdapter.auth.authorizeActorAction?.({ cfg, accountId: "default", senderId: "U123OWNER", @@ -311,7 +319,7 @@ describe("slack native approval adapter", () => { ).toEqual({ authorized: true }); expect( - slackNativeApprovalAdapter.auth.authorizeActorAction({ + slackNativeApprovalAdapter.auth.authorizeActorAction?.({ cfg, accountId: "default", senderId: "U999EXEC", @@ -324,7 +332,7 @@ describe("slack native approval adapter", () => { }); expect( - slackNativeApprovalAdapter.auth.authorizeActorAction({ + slackNativeApprovalAdapter.auth.authorizeActorAction?.({ cfg, accountId: "default", senderId: "U999EXEC", diff --git a/extensions/slack/src/approval-native.ts b/extensions/slack/src/approval-native.ts index cfab74e10fd..c54b830dc62 100644 --- a/extensions/slack/src/approval-native.ts +++ b/extensions/slack/src/approval-native.ts @@ -1,6 +1,8 @@ import { - createApproverRestrictedNativeApprovalAdapter, - resolveApprovalRequestOriginTarget, + createChannelApproverDmTargetResolver, + createChannelNativeOriginTargetResolver, + createApproverRestrictedNativeApprovalCapability, + splitChannelApprovalCapability, } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; @@ -88,40 +90,31 @@ function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean ); } -function resolveSlackOriginTarget(params: { - cfg: OpenClawConfig; - accountId: string; - request: ApprovalRequest; -}) { - if (!shouldHandleSlackExecApprovalRequest(params)) { - return null; - } - return resolveApprovalRequestOriginTarget({ - cfg: params.cfg, - request: params.request, - channel: "slack", - accountId: params.accountId, - resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget, - resolveSessionTarget: resolveSessionSlackOriginTarget, - targetsMatch: slackTargetsMatch, - }); -} +const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "slack", + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleSlackExecApprovalRequest({ + cfg, + accountId, + request, + }), + resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget, + resolveSessionTarget: resolveSessionSlackOriginTarget, + targetsMatch: slackTargetsMatch, +}); -function resolveSlackApproverDmTargets(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: ApprovalRequest; -}) { - if (!shouldHandleSlackExecApprovalRequest(params)) { - return []; - } - return getSlackExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - }).map((approver) => ({ to: `user:${approver}` })); -} +const resolveSlackApproverDmTargets = createChannelApproverDmTargetResolver({ + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleSlackExecApprovalRequest({ + cfg, + accountId, + request, + }), + resolveApprovers: getSlackExecApprovalApprovers, + mapApprover: (approver) => ({ to: `user:${approver}` }), +}); -export const slackNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({ +export const slackApprovalCapability = createApproverRestrictedNativeApprovalCapability({ channel: "slack", channelLabel: "Slack", listAccountIds: listSlackAccountIds, @@ -138,9 +131,9 @@ export const slackNativeApprovalAdapter = createApproverRestrictedNativeApproval requireMatchingTurnSourceChannel: true, resolveSuppressionAccountId: ({ target, request }) => target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined, - resolveOriginTarget: ({ cfg, accountId, request }) => - accountId ? resolveSlackOriginTarget({ cfg, accountId, request }) : null, - resolveApproverDmTargets: ({ cfg, accountId, request }) => - resolveSlackApproverDmTargets({ cfg, accountId, request }), + resolveOriginTarget: resolveSlackOriginTarget, + resolveApproverDmTargets: resolveSlackApproverDmTargets, notifyOriginWhenDmOnly: true, }); + +export const slackNativeApprovalAdapter = splitChannelApprovalCapability(slackApprovalCapability); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8f337d4796e..a37531784de 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,7 +38,7 @@ import { } from "./accounts.js"; import type { SlackActionContext } from "./action-runtime.js"; import { resolveSlackAutoThreadId } from "./action-threading.js"; -import { slackNativeApprovalAdapter } from "./approval-native.js"; +import { slackApprovalCapability } from "./approval-native.js"; import { createSlackActions } from "./channel-actions.js"; import { resolveSlackChannelType } from "./channel-type.js"; import { @@ -283,11 +283,7 @@ export const slackPlugin: ChannelPlugin = crea }), resolveNames: resolveSlackAllowlistNames, }, - auth: slackNativeApprovalAdapter.auth, - approvals: { - delivery: slackNativeApprovalAdapter.delivery, - native: slackNativeApprovalAdapter.native, - }, + approvalCapability: slackApprovalCapability, groups: { resolveRequireMention: resolveSlackGroupRequireMention, resolveToolPolicy: resolveSlackGroupToolPolicy, diff --git a/extensions/slack/src/exec-approvals.ts b/extensions/slack/src/exec-approvals.ts index 5ac8004a88e..6584f027733 100644 --- a/extensions/slack/src/exec-approvals.ts +++ b/extensions/slack/src/exec-approvals.ts @@ -1,17 +1,12 @@ import { + createChannelExecApprovalProfile, doesApprovalRequestMatchChannelAccount, - getExecApprovalReplyMetadata, - matchesApprovalRequestFilters, + isChannelExecApprovalTargetRecipient, resolveApprovalApprovers, } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; -import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { resolveSlackAccount } from "./accounts.js"; -type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; - export function normalizeSlackApproverId(value: string | number): string | undefined { const trimmed = String(value).trim(); if (!trimmed) { @@ -38,123 +33,49 @@ function resolveSlackOwnerApprovers(cfg: OpenClawConfig): string[] { normalizeApprover: normalizeSlackApproverId, }); } - -export function shouldHandleSlackExecApprovalRequest(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: ApprovalRequest; -}): boolean { - if ( - !doesApprovalRequestMatchChannelAccount({ - cfg: params.cfg, - request: params.request, - channel: "slack", - accountId: params.accountId, - }) - ) { - return false; - } - const config = resolveSlackAccount(params).config.execApprovals; - if (!config?.enabled) { - return false; - } - if (getSlackExecApprovalApprovers(params).length === 0) { - return false; - } - return matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, - }); -} - export function getSlackExecApprovalApprovers(params: { cfg: OpenClawConfig; accountId?: string | null; }): string[] { + const account = resolveSlackAccount(params).config; return resolveApprovalApprovers({ - explicit: - resolveSlackAccount(params).config.execApprovals?.approvers ?? - resolveSlackOwnerApprovers(params.cfg), + explicit: account.execApprovals?.approvers ?? resolveSlackOwnerApprovers(params.cfg), normalizeApprover: normalizeSlackApproverId, }); } -export function isSlackExecApprovalClientEnabled(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): boolean { - const config = resolveSlackAccount(params).config.execApprovals; - return Boolean(config?.enabled && getSlackExecApprovalApprovers(params).length > 0); -} - -export function isSlackExecApprovalApprover(params: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; -}): boolean { - const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined; - if (!senderId) { - return false; - } - return getSlackExecApprovalApprovers(params).includes(senderId); -} - -function isSlackExecApprovalTargetsMode(cfg: OpenClawConfig): boolean { - const execApprovals = cfg.approvals?.exec; - if (!execApprovals?.enabled) { - return false; - } - return execApprovals.mode === "targets" || execApprovals.mode === "both"; -} - export function isSlackExecApprovalTargetRecipient(params: { cfg: OpenClawConfig; senderId?: string | null; accountId?: string | null; }): boolean { - const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined; - if (!senderId || !isSlackExecApprovalTargetsMode(params.cfg)) { - return false; - } - const targets = params.cfg.approvals?.exec?.targets; - if (!targets) { - return false; - } - const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; - return targets.some((target) => { - if (target.channel?.trim().toLowerCase() !== "slack") { - return false; - } - if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) { - return false; - } - return normalizeSlackApproverId(target.to) === senderId; + return isChannelExecApprovalTargetRecipient({ + ...params, + channel: "slack", + normalizeSenderId: normalizeSlackApproverId, + matchTarget: ({ target, normalizedSenderId }) => + normalizeSlackApproverId(target.to) === normalizedSenderId, }); } -export function isSlackExecApprovalAuthorizedSender(params: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; -}): boolean { - return isSlackExecApprovalApprover(params) || isSlackExecApprovalTargetRecipient(params); -} +const slackExecApprovalProfile = createChannelExecApprovalProfile({ + resolveConfig: (params) => resolveSlackAccount(params).config.execApprovals, + resolveApprovers: getSlackExecApprovalApprovers, + normalizeSenderId: normalizeSlackApproverId, + isTargetRecipient: isSlackExecApprovalTargetRecipient, + matchesRequestAccount: (params) => + doesApprovalRequestMatchChannelAccount({ + cfg: params.cfg, + request: params.request, + channel: "slack", + accountId: params.accountId, + }), +}); -export function resolveSlackExecApprovalTarget(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): "dm" | "channel" | "both" { - return resolveSlackAccount(params).config.execApprovals?.target ?? "dm"; -} - -export function shouldSuppressLocalSlackExecApprovalPrompt(params: { - cfg: OpenClawConfig; - accountId?: string | null; - payload: ReplyPayload; -}): boolean { - return ( - isSlackExecApprovalClientEnabled(params) && - getExecApprovalReplyMetadata(params.payload) !== null - ); -} +export const isSlackExecApprovalClientEnabled = slackExecApprovalProfile.isClientEnabled; +export const isSlackExecApprovalApprover = slackExecApprovalProfile.isApprover; +export const isSlackExecApprovalAuthorizedSender = slackExecApprovalProfile.isAuthorizedSender; +export const resolveSlackExecApprovalTarget = slackExecApprovalProfile.resolveTarget; +export const shouldHandleSlackExecApprovalRequest = slackExecApprovalProfile.shouldHandleRequest; +export const shouldSuppressLocalSlackExecApprovalPrompt = + slackExecApprovalProfile.shouldSuppressLocalPrompt; diff --git a/extensions/slack/src/monitor/exec-approvals.ts b/extensions/slack/src/monitor/exec-approvals.ts index 182c5db38f5..1606eb46e9c 100644 --- a/extensions/slack/src/monitor/exec-approvals.ts +++ b/extensions/slack/src/monitor/exec-approvals.ts @@ -3,8 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildApprovalInteractiveReply, - createExecApprovalChannelRuntime, - deliverApprovalRequestViaChannelNativePlan, + createChannelNativeApprovalRuntime, getExecApprovalApproverDmNoticeText, resolveExecApprovalCommandDisplay, type ExecApprovalChannelRuntime, @@ -27,6 +26,10 @@ type SlackPendingApproval = { channelId: string; messageTs: string; }; +type SlackPendingDelivery = { + text: string; + blocks: SlackBlock[]; +}; type SlackExecApprovalConfig = NonNullable< NonNullable["slack"]>["execApprovals"] @@ -219,11 +222,19 @@ export class SlackExecApprovalHandler { constructor(opts: SlackExecApprovalHandlerOpts) { this.opts = opts; - this.runtime = createExecApprovalChannelRuntime({ + this.runtime = createChannelNativeApprovalRuntime< + SlackPendingApproval, + { to: string; threadTs?: string }, + SlackPendingDelivery, + ExecApprovalRequest, + ExecApprovalResolved + >({ label: "slack/exec-approvals", clientDisplayName: "Slack Exec Approvals", cfg: opts.cfg, + accountId: opts.accountId, gatewayUrl: opts.gatewayUrl, + nativeAdapter: slackNativeApprovalAdapter.native, isConfigured: () => Boolean( opts.config.enabled && @@ -233,7 +244,49 @@ export class SlackExecApprovalHandler { }).length > 0, ), shouldHandle: (request) => this.shouldHandle(request), - deliverRequested: async (request) => await this.deliverRequested(request), + buildPendingContent: ({ request }) => ({ + text: buildSlackPendingApprovalText(request), + blocks: buildSlackPendingApprovalBlocks(request), + }), + sendOriginNotice: async ({ originTarget }) => { + await sendMessageSlack(originTarget.to, getExecApprovalApproverDmNoticeText(), { + cfg: this.opts.cfg, + accountId: this.opts.accountId, + threadTs: originTarget.threadId != null ? String(originTarget.threadId) : undefined, + client: this.opts.app.client, + }); + }, + prepareTarget: ({ plannedTarget }) => ({ + dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`, + target: { + to: plannedTarget.target.to, + threadTs: + plannedTarget.target.threadId != null + ? String(plannedTarget.target.threadId) + : undefined, + }, + }), + deliverTarget: async ({ preparedTarget, pendingContent, request }) => { + const message = await sendMessageSlack(preparedTarget.to, pendingContent.text, { + cfg: this.opts.cfg, + accountId: this.opts.accountId, + threadTs: preparedTarget.threadTs, + blocks: pendingContent.blocks, + client: this.opts.app.client, + }); + return { + channelId: message.channelId, + messageTs: message.messageId, + }; + }, + onOriginNoticeError: ({ error }) => { + logError(`slack exec approvals: failed to send DM redirect notice: ${String(error)}`); + }, + onDeliveryError: ({ error, request }) => { + logError( + `slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`, + ); + }, finalizeResolved: async ({ request, resolved, entries }) => { await this.finalizeResolved(request, resolved, entries); }, @@ -248,7 +301,14 @@ export class SlackExecApprovalHandler { cfg: this.opts.cfg, accountId: this.opts.accountId, request, - }); + }) + ? slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + approvalKind: "exec", + request, + }).enabled === true + : false; } async start(): Promise { @@ -271,57 +331,6 @@ export class SlackExecApprovalHandler { await this.runtime.handleExpired(approvalId); } - private async deliverRequested(request: ExecApprovalRequest): Promise { - const text = buildSlackPendingApprovalText(request); - const blocks = buildSlackPendingApprovalBlocks(request); - return await deliverApprovalRequestViaChannelNativePlan({ - cfg: this.opts.cfg, - accountId: this.opts.accountId, - approvalKind: "exec", - request, - adapter: slackNativeApprovalAdapter.native, - sendOriginNotice: async ({ originTarget }) => { - await sendMessageSlack(originTarget.to, getExecApprovalApproverDmNoticeText(), { - cfg: this.opts.cfg, - accountId: this.opts.accountId, - threadTs: originTarget.threadId != null ? String(originTarget.threadId) : undefined, - client: this.opts.app.client, - }); - }, - prepareTarget: ({ plannedTarget }) => ({ - dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`, - target: { - to: plannedTarget.target.to, - threadTs: - plannedTarget.target.threadId != null - ? String(plannedTarget.target.threadId) - : undefined, - }, - }), - deliverTarget: async ({ preparedTarget }) => { - const message = await sendMessageSlack(preparedTarget.to, text, { - cfg: this.opts.cfg, - accountId: this.opts.accountId, - threadTs: preparedTarget.threadTs, - blocks, - client: this.opts.app.client, - }); - return { - channelId: message.channelId, - messageTs: message.messageId, - }; - }, - onOriginNoticeError: ({ error }) => { - logError(`slack exec approvals: failed to send DM redirect notice: ${String(error)}`); - }, - onDeliveryError: ({ error }) => { - logError( - `slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`, - ); - }, - }); - } - private async finalizeResolved( request: ExecApprovalRequest, resolved: ExecApprovalResolved, diff --git a/extensions/telegram/src/approval-native.ts b/extensions/telegram/src/approval-native.ts index 9966c4cf502..b0812774664 100644 --- a/extensions/telegram/src/approval-native.ts +++ b/extensions/telegram/src/approval-native.ts @@ -1,6 +1,8 @@ import { - createApproverRestrictedNativeApprovalAdapter, - resolveApprovalRequestOriginTarget, + createChannelApproverDmTargetResolver, + createChannelNativeOriginTargetResolver, + createApproverRestrictedNativeApprovalCapability, + splitChannelApprovalCapability, } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; @@ -11,6 +13,7 @@ import { isTelegramExecApprovalAuthorizedSender, isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, + shouldHandleTelegramExecApprovalRequest, } from "./exec-approvals.js"; import { normalizeTelegramChatId, parseTelegramTarget } from "./targets.js"; @@ -57,33 +60,31 @@ function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): return normalizedA === normalizedB && a.threadId === b.threadId; } -function resolveTelegramOriginTarget(params: { - cfg: OpenClawConfig; - accountId: string; - request: ApprovalRequest; -}) { - return resolveApprovalRequestOriginTarget({ - cfg: params.cfg, - request: params.request, - channel: "telegram", - accountId: params.accountId, - resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget, - resolveSessionTarget: resolveSessionTelegramOriginTarget, - targetsMatch: telegramTargetsMatch, - }); -} +const resolveTelegramOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "telegram", + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleTelegramExecApprovalRequest({ + cfg, + accountId, + request, + }), + resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget, + resolveSessionTarget: resolveSessionTelegramOriginTarget, + targetsMatch: telegramTargetsMatch, +}); -function resolveTelegramApproverDmTargets(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}) { - return getTelegramExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - }).map((approver) => ({ to: approver })); -} +const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({ + shouldHandleRequest: ({ cfg, accountId, request }) => + shouldHandleTelegramExecApprovalRequest({ + cfg, + accountId, + request, + }), + resolveApprovers: getTelegramExecApprovalApprovers, + mapApprover: (approver) => ({ to: approver }), +}); -export const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({ +export const telegramApprovalCapability = createApproverRestrictedNativeApprovalCapability({ channel: "telegram", channelLabel: "Telegram", listAccountIds: listTelegramAccountIds, @@ -100,14 +101,10 @@ export const telegramNativeApprovalAdapter = createApproverRestrictedNativeAppro requireMatchingTurnSourceChannel: true, resolveSuppressionAccountId: ({ target, request }) => target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined, - resolveOriginTarget: ({ cfg, accountId, request }) => - accountId - ? resolveTelegramOriginTarget({ - cfg, - accountId, - request, - }) - : null, - resolveApproverDmTargets: ({ cfg, accountId }) => - resolveTelegramApproverDmTargets({ cfg, accountId }), + resolveOriginTarget: resolveTelegramOriginTarget, + resolveApproverDmTargets: resolveTelegramApproverDmTargets, }); + +export const telegramNativeApprovalAdapter = splitChannelApprovalCapability( + telegramApprovalCapability, +); diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 84ee403253a..3dd1b0ac4d9 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -256,6 +256,38 @@ describe("telegramPlugin threading", () => { }); }); +describe("telegramPlugin bindings", () => { + it("preserves topic and direct command conversation routing", () => { + expect( + telegramPlugin.bindings?.resolveCommandConversation?.({ + accountId: "default", + threadId: "77", + originatingTo: "-1001", + }), + ).toEqual({ + conversationId: "-1001:topic:77", + parentConversationId: "-1001", + }); + + expect( + telegramPlugin.bindings?.resolveCommandConversation?.({ + accountId: "default", + originatingTo: "12345", + }), + ).toEqual({ + conversationId: "12345", + parentConversationId: "12345", + }); + + expect( + telegramPlugin.bindings?.resolveCommandConversation?.({ + accountId: "default", + originatingTo: "-1001", + }), + ).toBeNull(); + }); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index acd148d88de..b510c75bcc2 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -41,7 +41,7 @@ import { import { resolveTelegramAutoThreadId } from "./action-threading.js"; import { lookupTelegramChatId } from "./api-fetch.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; -import { telegramNativeApprovalAdapter } from "./approval-native.js"; +import { telegramApprovalCapability } from "./approval-native.js"; import * as auditModule from "./audit.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js"; @@ -578,10 +578,8 @@ export const telegramPlugin = createChatChannelPlugin({ await deleteTelegramUpdateOffset({ accountId }); }, }, - auth: telegramNativeApprovalAdapter.auth, - approvals: { - delivery: telegramNativeApprovalAdapter.delivery, - native: telegramNativeApprovalAdapter.native, + approvalCapability: { + ...telegramApprovalCapability, render: { exec: { buildPendingPayload: ({ request, nowMs }) => diff --git a/extensions/telegram/src/exec-approvals-handler.test.ts b/extensions/telegram/src/exec-approvals-handler.test.ts index b79c6e1239b..fb2408a5e33 100644 --- a/extensions/telegram/src/exec-approvals-handler.test.ts +++ b/extensions/telegram/src/exec-approvals-handler.test.ts @@ -243,6 +243,34 @@ describe("TelegramExecApprovalHandler", () => { ); }); + it("delivers plugin approvals when the agent only exists in the Telegram session key", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + agentFilter: ["main"], + target: "dm", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendMessage } = createHandler(cfg); + + await handler.handleRequested({ + ...pluginRequest, + request: { + ...pluginRequest.request, + agentId: undefined, + }, + }); + + const [chatId, text] = sendMessage.mock.calls[0] ?? []; + expect(chatId).toBe("8460800771"); + expect(text).toContain("Plugin approval required"); + }); + it("does not deliver plugin approvals for a different Telegram account", async () => { const cfg = { channels: { diff --git a/extensions/telegram/src/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts index 2a2a150e4f1..8c28c18e30a 100644 --- a/extensions/telegram/src/exec-approvals-handler.ts +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -1,13 +1,8 @@ -import { - buildPluginApprovalPendingReplyPayload, - matchesApprovalRequestFilters, -} from "openclaw/plugin-sdk/approval-runtime"; +import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { - createExecApprovalChannelRuntime, - deliverApprovalRequestViaChannelNativePlan, + createChannelNativeApprovalRuntime, type ExecApprovalChannelRuntime, - resolveApprovalRequestAccountId, } from "openclaw/plugin-sdk/infra-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -20,7 +15,6 @@ import type { PluginApprovalRequest, PluginApprovalResolved, } from "openclaw/plugin-sdk/infra-runtime"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { telegramNativeApprovalAdapter } from "./approval-native.js"; @@ -28,6 +22,7 @@ import { resolveTelegramInlineButtons } from "./button-types.js"; import { getTelegramExecApprovalApprovers, resolveTelegramExecApprovalConfig, + shouldHandleTelegramExecApprovalRequest, } from "./exec-approvals.js"; import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js"; @@ -35,26 +30,14 @@ const log = createSubsystemLogger("telegram/exec-approvals"); type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; -type ApprovalKind = "exec" | "plugin"; - type PendingMessage = { chatId: string; messageId: string; }; - -function resolveBoundTelegramAccountId(params: { - cfg: OpenClawConfig; - request: ApprovalRequest; -}): string | null { - return resolveApprovalRequestAccountId({ - cfg: params.cfg, - request: params.request, - channel: - params.request.request.turnSourceChannel?.trim().toLowerCase() === "telegram" - ? null - : "telegram", - }); -} +type TelegramPendingDelivery = { + text: string; + buttons: ReturnType; +}; export type TelegramExecApprovalHandlerOpts = { token: string; @@ -71,48 +54,6 @@ export type TelegramExecApprovalHandlerDeps = { editReplyMarkup?: typeof editMessageReplyMarkupTelegram; }; -function matchesFilters(params: { - cfg: OpenClawConfig; - accountId: string; - request: ApprovalRequest; -}): boolean { - const config = resolveTelegramExecApprovalConfig({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!config?.enabled) { - return false; - } - const approvers = getTelegramExecApprovalApprovers({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (approvers.length === 0) { - return false; - } - if ( - !matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config.agentFilter, - sessionFilter: config.sessionFilter, - fallbackAgentIdFromSessionKey: true, - }) - ) { - return false; - } - const boundAccountId = resolveBoundTelegramAccountId({ - cfg: params.cfg, - request: params.request, - }); - if ( - boundAccountId && - normalizeAccountId(boundAccountId) !== normalizeAccountId(params.accountId) - ) { - return false; - } - return true; -} - function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean { const config = resolveTelegramExecApprovalConfig({ cfg: params.cfg, @@ -144,26 +85,94 @@ export class TelegramExecApprovalHandler { this.sendTyping = deps.sendTyping ?? sendTypingTelegram; this.sendMessage = deps.sendMessage ?? sendMessageTelegram; this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram; - this.runtime = createExecApprovalChannelRuntime< + this.runtime = createChannelNativeApprovalRuntime< PendingMessage, + { chatId: string; messageThreadId?: number }, + TelegramPendingDelivery, ApprovalRequest, ApprovalResolved >({ label: "telegram/exec-approvals", clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`, cfg: this.opts.cfg, + accountId: this.opts.accountId, gatewayUrl: this.opts.gatewayUrl, eventKinds: ["exec", "plugin"], nowMs: this.nowMs, + nativeAdapter: telegramNativeApprovalAdapter.native, isConfigured: () => isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }), shouldHandle: (request) => - matchesFilters({ + shouldHandleTelegramExecApprovalRequest({ cfg: this.opts.cfg, accountId: this.opts.accountId, request, }), - deliverRequested: async (request) => await this.deliverRequested(request), + buildPendingContent: ({ request, approvalKind, nowMs }) => { + const payload = + approvalKind === "plugin" + ? buildPluginApprovalPendingReplyPayload({ + request: request as PluginApprovalRequest, + nowMs, + }) + : buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: resolveExecApprovalCommandDisplay((request as ExecApprovalRequest).request) + .commandText, + cwd: (request as ExecApprovalRequest).request.cwd ?? undefined, + host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway", + nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + } satisfies ExecApprovalPendingReplyParams); + return { + text: payload.text ?? "", + buttons: resolveTelegramInlineButtons({ + interactive: payload.interactive, + }), + }; + }, + prepareTarget: ({ plannedTarget }) => ({ + dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`, + target: { + chatId: plannedTarget.target.to, + messageThreadId: + typeof plannedTarget.target.threadId === "number" + ? plannedTarget.target.threadId + : undefined, + }, + }), + deliverTarget: async ({ preparedTarget, pendingContent }) => { + await this.sendTyping(preparedTarget.chatId, { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + ...(preparedTarget.messageThreadId != null + ? { messageThreadId: preparedTarget.messageThreadId } + : {}), + }).catch(() => {}); + + const result = await this.sendMessage(preparedTarget.chatId, pendingContent.text, { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + buttons: pendingContent.buttons, + ...(preparedTarget.messageThreadId != null + ? { messageThreadId: preparedTarget.messageThreadId } + : {}), + }); + return { + chatId: result.chatId, + messageId: result.messageId, + }; + }, + onDeliveryError: ({ error, request }) => { + log.error( + `telegram exec approvals: failed to send request ${request.id}: ${String(error)}`, + ); + }, finalizeResolved: async ({ resolved, entries }) => { await this.finalizeResolved(resolved, entries); }, @@ -174,7 +183,7 @@ export class TelegramExecApprovalHandler { } shouldHandle(request: ApprovalRequest): boolean { - return matchesFilters({ + return shouldHandleTelegramExecApprovalRequest({ cfg: this.opts.cfg, accountId: this.opts.accountId, request, @@ -193,77 +202,6 @@ export class TelegramExecApprovalHandler { await this.runtime.handleRequested(request); } - private async deliverRequested(request: ApprovalRequest): Promise { - const approvalKind: ApprovalKind = request.id.startsWith("plugin:") ? "plugin" : "exec"; - const payload = - approvalKind === "plugin" - ? buildPluginApprovalPendingReplyPayload({ - request: request as PluginApprovalRequest, - nowMs: this.nowMs(), - }) - : buildExecApprovalPendingReplyPayload({ - approvalId: request.id, - approvalSlug: request.id.slice(0, 8), - approvalCommandId: request.id, - command: resolveExecApprovalCommandDisplay((request as ExecApprovalRequest).request) - .commandText, - cwd: (request as ExecApprovalRequest).request.cwd ?? undefined, - host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway", - nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined, - expiresAtMs: request.expiresAtMs, - nowMs: this.nowMs(), - } satisfies ExecApprovalPendingReplyParams); - const buttons = resolveTelegramInlineButtons({ - interactive: payload.interactive, - }); - return await deliverApprovalRequestViaChannelNativePlan({ - cfg: this.opts.cfg, - accountId: this.opts.accountId, - approvalKind, - request, - adapter: telegramNativeApprovalAdapter.native, - prepareTarget: ({ plannedTarget }) => ({ - dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`, - target: { - chatId: plannedTarget.target.to, - messageThreadId: - typeof plannedTarget.target.threadId === "number" - ? plannedTarget.target.threadId - : undefined, - }, - }), - deliverTarget: async ({ preparedTarget }) => { - await this.sendTyping(preparedTarget.chatId, { - cfg: this.opts.cfg, - token: this.opts.token, - accountId: this.opts.accountId, - ...(preparedTarget.messageThreadId != null - ? { messageThreadId: preparedTarget.messageThreadId } - : {}), - }).catch(() => {}); - - const result = await this.sendMessage(preparedTarget.chatId, payload.text ?? "", { - cfg: this.opts.cfg, - token: this.opts.token, - accountId: this.opts.accountId, - buttons, - ...(preparedTarget.messageThreadId != null - ? { messageThreadId: preparedTarget.messageThreadId } - : {}), - }); - return { - chatId: result.chatId, - messageId: result.messageId, - }; - }, - onDeliveryError: ({ error }) => { - log.error( - `telegram exec approvals: failed to send request ${request.id}: ${String(error)}`, - ); - }, - }); - } - async handleResolved(resolved: ApprovalResolved): Promise { await this.runtime.handleResolved(resolved); } diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index 64951ded1a8..d807ab58f0c 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -7,6 +7,7 @@ import { isTelegramExecApprovalClientEnabled, isTelegramExecApprovalTargetRecipient, resolveTelegramExecApprovalTarget, + shouldHandleTelegramExecApprovalRequest, shouldEnableTelegramExecApprovalButtons, shouldInjectTelegramExecApprovalButtons, } from "./exec-approvals.js"; @@ -73,6 +74,29 @@ describe("telegram exec approvals", () => { ).toBe("dm"); }); + it("matches agent filters from the Telegram session key when request.agentId is absent", () => { + const cfg = buildConfig({ + enabled: true, + approvers: ["123"], + agentFilter: ["ops"], + }); + + expect( + shouldHandleTelegramExecApprovalRequest({ + cfg, + request: { + id: "req-1", + request: { + command: "echo hi", + sessionKey: "agent:ops:telegram:direct:123:tail", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toBe(true); + }); + it("only injects approval buttons on eligible telegram targets", () => { const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" }); const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" }); diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index 1359ca9fc85..9f814c26260 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -1,5 +1,9 @@ -import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; -import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime"; +import { + createChannelExecApprovalProfile, + isChannelExecApprovalTargetRecipient, + resolveApprovalRequestAccountId, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; @@ -41,79 +45,53 @@ export function getTelegramExecApprovalApprovers(params: { }); } -export function isTelegramExecApprovalClientEnabled(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): boolean { - const config = resolveTelegramExecApprovalConfig(params); - return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0); -} - -export function isTelegramExecApprovalApprover(params: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; -}): boolean { - const senderId = params.senderId?.trim(); - if (!senderId) { - return false; - } - const approvers = getTelegramExecApprovalApprovers(params); - return approvers.includes(senderId); -} - -function isTelegramExecApprovalTargetsMode(cfg: OpenClawConfig): boolean { - const execApprovals = cfg.approvals?.exec; - if (!execApprovals?.enabled) { - return false; - } - return execApprovals.mode === "targets" || execApprovals.mode === "both"; -} - export function isTelegramExecApprovalTargetRecipient(params: { cfg: OpenClawConfig; senderId?: string | null; accountId?: string | null; }): boolean { - const senderId = params.senderId?.trim(); - if (!senderId || !isTelegramExecApprovalTargetsMode(params.cfg)) { - return false; - } - const targets = params.cfg.approvals?.exec?.targets; - if (!targets) { - return false; - } - const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; - return targets.some((target) => { - const channel = target.channel?.trim().toLowerCase(); - if (channel !== "telegram") { - return false; - } - if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) { - return false; - } - const to = target.to ? normalizeTelegramChatId(target.to) : undefined; - if (!to || to.startsWith("-")) { - return false; - } - return to === senderId; + return isChannelExecApprovalTargetRecipient({ + ...params, + channel: "telegram", + matchTarget: ({ target, normalizedSenderId }) => { + const to = target.to ? normalizeTelegramChatId(target.to) : undefined; + if (!to || to.startsWith("-")) { + return false; + } + return to === normalizedSenderId; + }, }); } -export function isTelegramExecApprovalAuthorizedSender(params: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; -}): boolean { - return isTelegramExecApprovalApprover(params) || isTelegramExecApprovalTargetRecipient(params); -} +const telegramExecApprovalProfile = createChannelExecApprovalProfile({ + resolveConfig: resolveTelegramExecApprovalConfig, + resolveApprovers: getTelegramExecApprovalApprovers, + isTargetRecipient: isTelegramExecApprovalTargetRecipient, + matchesRequestAccount: ({ cfg, accountId, request }) => { + const boundAccountId = resolveApprovalRequestAccountId({ + cfg, + request, + channel: + request.request.turnSourceChannel?.trim().toLowerCase() === "telegram" ? null : "telegram", + }); + return ( + !boundAccountId || + !accountId || + normalizeAccountId(boundAccountId) === normalizeAccountId(accountId) + ); + }, + // Telegram session keys often carry the only stable agent ID for approval routing. + fallbackAgentIdFromSessionKey: true, + requireClientEnabledForLocalPromptSuppression: false, +}); -export function resolveTelegramExecApprovalTarget(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): "dm" | "channel" | "both" { - return resolveTelegramExecApprovalConfig(params)?.target ?? "dm"; -} +export const isTelegramExecApprovalClientEnabled = telegramExecApprovalProfile.isClientEnabled; +export const isTelegramExecApprovalApprover = telegramExecApprovalProfile.isApprover; +export const isTelegramExecApprovalAuthorizedSender = + telegramExecApprovalProfile.isAuthorizedSender; +export const resolveTelegramExecApprovalTarget = telegramExecApprovalProfile.resolveTarget; +export const shouldHandleTelegramExecApprovalRequest = + telegramExecApprovalProfile.shouldHandleRequest; export function shouldInjectTelegramExecApprovalButtons(params: { cfg: OpenClawConfig; @@ -158,7 +136,5 @@ export function shouldSuppressLocalTelegramExecApprovalPrompt(params: { accountId?: string | null; payload: ReplyPayload; }): boolean { - void params.cfg; - void params.accountId; - return getExecApprovalReplyMetadata(params.payload) !== null; + return telegramExecApprovalProfile.shouldSuppressLocalPrompt(params); } diff --git a/extensions/telegram/test-support.ts b/extensions/telegram/test-support.ts index 81ba08d2b54..f37164acdd1 100644 --- a/extensions/telegram/test-support.ts +++ b/extensions/telegram/test-support.ts @@ -1,34 +1,12 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime"; +import { splitChannelApprovalCapability } from "openclaw/plugin-sdk/approval-runtime"; import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/telegram-core"; import type { ResolvedTelegramAccount } from "./src/accounts.js"; import { resolveTelegramAccount } from "./src/accounts.js"; -import { listTelegramAccountIds } from "./src/accounts.js"; -import { - getTelegramExecApprovalApprovers, - isTelegramExecApprovalAuthorizedSender, - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, - resolveTelegramExecApprovalTarget, -} from "./src/exec-approvals.js"; +import { telegramApprovalCapability } from "./src/approval-native.js"; import { telegramConfigAdapter } from "./src/shared.js"; -const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({ - channel: "telegram", - channelLabel: "Telegram", - listAccountIds: listTelegramAccountIds, - hasApprovers: ({ cfg, accountId }) => - getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0, - isExecAuthorizedSender: ({ cfg, accountId, senderId }) => - isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }), - isPluginAuthorizedSender: ({ cfg, accountId, senderId }) => - isTelegramExecApprovalApprover({ cfg, accountId, senderId }), - isNativeDeliveryEnabled: ({ cfg, accountId }) => - isTelegramExecApprovalClientEnabled({ cfg, accountId }), - resolveNativeDeliveryMode: ({ cfg, accountId }) => - resolveTelegramExecApprovalTarget({ cfg, accountId }), - requireMatchingTurnSourceChannel: true, -}); +const telegramNativeApprovalAdapter = splitChannelApprovalCapability(telegramApprovalCapability); export const telegramCommandTestPlugin = { id: "telegram", @@ -44,6 +22,7 @@ export const telegramCommandTestPlugin = { }, config: telegramConfigAdapter, auth: telegramNativeApprovalAdapter.auth, + approvalCapability: telegramApprovalCapability, pairing: { idLabel: "telegramUserId", }, @@ -59,5 +38,12 @@ export const telegramCommandTestPlugin = { }), } satisfies Pick< ChannelPlugin, - "id" | "meta" | "capabilities" | "config" | "auth" | "pairing" | "allowlist" + | "id" + | "meta" + | "capabilities" + | "config" + | "auth" + | "approvalCapability" + | "pairing" + | "allowlist" >; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 9aa9cb5f620..2fcadce4476 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -118,6 +118,49 @@ function isApprovalNotFoundError(err: unknown): boolean { return /unknown or expired approval id/i.test(err.message); } +function formatApprovalSubmitError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +type ApprovalMethod = "exec.approval.resolve" | "plugin.approval.resolve"; + +function resolveApprovalMethods(params: { + approvalId: string; + execAuthorization: ReturnType; + pluginAuthorization: ReturnType; +}): ApprovalMethod[] { + if (params.approvalId.startsWith("plugin:")) { + return params.pluginAuthorization.authorized ? ["plugin.approval.resolve"] : []; + } + if (params.execAuthorization.authorized && params.pluginAuthorization.authorized) { + return ["exec.approval.resolve", "plugin.approval.resolve"]; + } + if (params.execAuthorization.authorized) { + return ["exec.approval.resolve"]; + } + if (params.pluginAuthorization.authorized) { + return ["plugin.approval.resolve"]; + } + return []; +} + +function resolveApprovalAuthorizationError(params: { + approvalId: string; + execAuthorization: ReturnType; + pluginAuthorization: ReturnType; +}): string { + if (params.approvalId.startsWith("plugin:")) { + return ( + params.pluginAuthorization.reason ?? "❌ You are not authorized to approve this request." + ); + } + return ( + params.execAuthorization.reason ?? + params.pluginAuthorization.reason ?? + "❌ You are not authorized to approve this request." + ); +} + export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -169,17 +212,6 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm }; } - if (isPluginId && !pluginApprovalAuthorization.authorized) { - return { - shouldContinue: false, - reply: { - text: - pluginApprovalAuthorization.reason ?? - "❌ You are not authorized to approve this request.", - }, - }; - } - const missingScope = requireGatewayClientScopeForInternalChannel(params, { label: "/approve", allowedScopes: ["operator.approvals", "operator.admin"], @@ -200,68 +232,49 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm }); }; - // Plugin approval IDs are kind-prefixed (`plugin:`); route directly when detected. - // Unprefixed IDs try the authorized path first, then fall back for backward compat. - if (isPluginId) { - try { - await callApprovalMethod("plugin.approval.resolve"); - } catch (err) { - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(err)}` }, - }; - } - } else if (execApprovalAuthorization.authorized) { - try { - await callApprovalMethod("exec.approval.resolve"); - } catch (err) { - if (isApprovalNotFoundError(err)) { - if (!pluginApprovalAuthorization.authorized) { - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(err)}` }, - }; - } - try { - await callApprovalMethod("plugin.approval.resolve"); - } catch (pluginErr) { - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(pluginErr)}` }, - }; - } - } else { - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(err)}` }, - }; - } - } - } else if (pluginApprovalAuthorization.authorized) { - try { - await callApprovalMethod("plugin.approval.resolve"); - } catch (err) { - if (isApprovalNotFoundError(err)) { - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(err)}` }, - }; - } - return { - shouldContinue: false, - reply: { text: `❌ Failed to submit approval: ${String(err)}` }, - }; - } - } else { + const methods = resolveApprovalMethods({ + approvalId: parsed.id, + execAuthorization: execApprovalAuthorization, + pluginAuthorization: pluginApprovalAuthorization, + }); + if (methods.length === 0) { return { shouldContinue: false, reply: { - text: - execApprovalAuthorization.reason ?? "❌ You are not authorized to approve this request.", + text: resolveApprovalAuthorizationError({ + approvalId: parsed.id, + execAuthorization: execApprovalAuthorization, + pluginAuthorization: pluginApprovalAuthorization, + }), }, }; } + let lastError: unknown = null; + for (const [index, method] of methods.entries()) { + try { + await callApprovalMethod(method); + lastError = null; + break; + } catch (error) { + lastError = error; + const isLastMethod = index === methods.length - 1; + if (!isApprovalNotFoundError(error) || isLastMethod) { + return { + shouldContinue: false, + reply: { text: `❌ Failed to submit approval: ${formatApprovalSubmitError(error)}` }, + }; + } + } + } + + if (lastError) { + return { + shouldContinue: false, + reply: { text: `❌ Failed to submit approval: ${formatApprovalSubmitError(lastError)}` }, + }; + } + return { shouldContinue: false, reply: { text: `✅ Approval ${parsed.decision} submitted for ${parsed.id}.` }, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 2d48dc7aef9..57cc978e1f8 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -11,7 +11,10 @@ import { buildLegacyDmAccountAllowlistAdapter, } from "../../plugin-sdk/allowlist-config-edit.js"; import { resolveApprovalApprovers } from "../../plugin-sdk/approval-approvers.js"; -import { createApproverRestrictedNativeApprovalAdapter } from "../../plugin-sdk/approval-runtime.js"; +import { + createApproverRestrictedNativeApprovalAdapter, + createResolvedApproverActionAuthAdapter, +} from "../../plugin-sdk/approval-runtime.js"; import { createScopedChannelConfigAdapter } from "../../plugin-sdk/channel-config-helpers.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; @@ -111,6 +114,42 @@ const slackCommandTestPlugin: ChannelPlugin = { }), }; +const signalCommandTestPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "signal", + label: "Signal", + docsPath: "/channels/signal", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + nativeCommands: true, + }, + }), + auth: createResolvedApproverActionAuthAdapter({ + channelLabel: "Signal", + resolveApprovers: ({ cfg, accountId }) => { + const signal = accountId ? cfg.channels?.signal?.accounts?.[accountId] : cfg.channels?.signal; + return resolveApprovalApprovers({ + allowFrom: signal?.allowFrom, + defaultTo: signal?.defaultTo, + normalizeApprover: (value) => String(value).trim() || undefined, + }); + }, + }), + allowlist: buildLegacyDmAccountAllowlistAdapter({ + channelId: "signal", + resolveAccount: ({ cfg, accountId }) => + accountId + ? (cfg.channels?.signal?.accounts?.[accountId] ?? {}) + : (cfg.channels?.signal ?? {}), + normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean), + resolveDmAllowFrom: (account) => account.allowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: () => undefined, + }), +}; + const whatsappCommandTestPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ id: "whatsapp", @@ -507,6 +546,11 @@ function setMinimalChannelPluginRegistryForTests(): void { plugin: slackCommandTestPlugin, source: "test", }, + { + pluginId: "signal", + plugin: signalCommandTestPlugin, + source: "test", + }, { pluginId: "telegram", plugin: telegramCommandTestPlugin, @@ -867,6 +911,35 @@ describe("/approve command", () => { ); }); + it("accepts Signal /approve from configured approvers even when chat access is otherwise blocked", async () => { + const cfg = { + commands: { text: true }, + channels: { + signal: { + allowFrom: ["+15551230000"], + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "signal", + Surface: "signal", + SenderId: "+15551230000", + }); + params.command.isAuthorizedSender = false; + + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + it("does not treat implicit default approval auth as a bypass for unauthorized senders", async () => { const cfg = { commands: { text: true }, @@ -920,7 +993,7 @@ describe("/approve command", () => { expect(callGatewayMock).not.toHaveBeenCalled(); }); - it("accepts Telegram /approve from exec target recipients even when native approvals are disabled", async () => { + it("ignores Telegram /approve from exec target recipients when native approvals are disabled", async () => { const cfg = { commands: { text: true }, approvals: { @@ -947,13 +1020,8 @@ describe("/approve command", () => { const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); + expect(result.reply).toBeUndefined(); + expect(callGatewayMock).not.toHaveBeenCalled(); }); it("requires configured Discord approvers for exec approvals", async () => { @@ -1188,7 +1256,7 @@ describe("/approve command", () => { expectGatewayCalls: 2, }, { - name: "telegram approvals disabled", + name: "telegram disabled native delivery reports the channel-disabled message", cfg: createTelegramApproveCfg(null), commandBody: "/approve abc12345 allow-once", ctx: { diff --git a/src/channels/plugins/approvals.test.ts b/src/channels/plugins/approvals.test.ts new file mode 100644 index 00000000000..87ee8065177 --- /dev/null +++ b/src/channels/plugins/approvals.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveChannelApprovalAdapter, resolveChannelApprovalCapability } from "./approvals.js"; + +describe("resolveChannelApprovalCapability", () => { + it("falls back to legacy approval fields when approvalCapability is absent", () => { + const authorizeActorAction = vi.fn(); + const getActionAvailabilityState = vi.fn(); + const delivery = { hasConfiguredDmRoute: vi.fn() }; + + expect( + resolveChannelApprovalCapability({ + auth: { + authorizeActorAction, + getActionAvailabilityState, + }, + approvals: { + delivery, + }, + }), + ).toEqual({ + authorizeActorAction, + getActionAvailabilityState, + delivery, + render: undefined, + native: undefined, + }); + }); + + it("merges partial approvalCapability fields with legacy approval wiring", () => { + const capabilityAuth = vi.fn(); + const legacyAvailability = vi.fn(); + const legacyDelivery = { hasConfiguredDmRoute: vi.fn() }; + + expect( + resolveChannelApprovalCapability({ + approvalCapability: { + authorizeActorAction: capabilityAuth, + }, + auth: { + getActionAvailabilityState: legacyAvailability, + }, + approvals: { + delivery: legacyDelivery, + }, + }), + ).toEqual({ + authorizeActorAction: capabilityAuth, + getActionAvailabilityState: legacyAvailability, + delivery: legacyDelivery, + render: undefined, + native: undefined, + }); + }); +}); + +describe("resolveChannelApprovalAdapter", () => { + it("preserves legacy delivery surfaces when approvalCapability only defines auth", () => { + const delivery = { hasConfiguredDmRoute: vi.fn() }; + + expect( + resolveChannelApprovalAdapter({ + approvalCapability: { + authorizeActorAction: vi.fn(), + }, + approvals: { + delivery, + }, + }), + ).toEqual({ + delivery, + render: undefined, + native: undefined, + }); + }); +}); diff --git a/src/channels/plugins/approvals.ts b/src/channels/plugins/approvals.ts index 5081821ccbf..6c9c69841bc 100644 --- a/src/channels/plugins/approvals.ts +++ b/src/channels/plugins/approvals.ts @@ -1,7 +1,63 @@ -import type { ChannelApprovalAdapter, ChannelPlugin } from "./types.js"; +import type { ChannelApprovalAdapter, ChannelApprovalCapability, ChannelPlugin } from "./types.js"; + +function buildApprovalCapabilityFromLegacyPlugin( + plugin?: Pick | null, +): ChannelApprovalCapability | undefined { + const authorizeActorAction = plugin?.auth?.authorizeActorAction; + const getActionAvailabilityState = plugin?.auth?.getActionAvailabilityState; + const approvals = plugin?.approvals; + if ( + !authorizeActorAction && + !getActionAvailabilityState && + !approvals?.delivery && + !approvals?.render && + !approvals?.native + ) { + return undefined; + } + return { + authorizeActorAction, + getActionAvailabilityState, + delivery: approvals?.delivery, + render: approvals?.render, + native: approvals?.native, + }; +} + +export function resolveChannelApprovalCapability( + plugin?: Pick | null, +): ChannelApprovalCapability | undefined { + const capability = plugin?.approvalCapability; + const legacyCapability = buildApprovalCapabilityFromLegacyPlugin(plugin); + if (!capability) { + return legacyCapability; + } + if (!legacyCapability) { + return capability; + } + return { + authorizeActorAction: capability.authorizeActorAction ?? legacyCapability.authorizeActorAction, + getActionAvailabilityState: + capability.getActionAvailabilityState ?? legacyCapability.getActionAvailabilityState, + delivery: capability.delivery ?? legacyCapability.delivery, + render: capability.render ?? legacyCapability.render, + native: capability.native ?? legacyCapability.native, + }; +} export function resolveChannelApprovalAdapter( - plugin?: Pick | null, + plugin?: Pick | null, ): ChannelApprovalAdapter | undefined { - return plugin?.approvals; + const capability = resolveChannelApprovalCapability(plugin); + if (!capability) { + return undefined; + } + if (!capability.delivery && !capability.render && !capability.native) { + return undefined; + } + return { + delivery: capability.delivery, + render: capability.render, + native: capability.native, + }; } diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index f423fd41c29..b4ce1e62517 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -16,4 +16,4 @@ export { type AllowlistMatchSource, } from "./allowlist-match.js"; export type { ChannelId, ChannelPlugin } from "./types.js"; -export { resolveChannelApprovalAdapter } from "./approvals.js"; +export { resolveChannelApprovalAdapter, resolveChannelApprovalCapability } from "./approvals.js"; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 191c1c54cd7..20a0dccdb2f 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -588,6 +588,11 @@ export type ChannelApprovalRenderAdapter = { }; }; +export type ChannelApprovalCapability = ChannelApprovalAdapter & { + authorizeActorAction?: ChannelAuthAdapter["authorizeActorAction"]; + getActionAvailabilityState?: ChannelAuthAdapter["getActionAvailabilityState"]; +}; + export type ChannelApprovalAdapter = { delivery?: ChannelApprovalDeliveryAdapter; render?: ChannelApprovalRenderAdapter; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index e2364b22ec8..ce208578f95 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -1,6 +1,7 @@ import type { ChannelSetupWizard } from "./setup-wizard.js"; import type { ChannelApprovalAdapter, + ChannelApprovalCapability, ChannelAuthAdapter, ChannelCommandAdapter, ChannelConfigAdapter, @@ -97,6 +98,7 @@ export type ChannelPlugin; auth?: ChannelAuthAdapter; + approvalCapability?: ChannelApprovalCapability; elevated?: ChannelElevatedAdapter; commands?: ChannelCommandAdapter; lifecycle?: ChannelLifecycleAdapter; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index 8e1202320e4..18566655175 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -9,6 +9,7 @@ export type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelActionAvailabilityState, ChannelApprovalAdapter, + ChannelApprovalCapability, ChannelApprovalForwardTarget, ChannelApprovalInitiatingSurfaceState, ChannelAuthAdapter, diff --git a/src/infra/approval-native-runtime.test.ts b/src/infra/approval-native-runtime.test.ts index b9d0cb32e4a..2d644aafc74 100644 --- a/src/infra/approval-native-runtime.test.ts +++ b/src/infra/approval-native-runtime.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js"; -import { deliverApprovalRequestViaChannelNativePlan } from "./approval-native-runtime.js"; +import { + createChannelNativeApprovalRuntime, + deliverApprovalRequestViaChannelNativePlan, +} from "./approval-native-runtime.js"; const execRequest = { id: "approval-1", @@ -102,3 +105,151 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => { expect(entries).toEqual([{ channelId: "approver-2" }]); }); }); + +describe("createChannelNativeApprovalRuntime", () => { + it("passes the resolved approval kind and pending content through native delivery hooks", async () => { + const describeDeliveryCapabilities = vi.fn().mockReturnValue({ + enabled: true, + preferredSurface: "approver-dm", + supportsOriginSurface: false, + supportsApproverDmSurface: true, + }); + const resolveApproverDmTargets = vi + .fn() + .mockImplementation(({ approvalKind, accountId }) => [ + { to: `${approvalKind}:${accountId}` }, + ]); + const buildPendingContent = vi.fn().mockResolvedValue("pending plugin"); + const prepareTarget = vi.fn().mockReturnValue({ + dedupeKey: "dm:plugin:secondary", + target: { chatId: "plugin:secondary" }, + }); + const deliverTarget = vi + .fn() + .mockResolvedValue({ chatId: "plugin:secondary", messageId: "m1" }); + const finalizeResolved = vi.fn().mockResolvedValue(undefined); + const runtime = createChannelNativeApprovalRuntime({ + label: "test/native-runtime", + clientDisplayName: "Test", + cfg: {} as never, + accountId: "secondary", + eventKinds: ["exec", "plugin"] as const, + nativeAdapter: { + describeDeliveryCapabilities, + resolveApproverDmTargets, + }, + isConfigured: () => true, + shouldHandle: () => true, + buildPendingContent, + prepareTarget, + deliverTarget, + finalizeResolved, + }); + + await runtime.handleRequested({ + id: "plugin:req-1", + request: { + title: "Plugin approval", + description: "Allow access", + }, + createdAtMs: 0, + expiresAtMs: 60_000, + }); + await runtime.handleResolved({ + id: "plugin:req-1", + decision: "allow-once", + ts: 1, + }); + + expect(buildPendingContent).toHaveBeenCalledWith({ + request: expect.objectContaining({ id: "plugin:req-1" }), + approvalKind: "plugin", + nowMs: expect.any(Number), + }); + expect(prepareTarget).toHaveBeenCalledWith({ + plannedTarget: { + surface: "approver-dm", + target: { to: "plugin:secondary" }, + reason: "preferred", + }, + request: expect.objectContaining({ id: "plugin:req-1" }), + approvalKind: "plugin", + pendingContent: "pending plugin", + }); + expect(deliverTarget).toHaveBeenCalledWith({ + plannedTarget: { + surface: "approver-dm", + target: { to: "plugin:secondary" }, + reason: "preferred", + }, + preparedTarget: { chatId: "plugin:secondary" }, + request: expect.objectContaining({ id: "plugin:req-1" }), + approvalKind: "plugin", + pendingContent: "pending plugin", + }); + expect(describeDeliveryCapabilities).toHaveBeenCalledWith({ + cfg: {} as never, + accountId: "secondary", + approvalKind: "plugin", + request: expect.objectContaining({ id: "plugin:req-1" }), + }); + expect(resolveApproverDmTargets).toHaveBeenCalledWith({ + cfg: {} as never, + accountId: "secondary", + approvalKind: "plugin", + request: expect.objectContaining({ id: "plugin:req-1" }), + }); + expect(finalizeResolved).toHaveBeenCalledWith({ + request: expect.objectContaining({ id: "plugin:req-1" }), + resolved: expect.objectContaining({ id: "plugin:req-1", decision: "allow-once" }), + entries: [{ chatId: "plugin:secondary", messageId: "m1" }], + }); + }); + + it("runs expiration through the shared runtime factory", async () => { + vi.useFakeTimers(); + const finalizeExpired = vi.fn().mockResolvedValue(undefined); + const runtime = createChannelNativeApprovalRuntime({ + label: "test/native-runtime-expiry", + clientDisplayName: "Test", + cfg: {} as never, + nowMs: Date.now, + nativeAdapter: { + describeDeliveryCapabilities: () => ({ + enabled: true, + preferredSurface: "approver-dm", + supportsOriginSurface: false, + supportsApproverDmSurface: true, + }), + resolveApproverDmTargets: async () => [{ to: "owner" }], + }, + isConfigured: () => true, + shouldHandle: () => true, + buildPendingContent: async () => "pending exec", + prepareTarget: async () => ({ + dedupeKey: "dm:owner", + target: { chatId: "owner" }, + }), + deliverTarget: async () => ({ chatId: "owner", messageId: "m1" }), + finalizeResolved: async () => {}, + finalizeExpired, + }); + + await runtime.handleRequested({ + id: "req-1", + request: { + command: "echo hi", + }, + createdAtMs: 0, + expiresAtMs: Date.now() + 60_000, + }); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(finalizeExpired).toHaveBeenCalledWith({ + request: expect.objectContaining({ id: "req-1" }), + entries: [{ chatId: "owner", messageId: "m1" }], + }); + vi.useRealTimers(); + }); +}); diff --git a/src/infra/approval-native-runtime.ts b/src/infra/approval-native-runtime.ts index 901a25ffdc6..2dea95fbe57 100644 --- a/src/infra/approval-native-runtime.ts +++ b/src/infra/approval-native-runtime.ts @@ -8,10 +8,18 @@ import { resolveChannelNativeApprovalDeliveryPlan, type ChannelApprovalNativePlannedTarget, } from "./approval-native-delivery.js"; +import { + createExecApprovalChannelRuntime, + type ExecApprovalChannelRuntime, + type ExecApprovalChannelRuntimeAdapter, +} from "./exec-approval-channel-runtime.js"; +import type { ExecApprovalResolved } from "./exec-approvals.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; +import type { PluginApprovalResolved } from "./plugin-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; export type PreparedChannelNativeApprovalTarget = { dedupeKey: string; @@ -152,3 +160,196 @@ export async function deliverApprovalRequestViaChannelNativePlan< return pendingEntries; } + +function defaultResolveApprovalKind(request: ApprovalRequest): ChannelApprovalKind { + return request.id.startsWith("plugin:") ? "plugin" : "exec"; +} + +type ChannelNativeApprovalRuntimeAdapter< + TPendingEntry, + TPreparedTarget, + TPendingContent, + TRequest extends ApprovalRequest = ApprovalRequest, + TResolved extends ApprovalResolved = ApprovalResolved, +> = Omit< + ExecApprovalChannelRuntimeAdapter, + "deliverRequested" +> & { + accountId?: string | null; + nativeAdapter?: ChannelApprovalNativeAdapter | null; + resolveApprovalKind?: (request: TRequest) => ChannelApprovalKind; + buildPendingContent: (params: { + request: TRequest; + approvalKind: ChannelApprovalKind; + nowMs: number; + }) => TPendingContent | Promise; + sendOriginNotice?: (params: { + originTarget: ChannelApprovalNativeTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => Promise; + prepareTarget: (params: { + plannedTarget: ChannelApprovalNativePlannedTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => + | PreparedChannelNativeApprovalTarget + | null + | Promise | null>; + deliverTarget: (params: { + plannedTarget: ChannelApprovalNativePlannedTarget; + preparedTarget: TPreparedTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => TPendingEntry | null | Promise; + onOriginNoticeError?: (params: { + error: unknown; + originTarget: ChannelApprovalNativeTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => void; + onDeliveryError?: (params: { + error: unknown; + plannedTarget: ChannelApprovalNativePlannedTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => void; + onDuplicateSkipped?: (params: { + plannedTarget: ChannelApprovalNativePlannedTarget; + preparedTarget: PreparedChannelNativeApprovalTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + }) => void; + onDelivered?: (params: { + plannedTarget: ChannelApprovalNativePlannedTarget; + preparedTarget: PreparedChannelNativeApprovalTarget; + request: TRequest; + approvalKind: ChannelApprovalKind; + pendingContent: TPendingContent; + entry: TPendingEntry; + }) => void; +}; + +export function createChannelNativeApprovalRuntime< + TPendingEntry, + TPreparedTarget, + TPendingContent, + TRequest extends ApprovalRequest = ApprovalRequest, + TResolved extends ApprovalResolved = ApprovalResolved, +>( + adapter: ChannelNativeApprovalRuntimeAdapter< + TPendingEntry, + TPreparedTarget, + TPendingContent, + TRequest, + TResolved + >, +): ExecApprovalChannelRuntime { + const nowMs = adapter.nowMs ?? Date.now; + const resolveApprovalKind = + adapter.resolveApprovalKind ?? ((request: TRequest) => defaultResolveApprovalKind(request)); + + return createExecApprovalChannelRuntime({ + label: adapter.label, + clientDisplayName: adapter.clientDisplayName, + cfg: adapter.cfg, + gatewayUrl: adapter.gatewayUrl, + eventKinds: adapter.eventKinds, + isConfigured: adapter.isConfigured, + shouldHandle: adapter.shouldHandle, + finalizeResolved: adapter.finalizeResolved, + finalizeExpired: adapter.finalizeExpired, + nowMs, + deliverRequested: async (request) => { + const approvalKind = resolveApprovalKind(request); + const pendingContent = await adapter.buildPendingContent({ + request, + approvalKind, + nowMs: nowMs(), + }); + return await deliverApprovalRequestViaChannelNativePlan({ + cfg: adapter.cfg, + accountId: adapter.accountId, + approvalKind, + request, + adapter: adapter.nativeAdapter, + sendOriginNotice: adapter.sendOriginNotice + ? async ({ originTarget, request }) => { + await adapter.sendOriginNotice?.({ + originTarget, + request, + approvalKind, + pendingContent, + }); + } + : undefined, + prepareTarget: async ({ plannedTarget, request }) => + await adapter.prepareTarget({ + plannedTarget, + request, + approvalKind, + pendingContent, + }), + deliverTarget: async ({ plannedTarget, preparedTarget, request }) => + await adapter.deliverTarget({ + plannedTarget, + preparedTarget, + request, + approvalKind, + pendingContent, + }), + onOriginNoticeError: adapter.onOriginNoticeError + ? ({ error, originTarget, request }) => { + adapter.onOriginNoticeError?.({ + error, + originTarget, + request, + approvalKind, + pendingContent, + }); + } + : undefined, + onDeliveryError: adapter.onDeliveryError + ? ({ error, plannedTarget, request }) => { + adapter.onDeliveryError?.({ + error, + plannedTarget, + request, + approvalKind, + pendingContent, + }); + } + : undefined, + onDuplicateSkipped: adapter.onDuplicateSkipped + ? ({ plannedTarget, preparedTarget, request }) => { + adapter.onDuplicateSkipped?.({ + plannedTarget, + preparedTarget, + request, + approvalKind, + pendingContent, + }); + } + : undefined, + onDelivered: adapter.onDelivered + ? ({ plannedTarget, preparedTarget, request, entry }) => { + adapter.onDelivered?.({ + plannedTarget, + preparedTarget, + request, + approvalKind, + pendingContent, + entry, + }); + } + : undefined, + }); + }, + }); +} diff --git a/src/infra/channel-approval-auth.test.ts b/src/infra/channel-approval-auth.test.ts index d6d3b9e1517..aa92f8cbf41 100644 --- a/src/infra/channel-approval-auth.test.ts +++ b/src/infra/channel-approval-auth.test.ts @@ -2,9 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getChannelPluginMock = vi.hoisted(() => vi.fn()); -vi.mock("../channels/plugins/index.js", () => ({ - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), -})); +vi.mock("../channels/plugins/index.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/index.js", + ); + return { + ...actual, + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + }; +}); import { resolveApprovalCommandAuthorization } from "./channel-approval-auth.js"; @@ -60,6 +66,27 @@ describe("resolveApprovalCommandAuthorization", () => { ).toEqual({ authorized: false, reason: "plugin denied", explicit: true }); }); + it("prefers approvalCapability over legacy auth wiring when present", () => { + getChannelPluginMock.mockReturnValue({ + auth: { + authorizeActorAction: () => ({ authorized: false, reason: "legacy denied" }), + }, + approvalCapability: { + authorizeActorAction: () => ({ authorized: true }), + getActionAvailabilityState: () => ({ kind: "enabled" }), + }, + }); + + expect( + resolveApprovalCommandAuthorization({ + cfg: {} as never, + channel: "matrix", + senderId: "123", + kind: "exec", + }), + ).toEqual({ authorized: true, explicit: true }); + }); + it("keeps disabled approval availability implicit even when same-chat auth returns allow", () => { getChannelPluginMock.mockReturnValue({ auth: { diff --git a/src/infra/channel-approval-auth.ts b/src/infra/channel-approval-auth.ts index e158dbc9a59..8fd1f48724a 100644 --- a/src/infra/channel-approval-auth.ts +++ b/src/infra/channel-approval-auth.ts @@ -1,4 +1,4 @@ -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { getChannelPlugin, resolveChannelApprovalCapability } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/config.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; @@ -19,8 +19,8 @@ export function resolveApprovalCommandAuthorization(params: { if (!channel) { return { authorized: true, explicit: false }; } - const channelPlugin = getChannelPlugin(channel); - const resolved = channelPlugin?.auth?.authorizeActorAction?.({ + const approvalCapability = resolveChannelApprovalCapability(getChannelPlugin(channel)); + const resolved = approvalCapability?.authorizeActorAction?.({ cfg: params.cfg, accountId: params.accountId, senderId: params.senderId, @@ -30,7 +30,7 @@ export function resolveApprovalCommandAuthorization(params: { if (!resolved) { return { authorized: true, explicit: false }; } - const availability = channelPlugin?.auth?.getActionAvailabilityState?.({ + const availability = approvalCapability?.getActionAvailabilityState?.({ cfg: params.cfg, accountId: params.accountId, action: "approve", diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index 67217d62578..fafbb05571c 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -14,10 +14,16 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../channels/plugins/index.js", () => ({ - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), -})); +vi.mock("../channels/plugins/index.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/index.js", + ); + return { + ...actual, + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), + }; +}); vi.mock("../utils/message-channel.js", () => ({ INTERNAL_MESSAGE_CHANNEL: "web", @@ -123,6 +129,26 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { expect(loadConfigMock).not.toHaveBeenCalled(); }); + it("reads approval availability from approvalCapability when auth is omitted", () => { + getChannelPluginMock.mockReturnValue({ + approvalCapability: { + getActionAvailabilityState: () => ({ kind: "disabled" }), + }, + }); + + expect( + resolveExecApprovalInitiatingSurfaceState({ + channel: "discord", + accountId: "main", + cfg: {} as never, + }), + ).toEqual({ + kind: "disabled", + channel: "discord", + channelLabel: "Discord", + }); + }); + it("loads config lazily when cfg is omitted and marks unsupported channels", () => { loadConfigMock.mockReturnValueOnce({ loaded: true }); getChannelPluginMock.mockImplementation((channel: string) => @@ -224,4 +250,35 @@ describe("hasConfiguredExecApprovalDmRoute", () => { listChannelPluginsMock.mockReturnValueOnce(plugins); expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(expected); }); + + it("detects DM routes exposed through approvalCapability", () => { + listChannelPluginsMock.mockReturnValueOnce([ + { + approvalCapability: { + delivery: { + hasConfiguredDmRoute: () => true, + }, + }, + }, + ]); + + expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true); + }); + + it("preserves legacy DM routes when approvalCapability only defines auth", () => { + listChannelPluginsMock.mockReturnValueOnce([ + { + approvalCapability: { + authorizeActorAction: () => ({ authorized: true }), + }, + approvals: { + delivery: { + hasConfiguredDmRoute: () => true, + }, + }, + }, + ]); + + expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true); + }); }); diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index f25d7493db3..aefe88c0ef3 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -1,4 +1,9 @@ -import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { + getChannelPlugin, + listChannelPlugins, + resolveChannelApprovalAdapter, + resolveChannelApprovalCapability, +} from "../channels/plugins/index.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -38,7 +43,9 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { } const cfg = params.cfg ?? loadConfig(); - const state = getChannelPlugin(channel)?.auth?.getActionAvailabilityState?.({ + const state = resolveChannelApprovalCapability( + getChannelPlugin(channel), + )?.getActionAvailabilityState?.({ cfg, accountId: params.accountId, action: "approve", @@ -54,6 +61,7 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return listChannelPlugins().some( - (plugin) => plugin.approvals?.delivery?.hasConfiguredDmRoute?.({ cfg }) ?? false, + (plugin) => + resolveChannelApprovalAdapter(plugin)?.delivery?.hasConfiguredDmRoute?.({ cfg }) ?? false, ); } diff --git a/src/plugin-sdk/approval-client-helpers.test.ts b/src/plugin-sdk/approval-client-helpers.test.ts new file mode 100644 index 00000000000..180de987a51 --- /dev/null +++ b/src/plugin-sdk/approval-client-helpers.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + createChannelExecApprovalProfile, + isChannelExecApprovalTargetRecipient, +} from "./approval-client-helpers.js"; +import type { OpenClawConfig } from "./config-runtime.js"; + +describe("isChannelExecApprovalTargetRecipient", () => { + it("matches targets by channel and account", () => { + const cfg: OpenClawConfig = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [ + { channel: "matrix", to: "user:@owner:example.org", accountId: "ops" }, + { channel: "matrix", to: "user:@other:example.org", accountId: "other" }, + ], + }, + }, + }; + + expect( + isChannelExecApprovalTargetRecipient({ + cfg, + senderId: "@owner:example.org", + accountId: "ops", + channel: "matrix", + matchTarget: ({ target, normalizedSenderId }) => target.to === `user:${normalizedSenderId}`, + }), + ).toBe(true); + + expect( + isChannelExecApprovalTargetRecipient({ + cfg, + senderId: "@owner:example.org", + accountId: "other", + channel: "matrix", + matchTarget: ({ target, normalizedSenderId }) => target.to === `user:${normalizedSenderId}`, + }), + ).toBe(false); + }); + + it("normalizes the requested channel id before matching targets", () => { + const cfg: OpenClawConfig = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "matrix", to: "user:@owner:example.org" }], + }, + }, + }; + + expect( + isChannelExecApprovalTargetRecipient({ + cfg, + senderId: "@owner:example.org", + channel: " Matrix ", + matchTarget: ({ target, normalizedSenderId }) => target.to === `user:${normalizedSenderId}`, + }), + ).toBe(true); + }); +}); + +describe("createChannelExecApprovalProfile", () => { + const profile = createChannelExecApprovalProfile({ + resolveConfig: () => ({ + enabled: true, + target: "channel", + agentFilter: ["ops"], + sessionFilter: ["tail$"], + }), + resolveApprovers: () => ["owner"], + isTargetRecipient: ({ senderId }) => senderId === "target", + matchesRequestAccount: ({ accountId }) => accountId !== "other", + }); + + it("reuses shared client, auth, and request-filter logic", () => { + expect(profile.isClientEnabled({ cfg: {} })).toBe(true); + expect(profile.isApprover({ cfg: {}, senderId: "owner" })).toBe(true); + expect(profile.isAuthorizedSender({ cfg: {}, senderId: "target" })).toBe(true); + expect(profile.resolveTarget({ cfg: {} })).toBe("channel"); + + expect( + profile.shouldHandleRequest({ + cfg: {}, + accountId: "ops", + request: { + id: "req-1", + request: { + command: "echo hi", + agentId: "ops", + sessionKey: "agent:ops:telegram:direct:owner:tail", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toBe(true); + + expect( + profile.shouldHandleRequest({ + cfg: {}, + accountId: "other", + request: { + id: "req-1", + request: { + command: "echo hi", + agentId: "ops", + sessionKey: "agent:ops:telegram:direct:owner:tail", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toBe(false); + }); + + it("supports local prompt suppression without requiring the client to be enabled", () => { + const promptProfile = createChannelExecApprovalProfile({ + resolveConfig: () => undefined, + resolveApprovers: () => [], + requireClientEnabledForLocalPromptSuppression: false, + }); + + expect( + promptProfile.shouldSuppressLocalPrompt({ + cfg: {}, + payload: { + channelData: { + execApproval: { + approvalId: "req-1", + approvalSlug: "req-1", + }, + }, + }, + }), + ).toBe(true); + }); +}); diff --git a/src/plugin-sdk/approval-client-helpers.ts b/src/plugin-sdk/approval-client-helpers.ts new file mode 100644 index 00000000000..3dec937723d --- /dev/null +++ b/src/plugin-sdk/approval-client-helpers.ts @@ -0,0 +1,153 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { ExecApprovalForwardTarget } from "../config/types.approvals.js"; +import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js"; +import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import type { ExecApprovalRequest } from "../infra/exec-approvals.js"; +import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; +import type { OpenClawConfig } from "./config-runtime.js"; +import { normalizeAccountId } from "./routing.js"; + +type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +type ApprovalTarget = "dm" | "channel" | "both"; + +type ChannelApprovalConfig = { + enabled?: boolean; + target?: ApprovalTarget; + agentFilter?: string[]; + sessionFilter?: string[]; +}; + +type ApprovalProfileParams = { + cfg: OpenClawConfig; + accountId?: string | null; +}; + +function defaultNormalizeSenderId(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed || undefined; +} + +function isApprovalTargetsMode(cfg: OpenClawConfig): boolean { + const execApprovals = cfg.approvals?.exec; + if (!execApprovals?.enabled) { + return false; + } + return execApprovals.mode === "targets" || execApprovals.mode === "both"; +} + +export function isChannelExecApprovalTargetRecipient(params: { + cfg: OpenClawConfig; + senderId?: string | null; + accountId?: string | null; + channel: string; + normalizeSenderId?: (value: string) => string | undefined; + matchTarget: (params: { + target: ExecApprovalForwardTarget; + normalizedSenderId: string; + normalizedAccountId?: string; + }) => boolean; +}): boolean { + const normalizeSenderId = params.normalizeSenderId ?? defaultNormalizeSenderId; + const normalizedSenderId = params.senderId ? normalizeSenderId(params.senderId) : undefined; + const normalizedChannel = params.channel.trim().toLowerCase(); + if (!normalizedSenderId || !isApprovalTargetsMode(params.cfg)) { + return false; + } + const targets = params.cfg.approvals?.exec?.targets; + if (!targets) { + return false; + } + const normalizedAccountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; + return targets.some((target) => { + if (target.channel?.trim().toLowerCase() !== normalizedChannel) { + return false; + } + if ( + normalizedAccountId && + target.accountId && + normalizeAccountId(target.accountId) !== normalizedAccountId + ) { + return false; + } + return params.matchTarget({ + target, + normalizedSenderId, + normalizedAccountId, + }); + }); +} + +export function createChannelExecApprovalProfile(params: { + resolveConfig: (params: ApprovalProfileParams) => ChannelApprovalConfig | undefined; + resolveApprovers: (params: ApprovalProfileParams) => string[]; + normalizeSenderId?: (value: string) => string | undefined; + isTargetRecipient?: (params: ApprovalProfileParams & { senderId?: string | null }) => boolean; + matchesRequestAccount?: (params: ApprovalProfileParams & { request: ApprovalRequest }) => boolean; + // Some channels encode the effective agent only in sessionKey for forwarded approvals. + fallbackAgentIdFromSessionKey?: boolean; + requireClientEnabledForLocalPromptSuppression?: boolean; +}) { + const normalizeSenderId = params.normalizeSenderId ?? defaultNormalizeSenderId; + + const isClientEnabled = (input: ApprovalProfileParams): boolean => { + const config = params.resolveConfig(input); + return Boolean(config?.enabled && params.resolveApprovers(input).length > 0); + }; + + const isApprover = (input: ApprovalProfileParams & { senderId?: string | null }): boolean => { + const normalizedSenderId = input.senderId ? normalizeSenderId(input.senderId) : undefined; + if (!normalizedSenderId) { + return false; + } + return params.resolveApprovers(input).includes(normalizedSenderId); + }; + + const isAuthorizedSender = ( + input: ApprovalProfileParams & { senderId?: string | null }, + ): boolean => { + return isApprover(input) || (params.isTargetRecipient?.(input) ?? false); + }; + + const resolveTarget = (input: ApprovalProfileParams): ApprovalTarget => { + return params.resolveConfig(input)?.target ?? "dm"; + }; + + const shouldHandleRequest = ( + input: ApprovalProfileParams & { request: ApprovalRequest }, + ): boolean => { + if (params.matchesRequestAccount && !params.matchesRequestAccount(input)) { + return false; + } + const config = params.resolveConfig(input); + if (!config?.enabled) { + return false; + } + if (params.resolveApprovers(input).length === 0) { + return false; + } + return matchesApprovalRequestFilters({ + request: input.request.request, + agentFilter: config.agentFilter, + sessionFilter: config.sessionFilter, + fallbackAgentIdFromSessionKey: params.fallbackAgentIdFromSessionKey === true, + }); + }; + + const shouldSuppressLocalPrompt = ( + input: ApprovalProfileParams & { payload: ReplyPayload }, + ): boolean => { + if (params.requireClientEnabledForLocalPromptSuppression !== false && !isClientEnabled(input)) { + return false; + } + return getExecApprovalReplyMetadata(input.payload) !== null; + }; + + return { + isClientEnabled, + isApprover, + isAuthorizedSender, + resolveTarget, + shouldHandleRequest, + shouldSuppressLocalPrompt, + }; +} diff --git a/src/plugin-sdk/approval-delivery-helpers.test.ts b/src/plugin-sdk/approval-delivery-helpers.test.ts index 7687b0a8a89..47e0abd8c08 100644 --- a/src/plugin-sdk/approval-delivery-helpers.test.ts +++ b/src/plugin-sdk/approval-delivery-helpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { createApproverRestrictedNativeApprovalAdapter } from "./approval-delivery-helpers.js"; +import { + createApproverRestrictedNativeApprovalAdapter, + createApproverRestrictedNativeApprovalCapability, + splitChannelApprovalCapability, +} from "./approval-delivery-helpers.js"; describe("createApproverRestrictedNativeApprovalAdapter", () => { it("uses approver-restricted authorization for exec and plugin commands", () => { @@ -14,6 +18,9 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { resolveNativeDeliveryMode: () => "dm", }); const authorizeActorAction = adapter.auth.authorizeActorAction; + if (!authorizeActorAction) { + throw new Error("approval auth unavailable"); + } expect( authorizeActorAction({ @@ -63,7 +70,10 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { resolveApproverDmTargets: () => [{ to: "approver-1" }], }); const getActionAvailabilityState = adapter.auth.getActionAvailabilityState; - const hasConfiguredDmRoute = adapter.delivery.hasConfiguredDmRoute; + const hasConfiguredDmRoute = adapter.delivery; + if (!getActionAvailabilityState || !hasConfiguredDmRoute?.hasConfiguredDmRoute) { + throw new Error("approval availability helpers unavailable"); + } const nativeCapabilities = adapter.native?.describeDeliveryCapabilities({ cfg: {} as never, accountId: "channel-only", @@ -97,7 +107,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { action: "approve", }), ).toEqual({ kind: "disabled" }); - expect(hasConfiguredDmRoute({ cfg: {} as never })).toBe(true); + expect(hasConfiguredDmRoute.hasConfiguredDmRoute({ cfg: {} as never })).toBe(true); expect(nativeCapabilities).toEqual({ enabled: true, preferredSurface: "origin", @@ -123,14 +133,21 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { resolveSuppressionAccountId: ({ request }) => request.request.turnSourceAccountId?.trim() || undefined, }); - const shouldSuppressForwardingFallback = adapter.delivery.shouldSuppressForwardingFallback; + const shouldSuppressForwardingFallback = adapter.delivery?.shouldSuppressForwardingFallback; + if (!shouldSuppressForwardingFallback) { + throw new Error("delivery suppression helper unavailable"); + } expect( shouldSuppressForwardingFallback({ cfg: {} as never, - target: { channel: "telegram" }, + target: { channel: "telegram", to: "target-1" }, request: { - request: { turnSourceChannel: "telegram", turnSourceAccountId: " topic-1 " }, + request: { + command: "pwd", + turnSourceChannel: "telegram", + turnSourceAccountId: " topic-1 ", + }, } as never, }), ).toBe(true); @@ -138,9 +155,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { expect( shouldSuppressForwardingFallback({ cfg: {} as never, - target: { channel: "telegram" }, + target: { channel: "telegram", to: "target-1" }, request: { - request: { turnSourceChannel: "slack", turnSourceAccountId: "topic-1" }, + request: { + command: "pwd", + turnSourceChannel: "slack", + turnSourceAccountId: "topic-1", + }, } as never, }), ).toBe(false); @@ -148,9 +169,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { expect( shouldSuppressForwardingFallback({ cfg: {} as never, - target: { channel: "slack" }, + target: { channel: "slack", to: "target-1" }, request: { - request: { turnSourceChannel: "telegram", turnSourceAccountId: "topic-1" }, + request: { + command: "pwd", + turnSourceChannel: "telegram", + turnSourceAccountId: "topic-1", + }, } as never, }), ).toBe(false); @@ -161,3 +186,105 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => { }); }); }); + +describe("createApproverRestrictedNativeApprovalCapability", () => { + it("builds the canonical approval capability and preserves legacy split compatibility", () => { + const capability = createApproverRestrictedNativeApprovalCapability({ + channel: "matrix", + channelLabel: "Matrix", + listAccountIds: () => ["work"], + hasApprovers: () => true, + isExecAuthorizedSender: ({ senderId }) => senderId === "@owner:example.com", + isNativeDeliveryEnabled: () => true, + resolveNativeDeliveryMode: () => "dm", + resolveApproverDmTargets: () => [{ to: "user:@owner:example.com" }], + }); + + expect( + capability.authorizeActorAction?.({ + cfg: {} as never, + accountId: "work", + senderId: "@owner:example.com", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + expect(capability.delivery?.hasConfiguredDmRoute?.({ cfg: {} as never })).toBe(true); + expect( + capability.native?.describeDeliveryCapabilities({ + cfg: {} as never, + accountId: "work", + approvalKind: "exec", + request: { + id: "approval-1", + request: { command: "pwd" }, + createdAtMs: 0, + expiresAtMs: 10_000, + }, + }), + ).toEqual({ + enabled: true, + preferredSurface: "approver-dm", + supportsOriginSurface: false, + supportsApproverDmSurface: true, + notifyOriginWhenDmOnly: false, + }); + + const split = splitChannelApprovalCapability(capability); + const legacy = createApproverRestrictedNativeApprovalAdapter({ + channel: "matrix", + channelLabel: "Matrix", + listAccountIds: () => ["work"], + hasApprovers: () => true, + isExecAuthorizedSender: ({ senderId }) => senderId === "@owner:example.com", + isNativeDeliveryEnabled: () => true, + resolveNativeDeliveryMode: () => "dm", + resolveApproverDmTargets: () => [{ to: "user:@owner:example.com" }], + }); + expect(split.delivery?.hasConfiguredDmRoute?.({ cfg: {} as never })).toBe( + legacy.delivery?.hasConfiguredDmRoute?.({ cfg: {} as never }), + ); + expect( + split.native?.describeDeliveryCapabilities({ + cfg: {} as never, + accountId: "work", + approvalKind: "exec", + request: { + id: "approval-1", + request: { command: "pwd" }, + createdAtMs: 0, + expiresAtMs: 10_000, + }, + }), + ).toEqual( + legacy.native?.describeDeliveryCapabilities({ + cfg: {} as never, + accountId: "work", + approvalKind: "exec", + request: { + id: "approval-1", + request: { command: "pwd" }, + createdAtMs: 0, + expiresAtMs: 10_000, + }, + }), + ); + expect( + split.auth.authorizeActorAction?.({ + cfg: {} as never, + accountId: "work", + senderId: "@owner:example.com", + action: "approve", + approvalKind: "exec", + }), + ).toEqual( + legacy.auth.authorizeActorAction?.({ + cfg: {} as never, + accountId: "work", + senderId: "@owner:example.com", + action: "approve", + approvalKind: "exec", + }), + ); + }); +}); diff --git a/src/plugin-sdk/approval-delivery-helpers.ts b/src/plugin-sdk/approval-delivery-helpers.ts index 37c03eefed2..3134dd60971 100644 --- a/src/plugin-sdk/approval-delivery-helpers.ts +++ b/src/plugin-sdk/approval-delivery-helpers.ts @@ -1,5 +1,6 @@ import type { ExecApprovalRequest } from "../infra/exec-approvals.js"; import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; +import type { ChannelApprovalCapability } from "./channel-contract.js"; import type { OpenClawConfig } from "./config-runtime.js"; import { normalizeMessageChannel } from "./routing.js"; @@ -21,7 +22,7 @@ type DeliverySuppressionParams = { request: { request: { turnSourceChannel?: string | null; turnSourceAccountId?: string | null } }; }; -export function createApproverRestrictedNativeApprovalAdapter(params: { +type ApproverRestrictedNativeApprovalParams = { channel: string; channelLabel: string; listAccountIds: (cfg: OpenClawConfig) => string[]; @@ -48,109 +49,158 @@ export function createApproverRestrictedNativeApprovalAdapter(params: { request: NativeApprovalRequest; }) => NativeApprovalTarget[] | Promise; notifyOriginWhenDmOnly?: boolean; -}) { +}; + +function buildApproverRestrictedNativeApprovalCapability( + params: ApproverRestrictedNativeApprovalParams, +): ChannelApprovalCapability { const pluginSenderAuth = params.isPluginAuthorizedSender ?? params.isExecAuthorizedSender; const normalizePreferredSurface = ( mode: NativeApprovalDeliveryMode, ): NativeApprovalSurface | "both" => mode === "channel" ? "origin" : mode === "dm" ? "approver-dm" : "both"; + return createChannelApprovalCapability({ + authorizeActorAction: ({ + cfg, + accountId, + senderId, + approvalKind, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; + action: "approve"; + approvalKind: ApprovalKind; + }) => { + const authorized = + approvalKind === "plugin" + ? pluginSenderAuth({ cfg, accountId, senderId }) + : params.isExecAuthorizedSender({ cfg, accountId, senderId }); + return authorized + ? { authorized: true } + : { + authorized: false, + reason: `❌ You are not authorized to approve ${approvalKind} requests on ${params.channelLabel}.`, + }; + }, + getActionAvailabilityState: ({ + cfg, + accountId, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + action: "approve"; + }) => + params.hasApprovers({ cfg, accountId }) && params.isNativeDeliveryEnabled({ cfg, accountId }) + ? ({ kind: "enabled" } as const) + : ({ kind: "disabled" } as const), + approvals: { + delivery: { + hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) => + params.listAccountIds(cfg).some((accountId) => { + if (!params.hasApprovers({ cfg, accountId })) { + return false; + } + if (!params.isNativeDeliveryEnabled({ cfg, accountId })) { + return false; + } + const target = params.resolveNativeDeliveryMode({ cfg, accountId }); + return target === "dm" || target === "both"; + }), + shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => { + const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel; + if (channel !== params.channel) { + return false; + } + if (params.requireMatchingTurnSourceChannel) { + const turnSourceChannel = normalizeMessageChannel( + input.request.request.turnSourceChannel, + ); + if (turnSourceChannel !== params.channel) { + return false; + } + } + const resolvedAccountId = params.resolveSuppressionAccountId?.(input); + const accountId = + (resolvedAccountId === undefined + ? input.target.accountId?.trim() + : resolvedAccountId.trim()) || undefined; + return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId }); + }, + }, + native: + params.resolveOriginTarget || params.resolveApproverDmTargets + ? { + describeDeliveryCapabilities: ({ + cfg, + accountId, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + approvalKind: ApprovalKind; + request: NativeApprovalRequest; + }) => ({ + enabled: + params.hasApprovers({ cfg, accountId }) && + params.isNativeDeliveryEnabled({ cfg, accountId }), + preferredSurface: normalizePreferredSurface( + params.resolveNativeDeliveryMode({ cfg, accountId }), + ), + supportsOriginSurface: Boolean(params.resolveOriginTarget), + supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets), + notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false, + }), + resolveOriginTarget: params.resolveOriginTarget, + resolveApproverDmTargets: params.resolveApproverDmTargets, + } + : undefined, + }, + }); +} + +export function createApproverRestrictedNativeApprovalAdapter( + params: ApproverRestrictedNativeApprovalParams, +) { + return splitChannelApprovalCapability(buildApproverRestrictedNativeApprovalCapability(params)); +} + +export function createChannelApprovalCapability(params: { + authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"]; + getActionAvailabilityState?: ChannelApprovalCapability["getActionAvailabilityState"]; + approvals?: Pick; +}): ChannelApprovalCapability { return { - auth: { - authorizeActorAction: ({ - cfg, - accountId, - senderId, - approvalKind, - }: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; - action: "approve"; - approvalKind: ApprovalKind; - }) => { - const authorized = - approvalKind === "plugin" - ? pluginSenderAuth({ cfg, accountId, senderId }) - : params.isExecAuthorizedSender({ cfg, accountId, senderId }); - return authorized - ? { authorized: true } - : { - authorized: false, - reason: `❌ You are not authorized to approve ${approvalKind} requests on ${params.channelLabel}.`, - }; - }, - getActionAvailabilityState: ({ - cfg, - accountId, - }: { - cfg: OpenClawConfig; - accountId?: string | null; - action: "approve"; - }) => - params.hasApprovers({ cfg, accountId }) && - params.isNativeDeliveryEnabled({ cfg, accountId }) - ? ({ kind: "enabled" } as const) - : ({ kind: "disabled" } as const), - }, - delivery: { - hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) => - params.listAccountIds(cfg).some((accountId) => { - if (!params.hasApprovers({ cfg, accountId })) { - return false; - } - if (!params.isNativeDeliveryEnabled({ cfg, accountId })) { - return false; - } - const target = params.resolveNativeDeliveryMode({ cfg, accountId }); - return target === "dm" || target === "both"; - }), - shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => { - const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel; - if (channel !== params.channel) { - return false; - } - if (params.requireMatchingTurnSourceChannel) { - const turnSourceChannel = normalizeMessageChannel( - input.request.request.turnSourceChannel, - ); - if (turnSourceChannel !== params.channel) { - return false; - } - } - const resolvedAccountId = params.resolveSuppressionAccountId?.(input); - const accountId = - (resolvedAccountId === undefined - ? input.target.accountId?.trim() - : resolvedAccountId.trim()) || undefined; - return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId }); - }, - }, - native: - params.resolveOriginTarget || params.resolveApproverDmTargets - ? { - describeDeliveryCapabilities: ({ - cfg, - accountId, - }: { - cfg: OpenClawConfig; - accountId?: string | null; - approvalKind: ApprovalKind; - request: NativeApprovalRequest; - }) => ({ - enabled: - params.hasApprovers({ cfg, accountId }) && - params.isNativeDeliveryEnabled({ cfg, accountId }), - preferredSurface: normalizePreferredSurface( - params.resolveNativeDeliveryMode({ cfg, accountId }), - ), - supportsOriginSurface: Boolean(params.resolveOriginTarget), - supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets), - notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false, - }), - resolveOriginTarget: params.resolveOriginTarget, - resolveApproverDmTargets: params.resolveApproverDmTargets, - } - : undefined, + authorizeActorAction: params.authorizeActorAction, + getActionAvailabilityState: params.getActionAvailabilityState, + delivery: params.approvals?.delivery, + render: params.approvals?.render, + native: params.approvals?.native, }; } + +export function splitChannelApprovalCapability(capability: ChannelApprovalCapability): { + auth: { + authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"]; + getActionAvailabilityState?: ChannelApprovalCapability["getActionAvailabilityState"]; + }; + delivery: ChannelApprovalCapability["delivery"]; + render: ChannelApprovalCapability["render"]; + native: ChannelApprovalCapability["native"]; +} { + return { + auth: { + authorizeActorAction: capability.authorizeActorAction, + getActionAvailabilityState: capability.getActionAvailabilityState, + }, + delivery: capability.delivery, + render: capability.render, + native: capability.native, + }; +} + +export function createApproverRestrictedNativeApprovalCapability( + params: ApproverRestrictedNativeApprovalParams, +): ChannelApprovalCapability { + return buildApproverRestrictedNativeApprovalCapability(params); +} diff --git a/src/plugin-sdk/approval-native-helpers.test.ts b/src/plugin-sdk/approval-native-helpers.test.ts new file mode 100644 index 00000000000..234e70a8001 --- /dev/null +++ b/src/plugin-sdk/approval-native-helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + createChannelApproverDmTargetResolver, + createChannelNativeOriginTargetResolver, +} from "./approval-native-helpers.js"; +import type { OpenClawConfig } from "./config-runtime.js"; + +describe("createChannelNativeOriginTargetResolver", () => { + it("reuses shared turn-source routing and respects shouldHandle gating", () => { + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "matrix", + shouldHandleRequest: ({ accountId }) => accountId === "ops", + resolveTurnSourceTarget: (request) => ({ + to: String(request.request.turnSourceTo), + threadId: request.request.turnSourceThreadId ?? undefined, + }), + resolveSessionTarget: (sessionTarget) => ({ + to: sessionTarget.to, + threadId: sessionTarget.threadId, + }), + targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId, + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + accountId: "ops", + request: { + id: "plugin:req-1", + request: { + title: "Plugin approval", + description: "Allow access", + turnSourceChannel: "matrix", + turnSourceTo: "room:!room:example.org", + turnSourceThreadId: "t1", + turnSourceAccountId: "ops", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ + to: "room:!room:example.org", + threadId: "t1", + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + accountId: "other", + request: { + id: "plugin:req-1", + request: { + title: "Plugin approval", + description: "Allow access", + turnSourceChannel: "matrix", + turnSourceTo: "room:!room:example.org", + turnSourceThreadId: "t1", + turnSourceAccountId: "ops", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toBeNull(); + }); +}); + +describe("createChannelApproverDmTargetResolver", () => { + it("filters null targets and skips delivery when shouldHandle rejects the request", () => { + const resolveApproverDmTargets = createChannelApproverDmTargetResolver({ + shouldHandleRequest: ({ approvalKind }) => approvalKind === "exec", + resolveApprovers: () => ["owner-1", "owner-2", "skip-me"], + mapApprover: (approver) => + approver === "skip-me" + ? null + : { + to: `user:${approver}`, + }, + }); + + expect( + resolveApproverDmTargets({ + cfg: {}, + accountId: "default", + approvalKind: "exec", + request: { + id: "req-1", + request: { command: "echo hi" }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual([{ to: "user:owner-1" }, { to: "user:owner-2" }]); + + expect( + resolveApproverDmTargets({ + cfg: {}, + accountId: "default", + approvalKind: "plugin", + request: { + id: "plugin:req-1", + request: { title: "Plugin approval", description: "Allow access" }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual([]); + }); +}); diff --git a/src/plugin-sdk/approval-native-helpers.ts b/src/plugin-sdk/approval-native-helpers.ts new file mode 100644 index 00000000000..1dc11b6e8df --- /dev/null +++ b/src/plugin-sdk/approval-native-helpers.ts @@ -0,0 +1,78 @@ +import type { ExecApprovalSessionTarget } from "../infra/exec-approval-session-target.js"; +import { resolveApprovalRequestOriginTarget } from "../infra/exec-approval-session-target.js"; +import type { ExecApprovalRequest } from "../infra/exec-approvals.js"; +import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; +import type { OpenClawConfig } from "./config-runtime.js"; + +type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +type ApprovalKind = "exec" | "plugin"; + +type ApprovalResolverParams = { + cfg: OpenClawConfig; + accountId?: string | null; + approvalKind?: ApprovalKind; + request: ApprovalRequest; +}; + +type NativeApprovalTarget = { + to: string; + threadId?: string | number | null; +}; + +export function createChannelNativeOriginTargetResolver(params: { + channel: string; + shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; + resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null; + resolveSessionTarget: ( + sessionTarget: ExecApprovalSessionTarget, + request: ApprovalRequest, + ) => TTarget | null; + targetsMatch: (a: TTarget, b: TTarget) => boolean; + resolveFallbackTarget?: (request: ApprovalRequest) => TTarget | null; +}) { + return (input: ApprovalResolverParams): TTarget | null => { + if (params.shouldHandleRequest && !params.shouldHandleRequest(input)) { + return null; + } + return resolveApprovalRequestOriginTarget({ + cfg: input.cfg, + request: input.request, + channel: params.channel, + accountId: input.accountId, + resolveTurnSourceTarget: params.resolveTurnSourceTarget, + resolveSessionTarget: (sessionTarget) => + params.resolveSessionTarget(sessionTarget, input.request), + targetsMatch: params.targetsMatch, + resolveFallbackTarget: params.resolveFallbackTarget, + }); + }; +} + +export function createChannelApproverDmTargetResolver< + TApprover, + TTarget extends NativeApprovalTarget = NativeApprovalTarget, +>(params: { + shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; + resolveApprovers: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => readonly TApprover[]; + mapApprover: (approver: TApprover, params: ApprovalResolverParams) => TTarget | null | undefined; +}) { + return (input: ApprovalResolverParams): TTarget[] => { + if (params.shouldHandleRequest && !params.shouldHandleRequest(input)) { + return []; + } + const targets: TTarget[] = []; + for (const approver of params.resolveApprovers({ + cfg: input.cfg, + accountId: input.accountId, + })) { + const target = params.mapApprover(approver, input); + if (target) { + targets.push(target); + } + } + return targets; + }; +} diff --git a/src/plugin-sdk/approval-runtime.ts b/src/plugin-sdk/approval-runtime.ts index 07500224172..9cc5997969b 100644 --- a/src/plugin-sdk/approval-runtime.ts +++ b/src/plugin-sdk/approval-runtime.ts @@ -36,7 +36,21 @@ export { type PluginApprovalResolved, } from "../infra/plugin-approvals.js"; export { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js"; -export { createApproverRestrictedNativeApprovalAdapter } from "./approval-delivery-helpers.js"; +export { + createChannelExecApprovalProfile, + isChannelExecApprovalTargetRecipient, +} from "./approval-client-helpers.js"; +export { + createChannelApproverDmTargetResolver, + createChannelNativeOriginTargetResolver, +} from "./approval-native-helpers.js"; +export { createChannelNativeApprovalRuntime } from "../infra/approval-native-runtime.js"; +export { + createApproverRestrictedNativeApprovalAdapter, + createApproverRestrictedNativeApprovalCapability, + createChannelApprovalCapability, + splitChannelApprovalCapability, +} from "./approval-delivery-helpers.js"; export { resolveApprovalApprovers } from "./approval-approvers.js"; export { matchesApprovalRequestFilters, diff --git a/src/plugin-sdk/channel-contract.ts b/src/plugin-sdk/channel-contract.ts index 40f0f4be85e..8e668d2cb6f 100644 --- a/src/plugin-sdk/channel-contract.ts +++ b/src/plugin-sdk/channel-contract.ts @@ -4,6 +4,8 @@ export type { BaseTokenResolution, ChannelAgentTool, ChannelAccountSnapshot, + ChannelApprovalAdapter, + ChannelApprovalCapability, ChannelCommandConversationContext, ChannelGroupContext, ChannelMessageActionAdapter,