mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
fix(session): preserve route on system events
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<SessionEntry> | null {
|
||||
const groupPatch = deriveGroupSessionPatch(params);
|
||||
const origin = deriveSessionOrigin(params.ctx);
|
||||
const origin = deriveSessionOrigin(params.ctx, {
|
||||
skipSystemEventOrigin: params.skipSystemEventOrigin,
|
||||
});
|
||||
if (!groupPatch && !origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user