fix(gateway): stop webchat route inheritance on channel sessions (#39175, thanks @widingmarcus-cyber)

Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 22:22:23 +00:00
parent 3a2fdc5136
commit b4bac484e3
3 changed files with 96 additions and 11 deletions

View File

@@ -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

View File

@@ -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",
}),
);
});
});

View File

@@ -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 &&