From b4bac484e34bac48f241640365a762c1ba79ee33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 22:22:23 +0000 Subject: [PATCH] fix(gateway): stop webchat route inheritance on channel sessions (#39175, thanks @widingmarcus-cyber) Co-authored-by: Marcus Widing --- CHANGELOG.md | 1 + .../chat.directive-tags.test.ts | 88 +++++++++++++++++++ src/gateway/server-methods/chat.ts | 18 ++-- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3101a6369..bbb511e4c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,7 @@ Docs: https://docs.openclaw.ai - Telegram/polling conflict recovery: reset the polling `webhookCleared` latch on `getUpdates` 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell. - Heartbeat/requests-in-flight scheduling: stop advancing `nextDueMs` and avoid immediate `scheduleNext()` timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW. - Memory/SQLite contention resilience: re-apply `PRAGMA busy_timeout` on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate `SQLITE_BUSY` failures under lock contention. (#39183) Thanks @MumuTW. +- Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber. ## 2026.3.2 diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 717c81337e8..37f5a0cfb6f 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -797,4 +797,92 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }), ); }); + + it("chat.send does not inherit external routes for webchat clients on channel-scoped sessions", async () => { + createTranscriptFixture("openclaw-chat-send-webchat-channel-scoped-no-inherit-"); + mockState.finalText = "ok"; + mockState.sessionEntry = { + deliveryContext: { + channel: "imessage", + to: "+8619800001234", + accountId: "default", + }, + lastChannel: "imessage", + lastTo: "+8619800001234", + lastAccountId: "default", + }; + const respond = vi.fn(); + const context = createChatContext(); + + // Webchat client accessing an iMessage channel-scoped session should NOT + // inherit the external delivery route. Fixes #38957. + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-webchat-channel-scoped-no-inherit", + client: { + connect: { + client: { + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + id: "openclaw-webchat", + }, + }, + } as unknown, + sessionKey: "agent:main:imessage:direct:+8619800001234", + deliver: true, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx).toEqual( + expect.objectContaining({ + OriginatingChannel: "webchat", + OriginatingTo: undefined, + ExplicitDeliverRoute: false, + AccountId: undefined, + }), + ); + }); + + it("chat.send still inherits external routes for UI clients on channel-scoped sessions", async () => { + createTranscriptFixture("openclaw-chat-send-ui-channel-scoped-inherit-"); + mockState.finalText = "ok"; + mockState.sessionEntry = { + deliveryContext: { + channel: "imessage", + to: "+8619800001234", + accountId: "default", + }, + lastChannel: "imessage", + lastTo: "+8619800001234", + lastAccountId: "default", + }; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-ui-channel-scoped-inherit", + client: { + connect: { + client: { + mode: GATEWAY_CLIENT_MODES.UI, + id: "openclaw-tui", + }, + }, + } as unknown, + sessionKey: "agent:main:imessage:direct:+8619800001234", + deliver: true, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx).toEqual( + expect.objectContaining({ + OriginatingChannel: "imessage", + OriginatingTo: "+8619800001234", + ExplicitDeliverRoute: true, + AccountId: "default", + }), + ); + }); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 497902b63ff..7b4adb5cd78 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -32,11 +32,7 @@ import { } from "../chat-abort.js"; import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js"; import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js"; -import { - GATEWAY_CLIENT_CAPS, - GATEWAY_CLIENT_MODES, - hasGatewayClientCap, -} from "../protocol/client-info.js"; +import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; import { ErrorCodes, errorShape, @@ -168,22 +164,22 @@ function resolveChatSendOriginatingRoute(params: { !isChannelScopedSession && typeof sessionScopeParts[1] === "string" && sessionChannelHint === routeChannelCandidate; - const isFromWebchatClient = - isWebchatClient(params.client) || params.client?.mode === GATEWAY_CLIENT_MODES.UI; + const isFromWebchatClient = isWebchatClient(params.client); const configuredMainKey = (params.mainKey ?? "main").trim().toLowerCase(); const isConfiguredMainSessionScope = normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; - // Keep explicit delivery for channel-scoped sessions, but refuse to inherit - // stale external routes for shared-main and other channel-agnostic webchat/UI - // turns where the session key does not encode the user's current target. + // Webchat/Control UI clients never inherit external delivery routes, even when + // accessing channel-scoped sessions. External routes are only for non-webchat + // clients where the session key explicitly encodes an external target. // Preserve the old configured-main contract: any connected non-webchat client // may inherit the last external route even when client metadata is absent. const canInheritDeliverableRoute = Boolean( + !isFromWebchatClient && sessionChannelHint && sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && ((!isChannelAgnosticSessionScope && (isChannelScopedSession || hasLegacyChannelPeerShape)) || - (isConfiguredMainSessionScope && params.hasConnectedClient && !isFromWebchatClient)), + (isConfiguredMainSessionScope && params.hasConnectedClient)), ); const hasDeliverableRoute = canInheritDeliverableRoute &&