Refactor channel approval capability seams (#58634)

Merged via squash.

Prepared head SHA: c9ad4e4706
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-01 17:10:25 -04:00
committed by GitHub
parent d9a7ffe003
commit c87c8e66bf
48 changed files with 2214 additions and 861 deletions

View File

@@ -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

View File

@@ -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"
}
},

View File

@@ -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<ResolvedAccount>;","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<ResolvedAccount, Probe, Audit>;","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<ResolvedAccount, Probe, Audit>;","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<ResolvedAccount>;","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<ResolvedAccount, Probe, Audit>;","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<ResolvedAccount, Probe, Audit>;","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<ResolvedAccount>;","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<ResolvedAccount>;","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<ResolvedAccount>;","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<ResolvedAccount, Probe, Audit>;","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<ResolvedAccount, Probe, Audit>;","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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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.

View File

@@ -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<string, unknown>) {
}
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();

View File

@@ -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);

View File

@@ -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<ResolvedDiscordAccount, DiscordProbe>
hint: "<channelId|user:ID|channel:ID>",
},
},
auth: discordNativeApprovalAdapter.auth,
approvals: {
delivery: discordNativeApprovalAdapter.delivery,
native: discordNativeApprovalAdapter.native,
},
approvalCapability: discordApprovalCapability,
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),

View File

@@ -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<typeof stripUndefinedFields>;
};
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<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
private async deliverRequested(request: ApprovalRequest): Promise<PendingApproval[]> {
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<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<ResolvedSlackAccount, SlackProbe> = crea
}),
resolveNames: resolveSlackAllowlistNames,
},
auth: slackNativeApprovalAdapter.auth,
approvals: {
delivery: slackNativeApprovalAdapter.delivery,
native: slackNativeApprovalAdapter.native,
},
approvalCapability: slackApprovalCapability,
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,

View File

@@ -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;

View File

@@ -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<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"]
@@ -219,11 +222,19 @@ export class SlackExecApprovalHandler {
constructor(opts: SlackExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createExecApprovalChannelRuntime<SlackPendingApproval>({
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<void> {
@@ -271,57 +331,6 @@ export class SlackExecApprovalHandler {
await this.runtime.handleExpired(approvalId);
}
private async deliverRequested(request: ExecApprovalRequest): Promise<SlackPendingApproval[]> {
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,

View File

@@ -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,
);

View File

@@ -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();

View File

@@ -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 }) =>

View File

@@ -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: {

View File

@@ -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<typeof resolveTelegramInlineButtons>;
};
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<PendingMessage[]> {
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<void> {
await this.runtime.handleResolved(resolved);
}

View File

@@ -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" });

View File

@@ -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);
}

View File

@@ -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<ResolvedTelegramAccount>,
"id" | "meta" | "capabilities" | "config" | "auth" | "pairing" | "allowlist"
| "id"
| "meta"
| "capabilities"
| "config"
| "auth"
| "approvalCapability"
| "pairing"
| "allowlist"
>;

View File

@@ -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<typeof resolveApprovalCommandAuthorization>;
pluginAuthorization: ReturnType<typeof resolveApprovalCommandAuthorization>;
}): 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<typeof resolveApprovalCommandAuthorization>;
pluginAuthorization: ReturnType<typeof resolveApprovalCommandAuthorization>;
}): 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:<uuid>`); 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}.` },

View File

@@ -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: {

View File

@@ -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,
});
});
});

View File

@@ -1,7 +1,63 @@
import type { ChannelApprovalAdapter, ChannelPlugin } from "./types.js";
import type { ChannelApprovalAdapter, ChannelApprovalCapability, ChannelPlugin } from "./types.js";
function buildApprovalCapabilityFromLegacyPlugin(
plugin?: Pick<ChannelPlugin, "auth" | "approvals"> | 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<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | 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<ChannelPlugin, "approvals"> | null,
plugin?: Pick<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | 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,
};
}

View File

@@ -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";

View File

@@ -588,6 +588,11 @@ export type ChannelApprovalRenderAdapter = {
};
};
export type ChannelApprovalCapability = ChannelApprovalAdapter & {
authorizeActorAction?: ChannelAuthAdapter["authorizeActorAction"];
getActionAvailabilityState?: ChannelAuthAdapter["getActionAvailabilityState"];
};
export type ChannelApprovalAdapter = {
delivery?: ChannelApprovalDeliveryAdapter;
render?: ChannelApprovalRenderAdapter;

View File

@@ -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<ResolvedAccount = any, Probe = unknown, Audit = unknow
gatewayMethods?: string[];
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
auth?: ChannelAuthAdapter;
approvalCapability?: ChannelApprovalCapability;
elevated?: ChannelElevatedAdapter;
commands?: ChannelCommandAdapter;
lifecycle?: ChannelLifecycleAdapter;

View File

@@ -9,6 +9,7 @@ export type { ChannelMessageCapability } from "./message-capabilities.js";
export type {
ChannelActionAvailabilityState,
ChannelApprovalAdapter,
ChannelApprovalCapability,
ChannelApprovalForwardTarget,
ChannelApprovalInitiatingSurfaceState,
ChannelAuthAdapter,

View File

@@ -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();
});
});

View File

@@ -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<TPreparedTarget> = {
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<TPendingEntry, TRequest, TResolved>,
"deliverRequested"
> & {
accountId?: string | null;
nativeAdapter?: ChannelApprovalNativeAdapter | null;
resolveApprovalKind?: (request: TRequest) => ChannelApprovalKind;
buildPendingContent: (params: {
request: TRequest;
approvalKind: ChannelApprovalKind;
nowMs: number;
}) => TPendingContent | Promise<TPendingContent>;
sendOriginNotice?: (params: {
originTarget: ChannelApprovalNativeTarget;
request: TRequest;
approvalKind: ChannelApprovalKind;
pendingContent: TPendingContent;
}) => Promise<void>;
prepareTarget: (params: {
plannedTarget: ChannelApprovalNativePlannedTarget;
request: TRequest;
approvalKind: ChannelApprovalKind;
pendingContent: TPendingContent;
}) =>
| PreparedChannelNativeApprovalTarget<TPreparedTarget>
| null
| Promise<PreparedChannelNativeApprovalTarget<TPreparedTarget> | null>;
deliverTarget: (params: {
plannedTarget: ChannelApprovalNativePlannedTarget;
preparedTarget: TPreparedTarget;
request: TRequest;
approvalKind: ChannelApprovalKind;
pendingContent: TPendingContent;
}) => TPendingEntry | null | Promise<TPendingEntry | null>;
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<TPreparedTarget>;
request: TRequest;
approvalKind: ChannelApprovalKind;
pendingContent: TPendingContent;
}) => void;
onDelivered?: (params: {
plannedTarget: ChannelApprovalNativePlannedTarget;
preparedTarget: PreparedChannelNativeApprovalTarget<TPreparedTarget>;
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<TRequest, TResolved> {
const nowMs = adapter.nowMs ?? Date.now;
const resolveApprovalKind =
adapter.resolveApprovalKind ?? ((request: TRequest) => defaultResolveApprovalKind(request));
return createExecApprovalChannelRuntime<TPendingEntry, TRequest, TResolved>({
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,
});
},
});
}

View File

@@ -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<typeof import("../channels/plugins/index.js")>(
"../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: {

View File

@@ -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",

View File

@@ -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<typeof import("../channels/plugins/index.js")>(
"../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);
});
});

View File

@@ -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,
);
}

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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",
}),
);
});
});

View File

@@ -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<NativeApprovalTarget[]>;
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, "delivery" | "render" | "native">;
}): 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);
}

View File

@@ -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([]);
});
});

View File

@@ -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<TTarget>(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;
};
}

View File

@@ -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,

View File

@@ -4,6 +4,8 @@ export type {
BaseTokenResolution,
ChannelAgentTool,
ChannelAccountSnapshot,
ChannelApprovalAdapter,
ChannelApprovalCapability,
ChannelCommandConversationContext,
ChannelGroupContext,
ChannelMessageActionAdapter,