diff --git a/CHANGELOG.md b/CHANGELOG.md index 557f1c96da7..0e78645c4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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) ## 2026.4.12 ### Changes diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 46ad10141c6..2bf9aeab3a8 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2520,6 +2520,89 @@ describe("initSessionState dmScope delivery migration", () => { }); describe("initSessionState internal channel routing preservation", () => { + it("does not synthesize heartbeat routing on a session with no external route", async () => { + const storePath = await createStorePath("system-event-no-route-"); + const sessionKey = "agent:main:main"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "sess-system-event-no-route", + updatedAt: Date.now(), + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "HEARTBEAT_OK", + SessionKey: sessionKey, + Provider: "heartbeat", + From: "heartbeat", + To: "heartbeat", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBeUndefined(); + expect(result.sessionEntry.lastTo).toBeUndefined(); + expect(result.sessionEntry.deliveryContext).toBeUndefined(); + expect(result.sessionEntry.origin).toBeUndefined(); + }); + + it("preserves the existing user route when a heartbeat targets a different chat on the shared session", async () => { + const storePath = await createStorePath("system-event-preserve-user-route-"); + const sessionKey = "agent:main:main"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "sess-system-event-shared", + updatedAt: Date.now(), + lastChannel: "feishu", + lastTo: "user:ou_sender_1", + deliveryContext: { + channel: "feishu", + to: "user:ou_sender_1", + accountId: "default", + }, + origin: { + provider: "feishu", + from: "user:ou_sender_1", + to: "user:ou_sender_1", + accountId: "default", + }, + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "heartbeat tick", + SessionKey: sessionKey, + Provider: "heartbeat", + From: "chat:oc_group_chat", + To: "chat:oc_group_chat", + OriginatingChannel: "feishu", + OriginatingTo: "chat:oc_group_chat", + AccountId: "default", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("feishu"); + expect(result.sessionEntry.lastTo).toBe("user:ou_sender_1"); + expect(result.sessionEntry.deliveryContext).toEqual({ + channel: "feishu", + to: "user:ou_sender_1", + accountId: "default", + }); + expect(result.sessionEntry.origin).toEqual({ + provider: "feishu", + from: "user:ou_sender_1", + to: "user:ou_sender_1", + accountId: "default", + }); + }); + it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => { const storePath = await createStorePath("preserve-external-channel-"); const sessionKey = "agent:main:telegram:group:12345"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index f9883254372..25bbc688dc8 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -485,39 +485,64 @@ export async function initSessionState(params: { // Track the originating channel/to for announce routing (subagent announce-back). const originatingChannelRaw = ctx.OriginatingChannel as string | undefined; const isInterSession = isInterSessionInputProvenance(ctx.InputProvenance); - const lastChannelRaw = resolveLastChannelRaw({ - originatingChannelRaw, - persistedLastChannel: baseEntry?.lastChannel, - sessionKey, - isInterSession, - }); - const lastToRaw = resolveLastToRaw({ - originatingChannelRaw, - originatingToRaw: ctx.OriginatingTo, - toRaw: ctx.To, - persistedLastTo: baseEntry?.lastTo, - persistedLastChannel: baseEntry?.lastChannel, - sessionKey, - isInterSession, - }); - const lastAccountIdRaw = resolveSessionDefaultAccountId({ - cfg, - channelRaw: lastChannelRaw, - accountIdRaw: ctx.AccountId, - persistedLastAccountId: baseEntry?.lastAccountId, - }); - // Only fall back to persisted threadId for thread sessions. Non-thread + // Automated heartbeat/cron/exec turns run inside the conversation session, + // but they must not rewrite the session's remembered external delivery route. + // Otherwise a heartbeat target like "group:..." or a synthetic sender like + // "heartbeat" leaks into the shared session and later user replies route to + // the wrong chat. + const lastChannelRaw = isSystemEvent + ? baseEntry?.lastChannel + : resolveLastChannelRaw({ + originatingChannelRaw, + persistedLastChannel: baseEntry?.lastChannel, + sessionKey, + isInterSession, + }); + const lastToRaw = isSystemEvent + ? baseEntry?.lastTo + : resolveLastToRaw({ + originatingChannelRaw, + originatingToRaw: ctx.OriginatingTo, + toRaw: ctx.To, + persistedLastTo: baseEntry?.lastTo, + persistedLastChannel: baseEntry?.lastChannel, + sessionKey, + isInterSession, + }); + const lastAccountIdRaw = isSystemEvent + ? baseEntry?.lastAccountId + : resolveSessionDefaultAccountId({ + cfg, + channelRaw: lastChannelRaw, + accountIdRaw: ctx.AccountId, + persistedLastAccountId: baseEntry?.lastAccountId, + }); + // Only fall back to persisted threadId for thread sessions. Non-thread // sessions (e.g. DM without topics) must not inherit a stale threadId from a // previous interaction that happened inside a topic/thread. - const lastThreadIdRaw = ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined); - const deliveryFields = normalizeSessionDeliveryFields({ - deliveryContext: { - channel: lastChannelRaw, - to: lastToRaw, - accountId: lastAccountIdRaw, - threadId: lastThreadIdRaw, - }, - }); + const lastThreadIdRaw = isSystemEvent + ? baseEntry?.lastThreadId + : ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined); + const deliveryFields = isSystemEvent + ? normalizeSessionDeliveryFields({ + channel: baseEntry?.channel, + lastChannel: baseEntry?.lastChannel, + lastTo: baseEntry?.lastTo, + lastAccountId: baseEntry?.lastAccountId, + lastThreadId: + baseEntry?.lastThreadId ?? + baseEntry?.deliveryContext?.threadId ?? + baseEntry?.origin?.threadId, + deliveryContext: baseEntry?.deliveryContext, + }) + : normalizeSessionDeliveryFields({ + deliveryContext: { + channel: lastChannelRaw, + to: lastToRaw, + accountId: lastAccountIdRaw, + threadId: lastThreadIdRaw, + }, + }); const lastChannel = deliveryFields.lastChannel ?? lastChannelRaw; const lastTo = deliveryFields.lastTo ?? lastToRaw; const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw; @@ -577,6 +602,7 @@ export async function initSessionState(params: { sessionKey, existing: sessionEntry, groupResolution, + skipSystemEventOrigin: isSystemEvent, }); if (metaPatch) { sessionEntry = { ...sessionEntry, ...metaPatch }; diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index b7480aa1880..e42a3a654be 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -51,7 +51,15 @@ const mergeOrigin = ( return Object.keys(merged).length > 0 ? merged : undefined; }; -export function deriveSessionOrigin(ctx: MsgContext): SessionOrigin | undefined { +export function deriveSessionOrigin( + ctx: MsgContext, + opts?: { skipSystemEventOrigin?: boolean }, +): SessionOrigin | undefined { + const isSystemEventProvider = + ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event"; + if (opts?.skipSystemEventOrigin && isSystemEventProvider) { + return undefined; + } const label = normalizeOptionalString(resolveConversationLabel(ctx)); const providerRaw = (typeof ctx.OriginatingChannel === "string" && ctx.OriginatingChannel) || @@ -175,9 +183,12 @@ export function deriveSessionMetaPatch(params: { sessionKey: string; existing?: SessionEntry; groupResolution?: GroupKeyResolution | null; + skipSystemEventOrigin?: boolean; }): Partial | null { const groupPatch = deriveGroupSessionPatch(params); - const origin = deriveSessionOrigin(params.ctx); + const origin = deriveSessionOrigin(params.ctx, { + skipSystemEventOrigin: params.skipSystemEventOrigin, + }); if (!groupPatch && !origin) { return null; }