@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
33import { beforeEach , describe , expect , it , vi } from "vitest" ;
44import { handleSlackAction , slackActionRuntime } from "./action-runtime.js" ;
55import { parseSlackBlocksInput } from "./blocks-input.js" ;
6+ import { buildSlackThreadingToolContext } from "./threading-tool-context.js" ;
67
78const originalSlackActionRuntime = { ...slackActionRuntime } ;
89const deleteSlackMessage = vi . fn ( async ( ..._args : unknown [ ] ) => ( { } ) ) ;
@@ -17,6 +18,9 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
1718const readSlackMessages = vi . fn ( async ( ..._args : unknown [ ] ) => ( { } ) ) ;
1819const removeOwnSlackReactions = vi . fn ( async ( ..._args : unknown [ ] ) => [ "thumbsup" ] ) ;
1920const removeSlackReaction = vi . fn ( async ( ..._args : unknown [ ] ) => ( { } ) ) ;
21+ const resolveSlackConversationName = vi . fn (
22+ async ( ..._args : unknown [ ] ) : Promise < string | undefined > => undefined ,
23+ ) ;
2024const sendSlackMessage = vi . fn ( async ( ..._args : unknown [ ] ) => ( { channelId : "C123" } ) ) ;
2125const unpinSlackMessage = vi . fn ( async ( ..._args : unknown [ ] ) => ( { } ) ) ;
2226
@@ -201,6 +205,7 @@ describe("handleSlackAction", () => {
201205
202206 beforeEach ( ( ) => {
203207 vi . clearAllMocks ( ) ;
208+ resolveSlackConversationName . mockReset ( ) . mockResolvedValue ( undefined ) ;
204209 Object . assign ( slackActionRuntime , originalSlackActionRuntime , {
205210 deleteSlackMessage,
206211 downloadSlackFile,
@@ -215,6 +220,7 @@ describe("handleSlackAction", () => {
215220 readSlackMessages,
216221 removeOwnSlackReactions,
217222 removeSlackReaction,
223+ resolveSlackConversationName,
218224 sendSlackMessage,
219225 unpinSlackMessage,
220226 } ) ;
@@ -994,6 +1000,152 @@ describe("handleSlackAction", () => {
9941000 expect ( requireMockArg ( readSlackMessages , "readSlackMessages" , 0 , 0 ) ) . toBe ( "C_ALLOWED" ) ;
9951001 } ) ;
9961002
1003+ it ( "resolves name-allowlisted reads from a core-shaped Slack threading context" , async ( ) => {
1004+ resolveSlackConversationName . mockResolvedValueOnce ( "allowed-channel" ) ;
1005+ readSlackMessages . mockResolvedValueOnce ( { messages : [ ] , hasMore : false } ) ;
1006+
1007+ const cfg = slackConfig ( {
1008+ groupPolicy : "allowlist" ,
1009+ dangerouslyAllowNameMatching : true ,
1010+ channels : {
1011+ "#allowed-channel" : { enabled : true } ,
1012+ } ,
1013+ } ) ;
1014+ const context = buildSlackThreadingToolContext ( {
1015+ cfg,
1016+ accountId : null ,
1017+ context : {
1018+ ChatType : "channel" ,
1019+ Channel : "slack" ,
1020+ To : "channel:C0123456789" ,
1021+ } ,
1022+ } ) ;
1023+
1024+ await handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg , context ) ;
1025+
1026+ expect ( resolveSlackConversationName ) . toHaveBeenCalledWith ( "C0123456789" , { cfg } ) ;
1027+ expect ( requireMockArg ( readSlackMessages , "readSlackMessages" , 0 , 0 ) ) . toBe ( "C0123456789" ) ;
1028+ } ) ;
1029+
1030+ it ( "does not treat the core Channel provider value as a Slack room name" , async ( ) => {
1031+ resolveSlackConversationName . mockResolvedValueOnce ( "actual-room" ) ;
1032+
1033+ const cfg = slackConfig ( {
1034+ groupPolicy : "allowlist" ,
1035+ dangerouslyAllowNameMatching : true ,
1036+ channels : {
1037+ "#slack" : { enabled : true } ,
1038+ } ,
1039+ } ) ;
1040+ const context = buildSlackThreadingToolContext ( {
1041+ cfg,
1042+ accountId : null ,
1043+ context : {
1044+ ChatType : "channel" ,
1045+ Channel : "slack" ,
1046+ To : "channel:C0123456789" ,
1047+ } ,
1048+ } ) ;
1049+
1050+ await expect (
1051+ handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg , context ) ,
1052+ ) . rejects . toThrow ( "Slack read target channel is not allowed." ) ;
1053+ expect ( resolveSlackConversationName ) . toHaveBeenCalledWith ( "C0123456789" , { cfg } ) ;
1054+ expect ( readSlackMessages ) . not . toHaveBeenCalled ( ) ;
1055+ } ) ;
1056+
1057+ it ( "does not authorize different Slack targets with the current context channel ID" , async ( ) => {
1058+ resolveSlackConversationName . mockResolvedValueOnce ( "other-channel" ) ;
1059+
1060+ const cfg = slackConfig ( {
1061+ groupPolicy : "allowlist" ,
1062+ dangerouslyAllowNameMatching : true ,
1063+ channels : {
1064+ "#allowed-channel" : { enabled : true } ,
1065+ } ,
1066+ } ) ;
1067+
1068+ await expect (
1069+ handleSlackAction ( { action : "readMessages" , channelId : "C9876543210" } , cfg , {
1070+ currentChannelId : "C0123456789" ,
1071+ } ) ,
1072+ ) . rejects . toThrow ( "Slack read target channel is not allowed." ) ;
1073+ expect ( resolveSlackConversationName ) . toHaveBeenCalledWith ( "C9876543210" , { cfg } ) ;
1074+ expect ( readSlackMessages ) . not . toHaveBeenCalled ( ) ;
1075+ } ) ;
1076+
1077+ it ( "uses the configured user read token to resolve name-allowlisted channels" , async ( ) => {
1078+ resolveSlackConversationName . mockResolvedValueOnce ( "allowed-channel" ) ;
1079+ readSlackMessages . mockResolvedValueOnce ( { messages : [ ] , hasMore : false } ) ;
1080+
1081+ const cfg = slackConfig ( {
1082+ userToken : "xoxp-reader" ,
1083+ groupPolicy : "allowlist" ,
1084+ dangerouslyAllowNameMatching : true ,
1085+ channels : {
1086+ "#allowed-channel" : { enabled : true } ,
1087+ } ,
1088+ } ) ;
1089+ await handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg ) ;
1090+
1091+ expect ( resolveSlackConversationName ) . toHaveBeenCalledWith ( "C0123456789" , {
1092+ cfg,
1093+ token : "xoxp-reader" ,
1094+ } ) ;
1095+ expect ( requireMockArg ( readSlackMessages , "readSlackMessages" , 0 , 0 ) ) . toBe ( "C0123456789" ) ;
1096+ } ) ;
1097+
1098+ it ( "resolves Slack target channel names before applying wildcard fallback denial" , async ( ) => {
1099+ resolveSlackConversationName . mockResolvedValueOnce ( "allowed-channel" ) ;
1100+ readSlackMessages . mockResolvedValueOnce ( { messages : [ ] , hasMore : false } ) ;
1101+
1102+ const cfg = slackConfig ( {
1103+ groupPolicy : "allowlist" ,
1104+ dangerouslyAllowNameMatching : true ,
1105+ channels : {
1106+ "*" : { enabled : false } ,
1107+ "#allowed-channel" : { enabled : true } ,
1108+ } ,
1109+ } ) ;
1110+ await handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg ) ;
1111+
1112+ expect ( resolveSlackConversationName ) . toHaveBeenCalledWith ( "C0123456789" , { cfg } ) ;
1113+ expect ( requireMockArg ( readSlackMessages , "readSlackMessages" , 0 , 0 ) ) . toBe ( "C0123456789" ) ;
1114+ } ) ;
1115+
1116+ it ( "does not let a name match override an explicit channel-id denial" , async ( ) => {
1117+ const cfg = slackConfig ( {
1118+ groupPolicy : "allowlist" ,
1119+ dangerouslyAllowNameMatching : true ,
1120+ channels : {
1121+ C0123456789 : { enabled : false } ,
1122+ "#allowed-channel" : { enabled : true } ,
1123+ } ,
1124+ } ) ;
1125+
1126+ await expect (
1127+ handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg ) ,
1128+ ) . rejects . toThrow ( "Slack read target channel is not allowed." ) ;
1129+ expect ( resolveSlackConversationName ) . not . toHaveBeenCalled ( ) ;
1130+ expect ( readSlackMessages ) . not . toHaveBeenCalled ( ) ;
1131+ } ) ;
1132+
1133+ it ( "fails closed before reading when Slack cannot resolve the target name" , async ( ) => {
1134+ resolveSlackConversationName . mockRejectedValueOnce ( new Error ( "missing_scope" ) ) ;
1135+ const cfg = slackConfig ( {
1136+ groupPolicy : "allowlist" ,
1137+ dangerouslyAllowNameMatching : true ,
1138+ channels : {
1139+ "#allowed-channel" : { enabled : true } ,
1140+ } ,
1141+ } ) ;
1142+
1143+ await expect (
1144+ handleSlackAction ( { action : "readMessages" , channelId : "C0123456789" } , cfg ) ,
1145+ ) . rejects . toThrow ( "missing_scope" ) ;
1146+ expect ( readSlackMessages ) . not . toHaveBeenCalled ( ) ;
1147+ } ) ;
1148+
9971149 it ( "rejects Slack reads for non-allowlisted target channels" , async ( ) => {
9981150 const cfg = slackConfig ( {
9991151 groupPolicy : "allowlist" ,
0 commit comments