fix(gateway): normalize session key casing to prevent ghost sessions (#12846)

* fix(gateway): normalize session key casing to prevent ghost sessions on Linux

On case-sensitive filesystems (Linux), mixed-case session keys like
agent:ops:MySession and agent:ops:mysession resolve to different store
entries, creating ghost duplicates that never converge.

Core changes in session-utils.ts:
- resolveSessionStoreKey: lowercase all session key components
- canonicalizeSpawnedByForAgent: accept cfg, resolve main-alias references
  via canonicalizeMainSessionAlias after lowercasing
- loadSessionEntry: return legacyKey only when it differs from canonicalKey
- resolveGatewaySessionStoreTarget: scan store for case-insensitive matches;
  add optional scanLegacyKeys param to skip disk reads for read-only callers
- Export findStoreKeysIgnoreCase for use by write-path consumers
- Compare global/unknown sentinels case-insensitively in all canonicalization
  functions

sessions-resolve.ts:
- Make resolveSessionKeyFromResolveParams async for inline migration
- Check canonical key first (fast path), then fall back to legacy scan
- Delete ALL legacy case-variant keys in a single updateSessionStore pass

Fixes #12603

* fix(gateway): propagate canonical keys and clean up all case variants on write paths

- agent.ts: use canonicalizeSpawnedByForAgent (with cfg) instead of raw
  toLowerCase; use findStoreKeysIgnoreCase to delete all legacy variants
  on store write; pass canonicalKey to addChatRun, registerAgentRunContext,
  resolveSendPolicy, and agentCommand
- sessions.ts: replace single-key migration with full case-variant cleanup
  via findStoreKeysIgnoreCase in patch/reset/delete/compact handlers; add
  case-insensitive fallback in preview (store already loaded); make
  sessions.resolve handler async; pass scanLegacyKeys: false in preview
- server-node-events.ts: use findStoreKeysIgnoreCase to clean all legacy
  variants on voice.transcript and agent.request write paths; pass
  canonicalKey to addChatRun and agentCommand

* test(gateway): add session key case-normalization tests

Cover the case-insensitive session key canonicalization logic:
- resolveSessionStoreKey normalizes mixed-case bare and prefixed keys
- resolveSessionStoreKey resolves mixed-case main aliases (MAIN, Main)
- resolveGatewaySessionStoreTarget includes legacy mixed-case store keys
- resolveGatewaySessionStoreTarget collects all case-variant duplicates
- resolveGatewaySessionStoreTarget finds legacy main alias keys with
  customized mainKey configuration

All 5 tests fail before the production changes, pass after.

* fix: clean legacy session alias cleanup gaps (openclaw#12846) thanks @mcaxtr

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Marcus Castro
2026-02-13 16:42:24 -03:00
committed by GitHub
parent f6232bc2b4
commit 4225206f0c
8 changed files with 544 additions and 76 deletions

View File

@@ -19,6 +19,7 @@ import {
buildGroupDisplayName,
canonicalizeMainSessionAlias,
loadSessionStore,
resolveAgentMainSessionKey,
resolveFreshSessionTotalTokens,
resolveMainSessionKey,
resolveStorePath,
@@ -189,8 +190,81 @@ export function loadSessionEntry(sessionKey: string) {
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[canonicalKey];
return { cfg, storePath, store, entry, canonicalKey };
const match = findStoreMatch(store, canonicalKey, sessionKey.trim());
const legacyKey = match?.key !== canonicalKey ? match?.key : undefined;
return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey };
}
/**
* Find a session entry by exact or case-insensitive key match.
* Returns both the entry and the actual store key it was found under,
* so callers can clean up legacy mixed-case keys when they differ from canonicalKey.
*/
function findStoreMatch(
store: Record<string, SessionEntry>,
...candidates: string[]
): { entry: SessionEntry; key: string } | undefined {
// Exact match first.
for (const candidate of candidates) {
if (candidate && store[candidate]) {
return { entry: store[candidate], key: candidate };
}
}
// Case-insensitive scan for ALL candidates.
const loweredSet = new Set(candidates.filter(Boolean).map((c) => c.toLowerCase()));
for (const key of Object.keys(store)) {
if (loweredSet.has(key.toLowerCase())) {
return { entry: store[key], key };
}
}
return undefined;
}
/**
* Find all on-disk store keys that match the given key case-insensitively.
* Returns every key from the store whose lowercased form equals the target's lowercased form.
*/
export function findStoreKeysIgnoreCase(
store: Record<string, unknown>,
targetKey: string,
): string[] {
const lowered = targetKey.toLowerCase();
const matches: string[] = [];
for (const key of Object.keys(store)) {
if (key.toLowerCase() === lowered) {
matches.push(key);
}
}
return matches;
}
/**
* Remove legacy key variants for one canonical session key.
* Candidates can include aliases (for example, "agent:ops:main" when canonical is "agent:ops:work").
*/
export function pruneLegacyStoreKeys(params: {
store: Record<string, unknown>;
canonicalKey: string;
candidates: Iterable<string>;
}) {
const keysToDelete = new Set<string>();
for (const candidate of params.candidates) {
const trimmed = String(candidate ?? "").trim();
if (!trimmed) {
continue;
}
if (trimmed !== params.canonicalKey) {
keysToDelete.add(trimmed);
}
for (const match of findStoreKeysIgnoreCase(params.store, trimmed)) {
if (match !== params.canonicalKey) {
keysToDelete.add(match);
}
}
}
for (const key of keysToDelete) {
delete params.store[key];
}
}
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
@@ -334,13 +408,14 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
}
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") {
return key;
const lowered = key.toLowerCase();
if (lowered === "global" || lowered === "unknown") {
return lowered;
}
if (key.startsWith("agent:")) {
return key;
if (lowered.startsWith("agent:")) {
return lowered;
}
return `agent:${normalizeAgentId(agentId)}:${key}`;
return `agent:${normalizeAgentId(agentId)}:${lowered}`;
}
function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string {
@@ -355,30 +430,33 @@ export function resolveSessionStoreKey(params: {
if (!raw) {
return raw;
}
if (raw === "global" || raw === "unknown") {
return raw;
const rawLower = raw.toLowerCase();
if (rawLower === "global" || rawLower === "unknown") {
return rawLower;
}
const parsed = parseAgentSessionKey(raw);
if (parsed) {
const agentId = normalizeAgentId(parsed.agentId);
const lowered = raw.toLowerCase();
const canonical = canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId,
sessionKey: raw,
sessionKey: lowered,
});
if (canonical !== raw) {
if (canonical !== lowered) {
return canonical;
}
return raw;
return lowered;
}
const lowered = raw.toLowerCase();
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
if (raw === "main" || raw === rawMainKey) {
if (lowered === "main" || lowered === rawMainKey) {
return resolveMainSessionKey(params.cfg);
}
const agentId = resolveDefaultStoreAgentId(params.cfg);
return canonicalizeSessionKeyForAgent(agentId, raw);
return canonicalizeSessionKeyForAgent(agentId, lowered);
}
function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string): string {
@@ -392,21 +470,37 @@ function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string):
return resolveDefaultStoreAgentId(cfg);
}
function canonicalizeSpawnedByForAgent(agentId: string, spawnedBy?: string): string | undefined {
export function canonicalizeSpawnedByForAgent(
cfg: OpenClawConfig,
agentId: string,
spawnedBy?: string,
): string | undefined {
const raw = spawnedBy?.trim();
if (!raw) {
return undefined;
}
if (raw === "global" || raw === "unknown") {
return raw;
const lower = raw.toLowerCase();
if (lower === "global" || lower === "unknown") {
return lower;
}
if (raw.startsWith("agent:")) {
return raw;
let result: string;
if (raw.toLowerCase().startsWith("agent:")) {
result = raw.toLowerCase();
} else {
result = `agent:${normalizeAgentId(agentId)}:${lower}`;
}
return `agent:${normalizeAgentId(agentId)}:${raw}`;
// Resolve main-alias references (e.g. agent:ops:main → configured main key).
const parsed = parseAgentSessionKey(result);
const resolvedAgent = parsed?.agentId ? normalizeAgentId(parsed.agentId) : agentId;
return canonicalizeMainSessionAlias({ cfg, agentId: resolvedAgent, sessionKey: result });
}
export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig; key: string }): {
export function resolveGatewaySessionStoreTarget(params: {
cfg: OpenClawConfig;
key: string;
scanLegacyKeys?: boolean;
store?: Record<string, SessionEntry>;
}): {
agentId: string;
storePath: string;
canonicalKey: string;
@@ -431,6 +525,23 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig;
if (key && key !== canonicalKey) {
storeKeys.add(key);
}
if (params.scanLegacyKeys !== false) {
// Build a set of scan targets: all known keys plus the main alias key so we
// catch legacy entries stored under "agent:{id}:MAIN" when mainKey != "main".
const scanTargets = new Set(storeKeys);
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
if (canonicalKey === agentMainKey) {
scanTargets.add(`agent:${agentId}:main`);
}
// Scan the on-disk store for case variants of every target to find
// legacy mixed-case entries (e.g. "agent:ops:MAIN" when canonical is "agent:ops:work").
const store = params.store ?? loadSessionStore(storePath);
for (const seed of scanTargets) {
for (const legacyKey of findStoreKeysIgnoreCase(store, seed)) {
storeKeys.add(legacyKey);
}
}
}
return {
agentId,
storePath,
@@ -441,25 +552,30 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig;
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
function mergeSessionEntryIntoCombined(params: {
cfg: OpenClawConfig;
combined: Record<string, SessionEntry>;
entry: SessionEntry;
agentId: string;
canonicalKey: string;
}) {
const { combined, entry, agentId, canonicalKey } = params;
const { cfg, combined, entry, agentId, canonicalKey } = params;
const existing = combined[canonicalKey];
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
combined[canonicalKey] = {
...entry,
...existing,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy),
};
} else {
combined[canonicalKey] = {
...existing,
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
spawnedBy: canonicalizeSpawnedByForAgent(
cfg,
agentId,
entry.spawnedBy ?? existing?.spawnedBy,
),
};
}
}
@@ -477,6 +593,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
mergeSessionEntryIntoCombined({
cfg,
combined,
entry,
agentId: defaultAgentId,
@@ -494,6 +611,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
mergeSessionEntryIntoCombined({
cfg,
combined,
entry,
agentId,