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

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadSessionStore } from "../config/sessions.js";
import { loadSessionStore, updateSessionStore } from "../config/sessions.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import {
ErrorCodes,
@@ -10,15 +10,16 @@ import {
import {
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
} from "./session-utils.js";
export type SessionsResolveResult = { ok: true; key: string } | { ok: false; error: ErrorShape };
export function resolveSessionKeyFromResolveParams(params: {
export async function resolveSessionKeyFromResolveParams(params: {
cfg: OpenClawConfig;
p: SessionsResolveParams;
}): SessionsResolveResult {
}): Promise<SessionsResolveResult> {
const { cfg, p } = params;
const key = typeof p.key === "string" ? p.key.trim() : "";
@@ -46,13 +47,25 @@ export function resolveSessionKeyFromResolveParams(params: {
if (hasKey) {
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (!existingKey) {
if (store[target.canonicalKey]) {
return { ok: true, key: target.canonicalKey };
}
const legacyKey = target.storeKeys.find((candidate) => store[candidate]);
if (!legacyKey) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
};
}
await updateSessionStore(target.storePath, (s) => {
const liveTarget = resolveGatewaySessionStoreTarget({ cfg, key, store: s });
const canonicalKey = liveTarget.canonicalKey;
// Migrate the first legacy entry to the canonical key.
if (!s[canonicalKey] && s[legacyKey]) {
s[canonicalKey] = s[legacyKey];
}
pruneLegacyStoreKeys({ store: s, canonicalKey, candidates: liveTarget.storeKeys });
});
return { ok: true, key: target.canonicalKey };
}