From 8393d995d5770dabe4e9b2d84d13688309f3088b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:37:26 -0600 Subject: [PATCH] gateway: tighten internal route inheritance for configured main sessions --- .../chat.directive-tags.test.ts | 71 +++++++++++++++++-- src/gateway/server-methods/chat.ts | 12 ++-- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 2b675951561..f9acd15805e 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -10,6 +10,7 @@ import type { GatewayRequestContext } from "./types.js"; const mockState = vi.hoisted(() => ({ transcriptPath: "", sessionId: "sess-1", + mainSessionKey: "main", finalText: "[[reply_to_current]]", triggerAgentRunStart: false, agentRunId: "run-agent-1", @@ -31,7 +32,11 @@ vi.mock("../session-utils.js", async (importOriginal) => { return { ...original, loadSessionEntry: (rawKey: string) => ({ - cfg: {}, + cfg: { + session: { + mainKey: mockState.mainSessionKey, + }, + }, storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), entry: { sessionId: mockState.sessionId, @@ -152,13 +157,21 @@ async function runNonStreamingChatSend(params: { client?: unknown; expectBroadcast?: boolean; }) { + const sendParams: { + sessionKey: string; + message: string; + idempotencyKey: string; + deliver?: boolean; + } = { + sessionKey: params.sessionKey ?? "main", + message: params.message ?? "hello", + idempotencyKey: params.idempotencyKey, + }; + if (typeof params.deliver === "boolean") { + sendParams.deliver = params.deliver; + } await chatHandlers["chat.send"]({ - params: { - sessionKey: params.sessionKey ?? "main", - message: params.message ?? "hello", - idempotencyKey: params.idempotencyKey, - deliver: params.deliver, - }, + params: sendParams, respond: params.respond as unknown as Parameters< (typeof chatHandlers)["chat.send"] >[0]["respond"], @@ -192,6 +205,7 @@ async function runNonStreamingChatSend(params: { describe("chat directive tag stripping for non-streaming final payloads", () => { afterEach(() => { mockState.finalText = "[[reply_to_current]]"; + mockState.mainSessionKey = "main"; mockState.triggerAgentRunStart = false; mockState.agentRunId = "run-agent-1"; mockState.sessionEntry = {}; @@ -598,6 +612,49 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); }); + it("chat.send inherits external delivery context for CLI clients on configured main sessions", async () => { + createTranscriptFixture("openclaw-chat-send-config-main-cli-routes-"); + mockState.mainSessionKey = "work"; + mockState.finalText = "ok"; + mockState.sessionEntry = { + deliveryContext: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + lastChannel: "whatsapp", + lastTo: "whatsapp:+8613800138000", + lastAccountId: "default", + }; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-config-main-cli-routes", + client: { + connect: { + client: { + mode: GATEWAY_CLIENT_MODES.CLI, + id: "cli", + }, + }, + } as unknown, + sessionKey: "agent:main:work", + deliver: true, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx).toEqual( + expect.objectContaining({ + OriginatingChannel: "whatsapp", + OriginatingTo: "whatsapp:+8613800138000", + AccountId: "default", + }), + ); + }); + it("chat.send does not inherit external delivery context for non-channel custom sessions", async () => { createTranscriptFixture("openclaw-chat-send-custom-no-cross-route-"); mockState.finalText = "ok"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 26e702f2cc8..1c750ec0db6 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -876,11 +876,12 @@ export const chatHandlers: GatewayRequestHandlers = { const sessionScopeParts = (parsedSessionKey?.rest ?? sessionKey).split(":").filter(Boolean); const sessionScopeHead = sessionScopeParts[0]; const sessionChannelHint = normalizeMessageChannel(sessionScopeHead); + const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase(); const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]] .map((part) => (part ?? "").trim().toLowerCase()) .filter(Boolean); const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has( - (sessionScopeHead ?? "").trim().toLowerCase(), + normalizedSessionScopeHead, ); const isChannelScopedSession = sessionPeerShapeCandidates.some((part) => CHANNEL_SCOPED_SESSION_SHAPES.has(part), @@ -892,15 +893,18 @@ export const chatHandlers: GatewayRequestHandlers = { const clientMode = client?.connect?.client?.mode; const isFromWebchatClient = isWebchatClient(client?.connect?.client) || clientMode === GATEWAY_CLIENT_MODES.UI; + const configuredMainKey = (cfg.session?.mainKey ?? "main").trim().toLowerCase(); + const isConfiguredMainSessionScope = + normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; // Channel-agnostic session scopes (main, direct:, etc.) can leak - // stale routes across surfaces. Allow main sessions only from non-Webchat - // clients so CLI replies can keep the last WA/Telegram route. + // stale routes across surfaces. Allow configured main sessions from + // non-Webchat/UI clients (e.g., CLI, backend) to keep the last external route. const canInheritDeliverableRoute = Boolean( sessionChannelHint && sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && ((!isChannelAgnosticSessionScope && (isChannelScopedSession || hasLegacyChannelPeerShape)) || - (sessionChannelHint === "main" && client?.connect !== undefined && !isFromWebchatClient)), + (isConfiguredMainSessionScope && client?.connect !== undefined && !isFromWebchatClient)), ); const hasDeliverableRoute = shouldDeliverExternally &&