@@ -59,6 +59,43 @@ describe("Ghost reminder bug (issue #13317)", () => {
5959 return { cfg, sessionKey } ;
6060 } ;
6161
62+ const createLastTargetConfig = ( params : {
63+ tmpDir : string ;
64+ storePath : string ;
65+ isolatedSession ?: boolean ;
66+ } ) : OpenClawConfig => ( {
67+ agents : {
68+ defaults : {
69+ workspace : params . tmpDir ,
70+ heartbeat : {
71+ every : "5m" ,
72+ target : "last" ,
73+ ...( params . isolatedSession === true ? { isolatedSession : true } : { } ) ,
74+ } ,
75+ } ,
76+ } ,
77+ channels : { telegram : { allowFrom : [ "*" ] } } ,
78+ session : { store : params . storePath } ,
79+ } ) ;
80+
81+ const writeTelegramSessionStore = async (
82+ storePath : string ,
83+ sessionKey : string ,
84+ overrides : Record < string , unknown > ,
85+ ) : Promise < void > => {
86+ await fs . writeFile (
87+ storePath ,
88+ JSON . stringify ( {
89+ [ sessionKey ] : {
90+ sessionId : "sid" ,
91+ updatedAt : Date . now ( ) ,
92+ lastChannel : "telegram" ,
93+ ...overrides ,
94+ } ,
95+ } ) ,
96+ ) ;
97+ } ;
98+
6299 const expectCronEventPrompt = (
63100 calledCtx : {
64101 Provider ?: string ;
@@ -144,6 +181,35 @@ describe("Ghost reminder bug (issue #13317)", () => {
144181 ) ;
145182 } ;
146183
184+ const expectUntrustedEventOwnership = async ( params : {
185+ tmpPrefix : string ;
186+ reason : "hook:wake" | "interval" ;
187+ isolatedSession ?: boolean ;
188+ forceSenderIsOwnerFalse : boolean ;
189+ } ) : Promise < void > => {
190+ const { result, sendTelegram, calledCtx } = await runHeartbeatCase ( {
191+ tmpPrefix : params . tmpPrefix ,
192+ replyText : "Handled internally" ,
193+ reason : params . reason ,
194+ target : "none" ,
195+ isolatedSession : params . isolatedSession ,
196+ enqueue : ( sessionKey ) => {
197+ enqueueSystemEvent ( "GitHub issue opened: untrusted webhook content" , {
198+ sessionKey,
199+ trusted : false ,
200+ } ) ;
201+ } ,
202+ } ) ;
203+
204+ expect ( result . status ) . toBe ( "ran" ) ;
205+ expect ( calledCtx ?. Provider ) . toBe ( "heartbeat" ) ;
206+ if ( params . isolatedSession === true ) {
207+ expect ( calledCtx ?. SessionKey ) . toContain ( ":heartbeat" ) ;
208+ }
209+ expect ( calledCtx ?. ForceSenderIsOwnerFalse ) . toBe ( params . forceSenderIsOwnerFalse ) ;
210+ expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
211+ } ;
212+
147213 it ( "does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present" , async ( ) => {
148214 const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase ( {
149215 tmpPrefix : "openclaw-ghost-" ,
@@ -330,87 +396,37 @@ describe("Ghost reminder bug (issue #13317)", () => {
330396 } ) ;
331397
332398 it ( "forces owner downgrade for untrusted hook:wake system events" , async ( ) => {
333- const { result , sendTelegram , calledCtx } = await runHeartbeatCase ( {
399+ await expectUntrustedEventOwnership ( {
334400 tmpPrefix : "openclaw-hook-untrusted-" ,
335- replyText : "Handled internally" ,
336401 reason : "hook:wake" ,
337- target : "none" ,
338- enqueue : ( sessionKey ) => {
339- enqueueSystemEvent ( "GitHub issue opened: untrusted webhook content" , {
340- sessionKey,
341- trusted : false ,
342- } ) ;
343- } ,
402+ forceSenderIsOwnerFalse : true ,
344403 } ) ;
345-
346- expect ( result . status ) . toBe ( "ran" ) ;
347- expect ( calledCtx ?. Provider ) . toBe ( "heartbeat" ) ;
348- expect ( calledCtx ?. ForceSenderIsOwnerFalse ) . toBe ( true ) ;
349- expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
350404 } ) ;
351405
352406 it ( "forces owner downgrade for untrusted interval events" , async ( ) => {
353- const { result , sendTelegram , calledCtx } = await runHeartbeatCase ( {
407+ await expectUntrustedEventOwnership ( {
354408 tmpPrefix : "openclaw-interval-untrusted-" ,
355- replyText : "Handled internally" ,
356409 reason : "interval" ,
357- target : "none" ,
358- enqueue : ( sessionKey ) => {
359- enqueueSystemEvent ( "GitHub issue opened: untrusted webhook content" , {
360- sessionKey,
361- trusted : false ,
362- } ) ;
363- } ,
410+ forceSenderIsOwnerFalse : true ,
364411 } ) ;
365-
366- expect ( result . status ) . toBe ( "ran" ) ;
367- expect ( calledCtx ?. Provider ) . toBe ( "heartbeat" ) ;
368- expect ( calledCtx ?. ForceSenderIsOwnerFalse ) . toBe ( true ) ;
369- expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
370412 } ) ;
371413
372414 it ( "does not force owner downgrade for untrusted hook:wake events with isolated sessions" , async ( ) => {
373- const { result , sendTelegram , calledCtx } = await runHeartbeatCase ( {
415+ await expectUntrustedEventOwnership ( {
374416 tmpPrefix : "openclaw-hook-untrusted-isolated-" ,
375- replyText : "Handled internally" ,
376417 reason : "hook:wake" ,
377- target : "none" ,
378418 isolatedSession : true ,
379- enqueue : ( sessionKey ) => {
380- enqueueSystemEvent ( "GitHub issue opened: untrusted webhook content" , {
381- sessionKey,
382- trusted : false ,
383- } ) ;
384- } ,
419+ forceSenderIsOwnerFalse : false ,
385420 } ) ;
386-
387- expect ( result . status ) . toBe ( "ran" ) ;
388- expect ( calledCtx ?. Provider ) . toBe ( "heartbeat" ) ;
389- expect ( calledCtx ?. SessionKey ) . toContain ( ":heartbeat" ) ;
390- expect ( calledCtx ?. ForceSenderIsOwnerFalse ) . toBe ( false ) ;
391- expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
392421 } ) ;
393422
394423 it ( "does not force owner downgrade for isolated interval runs with only base-session untrusted events" , async ( ) => {
395- const { result , sendTelegram , calledCtx } = await runHeartbeatCase ( {
424+ await expectUntrustedEventOwnership ( {
396425 tmpPrefix : "openclaw-interval-untrusted-isolated-" ,
397- replyText : "Handled internally" ,
398426 reason : "interval" ,
399- target : "none" ,
400427 isolatedSession : true ,
401- enqueue : ( sessionKey ) => {
402- enqueueSystemEvent ( "GitHub issue opened: untrusted webhook content" , {
403- sessionKey,
404- trusted : false ,
405- } ) ;
406- } ,
428+ forceSenderIsOwnerFalse : false ,
407429 } ) ;
408-
409- expect ( result . status ) . toBe ( "ran" ) ;
410- expect ( calledCtx ?. Provider ) . toBe ( "heartbeat" ) ;
411- expect ( calledCtx ?. SessionKey ) . toContain ( ":heartbeat" ) ;
412- expect ( calledCtx ?. ForceSenderIsOwnerFalse ) . toBe ( false ) ;
413- expect ( sendTelegram ) . not . toHaveBeenCalled ( ) ;
414430 } ) ;
415431
416432 it ( "routes wake-triggered heartbeat replies using queued system-event delivery context" , async ( ) => {
@@ -475,32 +491,9 @@ describe("Ghost reminder bug (issue #13317)", () => {
475491
476492 it ( "does not reuse stale turn-source routing for isolated wake runs" , async ( ) => {
477493 await withTempHeartbeatSandbox ( async ( { tmpDir, storePath, replySpy } ) => {
478- const cfg : OpenClawConfig = {
479- agents : {
480- defaults : {
481- workspace : tmpDir ,
482- heartbeat : {
483- every : "5m" ,
484- target : "last" ,
485- isolatedSession : true ,
486- } ,
487- } ,
488- } ,
489- channels : { telegram : { allowFrom : [ "*" ] } } ,
490- session : { store : storePath } ,
491- } ;
494+ const cfg = createLastTargetConfig ( { tmpDir, storePath, isolatedSession : true } ) ;
492495 const sessionKey = resolveMainSessionKey ( cfg ) ;
493- await fs . writeFile (
494- storePath ,
495- JSON . stringify ( {
496- [ sessionKey ] : {
497- sessionId : "sid" ,
498- updatedAt : Date . now ( ) ,
499- lastChannel : "telegram" ,
500- lastTo : "-100155462274" ,
501- } ,
502- } ) ,
503- ) ;
496+ await writeTelegramSessionStore ( storePath , sessionKey , { lastTo : "-100155462274" } ) ;
504497
505498 const sendTelegram = vi . fn ( ) . mockResolvedValue ( {
506499 messageId : "m1" ,
@@ -609,38 +602,17 @@ describe("Ghost reminder bug (issue #13317)", () => {
609602
610603 it ( "keeps Telegram topic routing for isolated scheduled heartbeats" , async ( ) => {
611604 await withTempHeartbeatSandbox ( async ( { tmpDir, storePath, replySpy } ) => {
612- const cfg : OpenClawConfig = {
613- agents : {
614- defaults : {
615- workspace : tmpDir ,
616- heartbeat : {
617- every : "5m" ,
618- target : "last" ,
619- isolatedSession : true ,
620- } ,
621- } ,
622- } ,
623- channels : { telegram : { allowFrom : [ "*" ] } } ,
624- session : { store : storePath } ,
625- } ;
605+ const cfg = createLastTargetConfig ( { tmpDir, storePath, isolatedSession : true } ) ;
626606 const sessionKey = resolveMainSessionKey ( cfg ) ;
627- await fs . writeFile (
628- storePath ,
629- JSON . stringify ( {
630- [ sessionKey ] : {
631- sessionId : "sid" ,
632- updatedAt : Date . now ( ) ,
633- lastChannel : "telegram" ,
634- lastTo : "-100155462274" ,
635- deliveryContext : {
636- channel : "telegram" ,
637- to : "-100155462274" ,
638- threadId : 42 ,
639- } ,
640- chatType : "group" ,
641- } ,
642- } ) ,
643- ) ;
607+ await writeTelegramSessionStore ( storePath , sessionKey , {
608+ lastTo : "-100155462274" ,
609+ deliveryContext : {
610+ channel : "telegram" ,
611+ to : "-100155462274" ,
612+ threadId : 42 ,
613+ } ,
614+ chatType : "group" ,
615+ } ) ;
644616
645617 const sendTelegram = vi . fn ( ) . mockResolvedValue ( {
646618 messageId : "m1" ,
0 commit comments