Files
openclaw/src/auto-reply/reply/session-delivery.ts
Vincent Koc 9fed9f1302 fix(session): tighten direct-session webchat routing matching (#37867)
* fix(session): require strict direct key routing shapes

* test(session): cover direct route poisoning cases
2026-03-06 08:53:16 -05:00

208 lines
6.9 KiB
TypeScript

import type { SessionEntry } from "../../config/sessions.js";
import { buildAgentMainSessionKey } from "../../routing/session-key.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import {
deliveryContextFromSession,
deliveryContextKey,
normalizeDeliveryContext,
} from "../../utils/delivery-context.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import type { MsgContext } from "../templating.js";
export type LegacyMainDeliveryRetirement = {
key: string;
entry: SessionEntry;
};
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);
}
function isMainSessionKey(sessionKey?: string): boolean {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return (sessionKey ?? "").trim().toLowerCase() === "main";
}
return parsed.rest.trim().toLowerCase() === "main";
}
const DIRECT_SESSION_MARKERS = new Set(["direct", "dm"]);
const THREAD_SESSION_MARKERS = new Set(["thread", "topic"]);
function hasStrictDirectSessionTail(parts: string[], markerIndex: number): boolean {
const peerId = parts[markerIndex + 1]?.trim();
if (!peerId) {
return false;
}
const tail = parts.slice(markerIndex + 2);
if (tail.length === 0) {
return true;
}
return tail.length === 2 && THREAD_SESSION_MARKERS.has(tail[0] ?? "") && Boolean(tail[1]?.trim());
}
function isDirectSessionKey(sessionKey?: string): boolean {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return false;
}
const scoped = parseAgentSessionKey(raw)?.rest ?? raw;
const parts = scoped.split(":").filter(Boolean);
if (parts.length < 2) {
return false;
}
if (DIRECT_SESSION_MARKERS.has(parts[0] ?? "")) {
return hasStrictDirectSessionTail(parts, 0);
}
const channel = normalizeMessageChannel(parts[0]);
if (!channel || !isDeliverableMessageChannel(channel)) {
return false;
}
if (DIRECT_SESSION_MARKERS.has(parts[1] ?? "")) {
return hasStrictDirectSessionTail(parts, 1);
}
return Boolean(parts[1]?.trim()) && DIRECT_SESSION_MARKERS.has(parts[2] ?? "")
? hasStrictDirectSessionTail(parts, 2)
: false;
}
function isExternalRoutingChannel(channel?: string): channel is string {
return Boolean(
channel && channel !== INTERNAL_MESSAGE_CHANNEL && isDeliverableMessageChannel(channel),
);
}
export function resolveLastChannelRaw(params: {
originatingChannelRaw?: string;
persistedLastChannel?: string;
sessionKey?: string;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
// WebChat should own reply routing for direct-session UI turns, even when the
// session previously replied through an external channel like iMessage.
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
(isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey))
) {
return params.originatingChannelRaw;
}
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
let resolved = params.originatingChannelRaw || params.persistedLastChannel;
// Internal/non-deliverable sources should not overwrite previously known
// external delivery routes (or explicit channel hints from the session key).
if (!isExternalRoutingChannel(originatingChannel)) {
if (isExternalRoutingChannel(persistedChannel)) {
resolved = persistedChannel;
} else if (isExternalRoutingChannel(sessionKeyChannelHint)) {
resolved = sessionKeyChannelHint;
}
}
return resolved;
}
export function resolveLastToRaw(params: {
originatingChannelRaw?: string;
originatingToRaw?: string;
toRaw?: string;
persistedLastTo?: string;
persistedLastChannel?: string;
sessionKey?: string;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
(isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey))
) {
return params.originatingToRaw || params.toRaw;
}
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
// When the turn originates from an internal/non-deliverable source, do not
// replace an established external destination with internal routing ids
// (e.g., session/webchat ids).
if (!isExternalRoutingChannel(originatingChannel)) {
const hasExternalFallback =
isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint);
if (hasExternalFallback && params.persistedLastTo) {
return params.persistedLastTo;
}
}
return params.originatingToRaw || params.toRaw || params.persistedLastTo;
}
export function maybeRetireLegacyMainDeliveryRoute(params: {
sessionCfg: { dmScope?: string } | undefined;
sessionKey: string;
sessionStore: Record<string, SessionEntry>;
agentId: string;
mainKey: string;
isGroup: boolean;
ctx: MsgContext;
}): LegacyMainDeliveryRetirement | undefined {
const dmScope = params.sessionCfg?.dmScope ?? "main";
if (dmScope === "main" || params.isGroup) {
return undefined;
}
const canonicalMainSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: params.mainKey,
}).toLowerCase();
if (params.sessionKey === canonicalMainSessionKey) {
return undefined;
}
const legacyMain = params.sessionStore[canonicalMainSessionKey];
if (!legacyMain) {
return undefined;
}
const legacyRouteKey = deliveryContextKey(deliveryContextFromSession(legacyMain));
if (!legacyRouteKey) {
return undefined;
}
const activeDirectRouteKey = deliveryContextKey(
normalizeDeliveryContext({
channel: params.ctx.OriginatingChannel as string | undefined,
to: params.ctx.OriginatingTo || params.ctx.To,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
}),
);
if (!activeDirectRouteKey || activeDirectRouteKey !== legacyRouteKey) {
return undefined;
}
if (
legacyMain.deliveryContext === undefined &&
legacyMain.lastChannel === undefined &&
legacyMain.lastTo === undefined &&
legacyMain.lastAccountId === undefined &&
legacyMain.lastThreadId === undefined
) {
return undefined;
}
return {
key: canonicalMainSessionKey,
entry: {
...legacyMain,
deliveryContext: undefined,
lastChannel: undefined,
lastTo: undefined,
lastAccountId: undefined,
lastThreadId: undefined,
},
};
}