Skip to content

Commit 527895f

Browse files
authored
Gateway/sessions: preserve shared session route on system events (#66073)
Merged via squash. Prepared head SHA: 314a935 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky
1 parent 907df51 commit 527895f

4 files changed

Lines changed: 154 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
1717
- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.
1818
- Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.
19+
- Gateway/sessions: stop heartbeat, cron-event, and exec-event turns from overwriting shared-session routing and origin metadata, preventing synthetic `heartbeat` targets from poisoning later cron or user delivery. (#63733, #35300)
1920
## 2026.4.12
2021

2122
### Changes

src/auto-reply/reply/session.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2520,6 +2520,89 @@ describe("initSessionState dmScope delivery migration", () => {
25202520
});
25212521

25222522
describe("initSessionState internal channel routing preservation", () => {
2523+
it("does not synthesize heartbeat routing on a session with no external route", async () => {
2524+
const storePath = await createStorePath("system-event-no-route-");
2525+
const sessionKey = "agent:main:main";
2526+
await writeSessionStoreFast(storePath, {
2527+
[sessionKey]: {
2528+
sessionId: "sess-system-event-no-route",
2529+
updatedAt: Date.now(),
2530+
},
2531+
});
2532+
const cfg = { session: { store: storePath } } as OpenClawConfig;
2533+
2534+
const result = await initSessionState({
2535+
ctx: {
2536+
Body: "HEARTBEAT_OK",
2537+
SessionKey: sessionKey,
2538+
Provider: "heartbeat",
2539+
From: "heartbeat",
2540+
To: "heartbeat",
2541+
},
2542+
cfg,
2543+
commandAuthorized: true,
2544+
});
2545+
2546+
expect(result.sessionEntry.lastChannel).toBeUndefined();
2547+
expect(result.sessionEntry.lastTo).toBeUndefined();
2548+
expect(result.sessionEntry.deliveryContext).toBeUndefined();
2549+
expect(result.sessionEntry.origin).toBeUndefined();
2550+
});
2551+
2552+
it("preserves the existing user route when a heartbeat targets a different chat on the shared session", async () => {
2553+
const storePath = await createStorePath("system-event-preserve-user-route-");
2554+
const sessionKey = "agent:main:main";
2555+
await writeSessionStoreFast(storePath, {
2556+
[sessionKey]: {
2557+
sessionId: "sess-system-event-shared",
2558+
updatedAt: Date.now(),
2559+
lastChannel: "feishu",
2560+
lastTo: "user:ou_sender_1",
2561+
deliveryContext: {
2562+
channel: "feishu",
2563+
to: "user:ou_sender_1",
2564+
accountId: "default",
2565+
},
2566+
origin: {
2567+
provider: "feishu",
2568+
from: "user:ou_sender_1",
2569+
to: "user:ou_sender_1",
2570+
accountId: "default",
2571+
},
2572+
},
2573+
});
2574+
const cfg = { session: { store: storePath } } as OpenClawConfig;
2575+
2576+
const result = await initSessionState({
2577+
ctx: {
2578+
Body: "heartbeat tick",
2579+
SessionKey: sessionKey,
2580+
Provider: "heartbeat",
2581+
From: "chat:oc_group_chat",
2582+
To: "chat:oc_group_chat",
2583+
OriginatingChannel: "feishu",
2584+
OriginatingTo: "chat:oc_group_chat",
2585+
AccountId: "default",
2586+
},
2587+
cfg,
2588+
commandAuthorized: true,
2589+
});
2590+
2591+
expect(result.sessionEntry.lastChannel).toBe("feishu");
2592+
expect(result.sessionEntry.lastTo).toBe("user:ou_sender_1");
2593+
expect(result.sessionEntry.deliveryContext).toEqual({
2594+
channel: "feishu",
2595+
to: "user:ou_sender_1",
2596+
accountId: "default",
2597+
});
2598+
expect(result.sessionEntry.origin).toEqual({
2599+
provider: "feishu",
2600+
from: "user:ou_sender_1",
2601+
to: "user:ou_sender_1",
2602+
accountId: "default",
2603+
});
2604+
});
2605+
25232606
it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => {
25242607
const storePath = await createStorePath("preserve-external-channel-");
25252608
const sessionKey = "agent:main:telegram:group:12345";

src/auto-reply/reply/session.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -485,39 +485,64 @@ export async function initSessionState(params: {
485485
// Track the originating channel/to for announce routing (subagent announce-back).
486486
const originatingChannelRaw = ctx.OriginatingChannel as string | undefined;
487487
const isInterSession = isInterSessionInputProvenance(ctx.InputProvenance);
488-
const lastChannelRaw = resolveLastChannelRaw({
489-
originatingChannelRaw,
490-
persistedLastChannel: baseEntry?.lastChannel,
491-
sessionKey,
492-
isInterSession,
493-
});
494-
const lastToRaw = resolveLastToRaw({
495-
originatingChannelRaw,
496-
originatingToRaw: ctx.OriginatingTo,
497-
toRaw: ctx.To,
498-
persistedLastTo: baseEntry?.lastTo,
499-
persistedLastChannel: baseEntry?.lastChannel,
500-
sessionKey,
501-
isInterSession,
502-
});
503-
const lastAccountIdRaw = resolveSessionDefaultAccountId({
504-
cfg,
505-
channelRaw: lastChannelRaw,
506-
accountIdRaw: ctx.AccountId,
507-
persistedLastAccountId: baseEntry?.lastAccountId,
508-
});
509-
// Only fall back to persisted threadId for thread sessions. Non-thread
488+
// Automated heartbeat/cron/exec turns run inside the conversation session,
489+
// but they must not rewrite the session's remembered external delivery route.
490+
// Otherwise a heartbeat target like "group:..." or a synthetic sender like
491+
// "heartbeat" leaks into the shared session and later user replies route to
492+
// the wrong chat.
493+
const lastChannelRaw = isSystemEvent
494+
? baseEntry?.lastChannel
495+
: resolveLastChannelRaw({
496+
originatingChannelRaw,
497+
persistedLastChannel: baseEntry?.lastChannel,
498+
sessionKey,
499+
isInterSession,
500+
});
501+
const lastToRaw = isSystemEvent
502+
? baseEntry?.lastTo
503+
: resolveLastToRaw({
504+
originatingChannelRaw,
505+
originatingToRaw: ctx.OriginatingTo,
506+
toRaw: ctx.To,
507+
persistedLastTo: baseEntry?.lastTo,
508+
persistedLastChannel: baseEntry?.lastChannel,
509+
sessionKey,
510+
isInterSession,
511+
});
512+
const lastAccountIdRaw = isSystemEvent
513+
? baseEntry?.lastAccountId
514+
: resolveSessionDefaultAccountId({
515+
cfg,
516+
channelRaw: lastChannelRaw,
517+
accountIdRaw: ctx.AccountId,
518+
persistedLastAccountId: baseEntry?.lastAccountId,
519+
});
520+
// Only fall back to persisted threadId for thread sessions. Non-thread
510521
// sessions (e.g. DM without topics) must not inherit a stale threadId from a
511522
// previous interaction that happened inside a topic/thread.
512-
const lastThreadIdRaw = ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined);
513-
const deliveryFields = normalizeSessionDeliveryFields({
514-
deliveryContext: {
515-
channel: lastChannelRaw,
516-
to: lastToRaw,
517-
accountId: lastAccountIdRaw,
518-
threadId: lastThreadIdRaw,
519-
},
520-
});
523+
const lastThreadIdRaw = isSystemEvent
524+
? baseEntry?.lastThreadId
525+
: ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined);
526+
const deliveryFields = isSystemEvent
527+
? normalizeSessionDeliveryFields({
528+
channel: baseEntry?.channel,
529+
lastChannel: baseEntry?.lastChannel,
530+
lastTo: baseEntry?.lastTo,
531+
lastAccountId: baseEntry?.lastAccountId,
532+
lastThreadId:
533+
baseEntry?.lastThreadId ??
534+
baseEntry?.deliveryContext?.threadId ??
535+
baseEntry?.origin?.threadId,
536+
deliveryContext: baseEntry?.deliveryContext,
537+
})
538+
: normalizeSessionDeliveryFields({
539+
deliveryContext: {
540+
channel: lastChannelRaw,
541+
to: lastToRaw,
542+
accountId: lastAccountIdRaw,
543+
threadId: lastThreadIdRaw,
544+
},
545+
});
521546
const lastChannel = deliveryFields.lastChannel ?? lastChannelRaw;
522547
const lastTo = deliveryFields.lastTo ?? lastToRaw;
523548
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
@@ -577,6 +602,7 @@ export async function initSessionState(params: {
577602
sessionKey,
578603
existing: sessionEntry,
579604
groupResolution,
605+
skipSystemEventOrigin: isSystemEvent,
580606
});
581607
if (metaPatch) {
582608
sessionEntry = { ...sessionEntry, ...metaPatch };

src/config/sessions/metadata.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,15 @@ const mergeOrigin = (
5151
return Object.keys(merged).length > 0 ? merged : undefined;
5252
};
5353

54-
export function deriveSessionOrigin(ctx: MsgContext): SessionOrigin | undefined {
54+
export function deriveSessionOrigin(
55+
ctx: MsgContext,
56+
opts?: { skipSystemEventOrigin?: boolean },
57+
): SessionOrigin | undefined {
58+
const isSystemEventProvider =
59+
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
60+
if (opts?.skipSystemEventOrigin && isSystemEventProvider) {
61+
return undefined;
62+
}
5563
const label = normalizeOptionalString(resolveConversationLabel(ctx));
5664
const providerRaw =
5765
(typeof ctx.OriginatingChannel === "string" && ctx.OriginatingChannel) ||
@@ -175,9 +183,12 @@ export function deriveSessionMetaPatch(params: {
175183
sessionKey: string;
176184
existing?: SessionEntry;
177185
groupResolution?: GroupKeyResolution | null;
186+
skipSystemEventOrigin?: boolean;
178187
}): Partial<SessionEntry> | null {
179188
const groupPatch = deriveGroupSessionPatch(params);
180-
const origin = deriveSessionOrigin(params.ctx);
189+
const origin = deriveSessionOrigin(params.ctx, {
190+
skipSystemEventOrigin: params.skipSystemEventOrigin,
191+
});
181192
if (!groupPatch && !origin) {
182193
return null;
183194
}

0 commit comments

Comments
 (0)