mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 05:12:15 +00:00
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:
committed by
GitHub
parent
d9a7ffe003
commit
c87c8e66bf
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
|
||||
@@ -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}.` },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
75
src/channels/plugins/approvals.test.ts
Normal file
75
src/channels/plugins/approvals.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -588,6 +588,11 @@ export type ChannelApprovalRenderAdapter = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelApprovalCapability = ChannelApprovalAdapter & {
|
||||
authorizeActorAction?: ChannelAuthAdapter["authorizeActorAction"];
|
||||
getActionAvailabilityState?: ChannelAuthAdapter["getActionAvailabilityState"];
|
||||
};
|
||||
|
||||
export type ChannelApprovalAdapter = {
|
||||
delivery?: ChannelApprovalDeliveryAdapter;
|
||||
render?: ChannelApprovalRenderAdapter;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
export type {
|
||||
ChannelActionAvailabilityState,
|
||||
ChannelApprovalAdapter,
|
||||
ChannelApprovalCapability,
|
||||
ChannelApprovalForwardTarget,
|
||||
ChannelApprovalInitiatingSurfaceState,
|
||||
ChannelAuthAdapter,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
141
src/plugin-sdk/approval-client-helpers.test.ts
Normal file
141
src/plugin-sdk/approval-client-helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
src/plugin-sdk/approval-client-helpers.ts
Normal file
153
src/plugin-sdk/approval-client-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
110
src/plugin-sdk/approval-native-helpers.test.ts
Normal file
110
src/plugin-sdk/approval-native-helpers.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
78
src/plugin-sdk/approval-native-helpers.ts
Normal file
78
src/plugin-sdk/approval-native-helpers.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,8 @@ export type {
|
||||
BaseTokenResolution,
|
||||
ChannelAgentTool,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelApprovalAdapter,
|
||||
ChannelApprovalCapability,
|
||||
ChannelCommandConversationContext,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
|
||||
Reference in New Issue
Block a user