fix(webchat): preserve session channel routing on internal turns (#23258)

Co-authored-by: binary64 <1680627+binary64@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-22 21:03:52 +01:00
parent b0252ab90c
commit 8a83ca54a1
3 changed files with 110 additions and 1 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. (#11022) Thanks @coygeek.
- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64.
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting.

View File

@@ -1371,3 +1371,72 @@ describe("initSessionState stale threadId fallback", () => {
expect(result.sessionEntry.lastThreadId).toBe(99);
});
});
describe("initSessionState internal channel routing preservation", () => {
it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => {
const storePath = await createStorePath("preserve-external-channel-");
const sessionKey = "agent:main:telegram:group:12345";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: "sess-1",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "group:12345",
deliveryContext: {
channel: "telegram",
to: "group:12345",
},
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "internal follow-up",
SessionKey: sessionKey,
OriginatingChannel: "webchat",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("telegram");
expect(result.sessionEntry.deliveryContext?.channel).toBe("telegram");
});
it("uses session key channel hint when first turn is internal webchat", async () => {
const storePath = await createStorePath("session-key-channel-hint-");
const sessionKey = "agent:main:telegram:group:98765";
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello",
SessionKey: sessionKey,
OriginatingChannel: "webchat",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("telegram");
expect(result.sessionEntry.deliveryContext?.channel).toBe("telegram");
});
it("keeps webchat channel for webchat/main sessions", async () => {
const storePath = await createStorePath("preserve-webchat-main-");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello",
SessionKey: "agent:main:main",
OriginatingChannel: "webchat",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("webchat");
});
});

View File

@@ -31,7 +31,13 @@ import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenanc
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
@@ -39,6 +45,18 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
const log = createSubsystemLogger("session-init");
function resolveSessionKeyChannelHint(sessionKey?: string): string | undefined {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed?.rest) {
return undefined;
}
const head = parsed.rest.split(":")[0]?.trim().toLowerCase();
if (!head || head === "main" || head === "cron" || head === "subagent" || head === "acp") {
return undefined;
}
return normalizeMessageChannel(head);
}
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
@@ -267,7 +285,28 @@ export async function initSessionState(params: {
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
// Track the originating channel/to for announce routing (subagent announce-back).
const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
const originatingChannelRaw = ctx.OriginatingChannel as string | undefined;
const originatingChannel = normalizeMessageChannel(originatingChannelRaw);
const persistedChannel = normalizeMessageChannel(baseEntry?.lastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(sessionKey);
let lastChannelRaw = originatingChannelRaw || baseEntry?.lastChannel;
// Internal webchat/system turns should not overwrite previously known external
// delivery routes (or explicit channel hints encoded in the session key).
if (originatingChannel === INTERNAL_MESSAGE_CHANNEL) {
if (
persistedChannel &&
persistedChannel !== INTERNAL_MESSAGE_CHANNEL &&
isDeliverableMessageChannel(persistedChannel)
) {
lastChannelRaw = persistedChannel;
} else if (
sessionKeyChannelHint &&
sessionKeyChannelHint !== INTERNAL_MESSAGE_CHANNEL &&
isDeliverableMessageChannel(sessionKeyChannelHint)
) {
lastChannelRaw = sessionKeyChannelHint;
}
}
const lastToRaw = ctx.OriginatingTo || ctx.To || baseEntry?.lastTo;
const lastAccountIdRaw = ctx.AccountId || baseEntry?.lastAccountId;
// Only fall back to persisted threadId for thread sessions. Non-thread