diff --git a/CHANGELOG.md b/CHANGELOG.md index b4391281c2f..8f3f68af312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. - Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. ## 2026.2.23 diff --git a/src/channels/session.test.ts b/src/channels/session.test.ts index 0be177f85f5..429985efd90 100644 --- a/src/channels/session.test.ts +++ b/src/channels/session.test.ts @@ -75,4 +75,32 @@ describe("recordInboundSession", () => { }), ); }); + + it("normalizes mixed-case session keys before recording and route updates", async () => { + const { recordInboundSession } = await import("./session.js"); + + await recordInboundSession({ + storePath: "/tmp/openclaw-session-store.json", + sessionKey: "Agent:Main:Telegram:1234:Thread:42", + ctx, + updateLastRoute: { + sessionKey: "agent:main:telegram:1234:thread:42", + channel: "telegram", + to: "telegram:1234", + }, + onRecordError: vi.fn(), + }); + + expect(recordSessionMetaFromInboundMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:1234:thread:42", + }), + ); + expect(updateLastRouteMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:1234:thread:42", + ctx, + }), + ); + }); }); diff --git a/src/channels/session.ts b/src/channels/session.ts index c2f2433be2a..6a56638cdff 100644 --- a/src/channels/session.ts +++ b/src/channels/session.ts @@ -6,6 +6,10 @@ import { updateLastRoute, } from "../config/sessions.js"; +function normalizeSessionStoreKey(sessionKey: string): string { + return sessionKey.trim().toLowerCase(); +} + export type InboundLastRouteUpdate = { sessionKey: string; channel: SessionEntry["lastChannel"]; @@ -24,9 +28,10 @@ export async function recordInboundSession(params: { onRecordError: (err: unknown) => void; }): Promise { const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params; + const canonicalSessionKey = normalizeSessionStoreKey(sessionKey); void recordSessionMetaFromInbound({ storePath, - sessionKey, + sessionKey: canonicalSessionKey, ctx, groupResolution, createIfMissing, @@ -36,9 +41,10 @@ export async function recordInboundSession(params: { if (!update) { return; } + const targetSessionKey = normalizeSessionStoreKey(update.sessionKey); await updateLastRoute({ storePath, - sessionKey: update.sessionKey, + sessionKey: targetSessionKey, deliveryContext: { channel: update.channel, to: update.to, @@ -46,7 +52,7 @@ export async function recordInboundSession(params: { threadId: update.threadId, }, // Avoid leaking inbound origin metadata into a different target session. - ctx: update.sessionKey === sessionKey ? ctx : undefined, + ctx: targetSessionKey === canonicalSessionKey ? ctx : undefined, groupResolution, }); } diff --git a/src/config/sessions/store.session-key-normalization.test.ts b/src/config/sessions/store.session-key-normalization.test.ts new file mode 100644 index 00000000000..76fdf4d723b --- /dev/null +++ b/src/config/sessions/store.session-key-normalization.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { MsgContext } from "../../auto-reply/templating.js"; +import { + clearSessionStoreCacheForTest, + loadSessionStore, + recordSessionMetaFromInbound, + updateLastRoute, +} from "../sessions.js"; + +const CANONICAL_KEY = "agent:main:webchat:dm:mixed-user"; +const MIXED_CASE_KEY = "Agent:Main:WebChat:DM:MiXeD-User"; + +function createInboundContext(): MsgContext { + return { + Provider: "webchat", + Surface: "webchat", + ChatType: "direct", + From: "WebChat:User-1", + To: "webchat:agent", + SessionKey: MIXED_CASE_KEY, + OriginatingTo: "webchat:user-1", + }; +} + +describe("session store key normalization", () => { + let tempDir = ""; + let storePath = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-key-normalize-")); + storePath = path.join(tempDir, "sessions.json"); + await fs.writeFile(storePath, "{}", "utf-8"); + }); + + afterEach(async () => { + clearSessionStoreCacheForTest(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("records inbound metadata under a canonical lowercase key", async () => { + await recordSessionMetaFromInbound({ + storePath, + sessionKey: MIXED_CASE_KEY, + ctx: createInboundContext(), + }); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(Object.keys(store)).toEqual([CANONICAL_KEY]); + expect(store[CANONICAL_KEY]?.origin?.provider).toBe("webchat"); + }); + + it("does not create a duplicate mixed-case key when last route is updated", async () => { + await recordSessionMetaFromInbound({ + storePath, + sessionKey: CANONICAL_KEY, + ctx: createInboundContext(), + }); + + await updateLastRoute({ + storePath, + sessionKey: MIXED_CASE_KEY, + channel: "webchat", + to: "webchat:user-1", + }); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(Object.keys(store)).toEqual([CANONICAL_KEY]); + expect(store[CANONICAL_KEY]).toEqual( + expect.objectContaining({ + lastChannel: "webchat", + lastTo: "webchat:user-1", + }), + ); + }); + + it("migrates legacy mixed-case entries to the canonical key on update", async () => { + await fs.writeFile( + storePath, + JSON.stringify( + { + [MIXED_CASE_KEY]: { + sessionId: "legacy-session", + updatedAt: 1, + chatType: "direct", + channel: "webchat", + }, + }, + null, + 2, + ), + "utf-8", + ); + clearSessionStoreCacheForTest(); + + await updateLastRoute({ + storePath, + sessionKey: CANONICAL_KEY, + channel: "webchat", + to: "webchat:user-2", + }); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store[CANONICAL_KEY]?.sessionId).toBe("legacy-session"); + expect(store[MIXED_CASE_KEY]).toBeUndefined(); + }); +}); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index d224f368299..54b0c0c70e0 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -106,6 +106,51 @@ function removeThreadFromDeliveryContext(context?: DeliveryContext): DeliveryCon return next; } +function normalizeStoreSessionKey(sessionKey: string): string { + return sessionKey.trim().toLowerCase(); +} + +function resolveStoreSessionEntry(params: { + store: Record; + sessionKey: string; +}): { + normalizedKey: string; + existing: SessionEntry | undefined; + legacyKeys: string[]; +} { + const trimmedKey = params.sessionKey.trim(); + const normalizedKey = normalizeStoreSessionKey(trimmedKey); + const legacyKeySet = new Set(); + if ( + trimmedKey !== normalizedKey && + Object.prototype.hasOwnProperty.call(params.store, trimmedKey) + ) { + legacyKeySet.add(trimmedKey); + } + let existing = + params.store[normalizedKey] ?? (legacyKeySet.size > 0 ? params.store[trimmedKey] : undefined); + let existingUpdatedAt = existing?.updatedAt ?? 0; + for (const [candidateKey, candidateEntry] of Object.entries(params.store)) { + if (candidateKey === normalizedKey) { + continue; + } + if (candidateKey.toLowerCase() !== normalizedKey) { + continue; + } + legacyKeySet.add(candidateKey); + const candidateUpdatedAt = candidateEntry?.updatedAt ?? 0; + if (!existing || candidateUpdatedAt > existingUpdatedAt) { + existing = candidateEntry; + existingUpdatedAt = candidateUpdatedAt; + } + } + return { + normalizedKey, + existing, + legacyKeys: [...legacyKeySet], + }; +} + function normalizeSessionStore(store: Record): void { for (const [key, entry] of Object.entries(store)) { if (!entry) { @@ -239,7 +284,8 @@ export function readSessionUpdatedAt(params: { }): number | undefined { try { const store = loadSessionStore(params.storePath); - return store[params.sessionKey]?.updatedAt; + const resolved = resolveStoreSessionEntry({ store, sessionKey: params.sessionKey }); + return resolved.existing?.updatedAt; } catch { return undefined; } @@ -807,7 +853,8 @@ export async function updateSessionStoreEntry(params: { const { storePath, sessionKey, update } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath, { skipCache: true }); - const existing = store[sessionKey]; + const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const existing = resolved.existing; if (!existing) { return null; } @@ -816,8 +863,13 @@ export async function updateSessionStoreEntry(params: { return existing; } const next = mergeSessionEntry(existing, patch); - store[sessionKey] = next; - await saveSessionStoreUnlocked(storePath, store, { activeSessionKey: sessionKey }); + store[resolved.normalizedKey] = next; + for (const legacyKey of resolved.legacyKeys) { + delete store[legacyKey]; + } + await saveSessionStoreUnlocked(storePath, store, { + activeSessionKey: resolved.normalizedKey, + }); return next; }); } @@ -834,24 +886,34 @@ export async function recordSessionMetaFromInbound(params: { return await updateSessionStore( storePath, (store) => { - const existing = store[sessionKey]; + const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const existing = resolved.existing; const patch = deriveSessionMetaPatch({ ctx, - sessionKey, + sessionKey: resolved.normalizedKey, existing, groupResolution: params.groupResolution, }); if (!patch) { + if (existing && resolved.legacyKeys.length > 0) { + store[resolved.normalizedKey] = existing; + for (const legacyKey of resolved.legacyKeys) { + delete store[legacyKey]; + } + } return existing ?? null; } if (!existing && !createIfMissing) { return null; } const next = mergeSessionEntry(existing, patch); - store[sessionKey] = next; + store[resolved.normalizedKey] = next; + for (const legacyKey of resolved.legacyKeys) { + delete store[legacyKey]; + } return next; }, - { activeSessionKey: sessionKey }, + { activeSessionKey: normalizeStoreSessionKey(sessionKey) }, ); } @@ -869,7 +931,8 @@ export async function updateLastRoute(params: { const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); - const existing = store[sessionKey]; + const resolved = resolveStoreSessionEntry({ store, sessionKey }); + const existing = resolved.existing; const now = Date.now(); const explicitContext = normalizeDeliveryContext(params.deliveryContext); const inlineContext = normalizeDeliveryContext({ @@ -910,7 +973,7 @@ export async function updateLastRoute(params: { const metaPatch = ctx ? deriveSessionMetaPatch({ ctx, - sessionKey, + sessionKey: resolved.normalizedKey, existing, groupResolution: params.groupResolution, }) @@ -927,8 +990,13 @@ export async function updateLastRoute(params: { existing, metaPatch ? { ...basePatch, ...metaPatch } : basePatch, ); - store[sessionKey] = next; - await saveSessionStoreUnlocked(storePath, store, { activeSessionKey: sessionKey }); + store[resolved.normalizedKey] = next; + for (const legacyKey of resolved.legacyKeys) { + delete store[legacyKey]; + } + await saveSessionStoreUnlocked(storePath, store, { + activeSessionKey: resolved.normalizedKey, + }); return next; }); }