gateway: tighten internal route inheritance for configured main sessions

This commit is contained in:
Tak Hoffman
2026-03-04 23:37:26 -06:00
parent 459682a812
commit 8393d995d5
2 changed files with 72 additions and 11 deletions

View File

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

View File

@@ -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:<peer>, 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 &&