@@ -548,6 +548,160 @@ describe("runMessageAction media behavior", () => {
548548 } ) ;
549549 } ) ;
550550
551+ describe ( "reply hydration" , ( ) => {
552+ // The reply action accepts attachments via the same media/path/filePath
553+ // params as send. Before openclaw#79864 the runner only hydrated
554+ // sendAttachment/setGroupIcon/upload-file, so a channel plugin's reply
555+ // handler saw the raw path and could forward it directly to its CLI —
556+ // bypassing localRoots, sandbox, and size checks. These tests pin the
557+ // wiring at the runner level: paths must arrive at the plugin handler
558+ // as a hydrated buffer, paths outside the resolver's policy must
559+ // reject before the handler runs, and reply must not inherit the
560+ // sendAttachment caption-fallback that would synthesize a bogus
561+ // caption from the agent's reply text.
562+ const cfg = {
563+ channels : {
564+ replychat : {
565+ enabled : true ,
566+ } ,
567+ } ,
568+ } as OpenClawConfig ;
569+ const handleActionMock = vi . fn ( ) ;
570+ const replyPlugin : ChannelPlugin = {
571+ id : "replychat" ,
572+ meta : {
573+ id : "replychat" ,
574+ label : "ReplyChat" ,
575+ selectionLabel : "ReplyChat" ,
576+ docsPath : "/channels/replychat" ,
577+ blurb : "ReplyChat test plugin." ,
578+ } ,
579+ capabilities : { chatTypes : [ "direct" , "group" ] , media : true } ,
580+ config : {
581+ listAccountIds : ( ) => [ "default" ] ,
582+ resolveAccount : ( ) => ( { enabled : true } ) ,
583+ isConfigured : ( ) => true ,
584+ } ,
585+ actions : {
586+ describeMessageTool : ( ) => ( { actions : [ "reply" ] } ) ,
587+ supportsAction : ( { action } ) => action === "reply" ,
588+ handleAction : async ( { params } ) => {
589+ handleActionMock ( params ) ;
590+ return jsonResult ( {
591+ ok : true ,
592+ buffer : params . buffer ,
593+ filename : params . filename ,
594+ caption : params . caption ,
595+ contentType : params . contentType ,
596+ text : params . text ,
597+ message : params . message ,
598+ } ) ;
599+ } ,
600+ } ,
601+ } ;
602+
603+ beforeEach ( ( ) => {
604+ handleActionMock . mockReset ( ) ;
605+ setActivePluginRegistry (
606+ createTestRegistry ( [
607+ {
608+ pluginId : "replychat" ,
609+ source : "test" ,
610+ plugin : replyPlugin ,
611+ } ,
612+ ] ) ,
613+ ) ;
614+ vi . mocked ( loadWebMedia ) . mockResolvedValue ( {
615+ buffer : Buffer . from ( "hello" ) ,
616+ contentType : "image/png" ,
617+ kind : "image" ,
618+ fileName : "pic.png" ,
619+ } ) ;
620+ } ) ;
621+
622+ afterEach ( ( ) => {
623+ setActivePluginRegistry ( createTestRegistry ( [ ] ) ) ;
624+ vi . clearAllMocks ( ) ;
625+ } ) ;
626+
627+ it ( "hydrates buffer and filename from a remote URL before the reply handler runs" , async ( ) => {
628+ const result = await runMessageAction ( {
629+ cfg,
630+ action : "reply" ,
631+ params : {
632+ channel : "replychat" ,
633+ target : "+15551234567" ,
634+ messageId : "parent-id" ,
635+ text : "look at this" ,
636+ media : "https://example.com/pic.png" ,
637+ } ,
638+ } ) ;
639+
640+ expect ( result . kind ) . toBe ( "action" ) ;
641+ expect ( handleActionMock ) . toHaveBeenCalledTimes ( 1 ) ;
642+ const handlerParams = handleActionMock . mock . calls [ 0 ] ?. [ 0 ] as Record < string , unknown > ;
643+ expect ( handlerParams . buffer ) . toBe ( Buffer . from ( "hello" ) . toString ( "base64" ) ) ;
644+ expect ( handlerParams . filename ) . toBe ( "pic.png" ) ;
645+ expect ( handlerParams . contentType ) . toBe ( "image/png" ) ;
646+ } ) ;
647+
648+ it ( "rejects host paths outside mediaLocalRoots before invoking the reply handler" , async ( ) => {
649+ // Use the real loader so its localRoots/workspaceOnly enforcement runs.
650+ const actual = await vi . importActual < typeof import ( "../../media/web-media.js" ) > (
651+ "../../media/web-media.js" ,
652+ ) ;
653+ vi . mocked ( loadWebMedia ) . mockImplementation ( actual . loadWebMedia ) ;
654+
655+ const tempDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "msg-reply-bypass-" ) ) ;
656+ try {
657+ const outsidePath = path . join ( tempDir , "secret.txt" ) ;
658+ await fs . writeFile ( outsidePath , "secret" , "utf8" ) ;
659+
660+ await expect (
661+ runMessageAction ( {
662+ cfg : {
663+ ...cfg ,
664+ tools : { fs : { workspaceOnly : true } } ,
665+ } ,
666+ action : "reply" ,
667+ params : {
668+ channel : "replychat" ,
669+ target : "+15551234567" ,
670+ messageId : "parent-id" ,
671+ text : "look at this" ,
672+ path : outsidePath ,
673+ } ,
674+ } ) ,
675+ ) . rejects . toThrow ( / a l l o w e d d i r e c t o r y | p a t h - n o t - a l l o w e d | w o r k s p a c e / i) ;
676+ expect ( handleActionMock ) . not . toHaveBeenCalled ( ) ;
677+ } finally {
678+ await fs . rm ( tempDir , { recursive : true , force : true } ) ;
679+ }
680+ } ) ;
681+
682+ it ( "does not synthesize a caption from message on reply" , async ( ) => {
683+ // sendAttachment falls back caption -> message when caption is missing.
684+ // Reply has its own text/message body, so caption fallback would
685+ // invent a bogus caption param the channel handler shouldn't see.
686+ await runMessageAction ( {
687+ cfg,
688+ action : "reply" ,
689+ params : {
690+ channel : "replychat" ,
691+ target : "+15551234567" ,
692+ messageId : "parent-id" ,
693+ message : "look at this" ,
694+ media : "https://example.com/pic.png" ,
695+ } ,
696+ } ) ;
697+
698+ expect ( handleActionMock ) . toHaveBeenCalledTimes ( 1 ) ;
699+ const handlerParams = handleActionMock . mock . calls [ 0 ] ?. [ 0 ] as Record < string , unknown > ;
700+ expect ( handlerParams . caption ) . toBeUndefined ( ) ;
701+ expect ( handlerParams . message ) . toBe ( "look at this" ) ;
702+ } ) ;
703+ } ) ;
704+
551705 describe ( "plugin-owned media-source discovery routing" , ( ) => {
552706 const profilePlugin : ChannelPlugin = {
553707 ...createChannelTestPluginBase ( {
0 commit comments