Sessions: canonicalize mixed-case session keys

This commit is contained in:
Vignesh Natarajan
2026-02-22 23:26:32 -08:00
parent 1be8897339
commit 9ea740afb6
5 changed files with 229 additions and 15 deletions

View File

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

View File

@@ -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,
}),
);
});
});

View File

@@ -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<void> {
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,
});
}

View File

@@ -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();
});
});

View File

@@ -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<string, SessionEntry>;
sessionKey: string;
}): {
normalizedKey: string;
existing: SessionEntry | undefined;
legacyKeys: string[];
} {
const trimmedKey = params.sessionKey.trim();
const normalizedKey = normalizeStoreSessionKey(trimmedKey);
const legacyKeySet = new Set<string>();
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<string, SessionEntry>): 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;
});
}