diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 7c856598d2d..b2e94a99247 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, isGatewayMessageChannel, normalizeMessageChannel, + type DeliverableMessageChannel, type GatewayMessageChannel, } from "../../utils/message-channel.js"; import type { OutboundTargetResolution } from "./targets.js"; @@ -32,6 +33,20 @@ export function resolveAgentDeliveryPlan(params: { explicitThreadId?: string | number; accountId?: string; wantsDelivery: boolean; + /** + * The channel that originated the current agent turn. When provided, + * overrides session-level `lastChannel` to prevent cross-channel reply + * routing in shared sessions (dmScope="main"). + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: string; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): AgentDeliveryPlan { const requestedRaw = typeof params.requestedChannel === "string" ? params.requestedChannel.trim() : ""; @@ -43,11 +58,24 @@ export function resolveAgentDeliveryPlan(params: { ? params.explicitTo.trim() : undefined; + // Resolve turn-source channel for cross-channel safety. + const normalizedTurnSource = params.turnSourceChannel + ? normalizeMessageChannel(params.turnSourceChannel) + : undefined; + const turnSourceChannel = + normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource) + ? normalizedTurnSource + : undefined; + const baseDelivery = resolveSessionDeliveryTarget({ entry: params.sessionEntry, requestedChannel: requestedChannel === INTERNAL_MESSAGE_CHANNEL ? "last" : requestedChannel, explicitTo, explicitThreadId: params.explicitThreadId, + turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); const resolvedChannel = (() => { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 5cc004a4b3a..c9976414e21 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -357,3 +357,79 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(1008013); }); }); + +describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => { + it("uses turnSourceChannel over session lastChannel when provided", () => { + // Simulate: WhatsApp message originated the turn, but a Slack message + // arrived concurrently and updated lastChannel to "slack" + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-shared", + updatedAt: 1, + lastChannel: "slack", // <- concurrently overwritten + lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", // <- originated from WhatsApp + turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+66972796305"); + }); + + it("falls back to session lastChannel when turnSourceChannel is not set", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-normal", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "8587265585", + }, + requestedChannel: "last", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + }); + + it("respects explicit requestedChannel over turnSourceChannel", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-explicit", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U12345", + }, + requestedChannel: "telegram", + explicitTo: "8587265585", + turnSourceChannel: "whatsapp", + turnSourceTo: "+66972796305", + }); + + // Explicit requestedChannel "telegram" is not "last", so it takes priority + expect(resolved.channel).toBe("telegram"); + }); + + it("preserves turnSourceAccountId and turnSourceThreadId", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-meta", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong-account", + }, + requestedChannel: "last", + turnSourceChannel: "telegram", + turnSourceTo: "8587265585", + turnSourceAccountId: "bot-123", + turnSourceThreadId: 42, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + expect(resolved.accountId).toBe("bot-123"); + expect(resolved.threadId).toBe(42); + }); +}); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index f03918423e2..6df0ecee6d2 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -62,13 +62,41 @@ export function resolveSessionDeliveryTarget(params: { fallbackChannel?: DeliverableMessageChannel; allowMismatchedLastTo?: boolean; mode?: ChannelOutboundTargetMode; + /** + * When set, this overrides the session-level `lastChannel` for "last" + * resolution. This prevents cross-channel reply routing when multiple + * channels share the same session (dmScope = "main") and an inbound + * message from a different channel updates `lastChannel` while an agent + * turn is still in flight. + * + * Callers should set this to the channel that originated the current + * agent turn so the reply always routes back to the correct channel. + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: DeliverableMessageChannel; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): SessionDeliveryTarget { const context = deliveryContextFromSession(params.entry); - const lastChannel = + const sessionLastChannel = context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined; - const lastTo = context?.to; - const lastAccountId = context?.accountId; - const lastThreadId = context?.threadId; + + // When a turn-source channel is provided, use it instead of the session's + // mutable lastChannel. This prevents a concurrent inbound from a different + // channel from hijacking the reply target (cross-channel privacy leak). + const lastChannel = params.turnSourceChannel ?? sessionLastChannel; + const lastTo = params.turnSourceChannel ? (params.turnSourceTo ?? context?.to) : context?.to; + const lastAccountId = params.turnSourceChannel + ? (params.turnSourceAccountId ?? context?.accountId) + : context?.accountId; + const lastThreadId = params.turnSourceChannel + ? (params.turnSourceThreadId ?? context?.threadId) + : context?.threadId; const rawRequested = params.requestedChannel ?? "last"; const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);