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

@@ -38,7 +38,12 @@ import {
validateAgentParams,
validateAgentWaitParams,
} from "../protocol/index.js";
import { loadSessionEntry } from "../session-utils.js";
import {
canonicalizeSpawnedByForAgent,
loadSessionEntry,
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
} from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { waitForAgentJob } from "./agent-job.js";
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
@@ -213,6 +218,7 @@ export const agentHandlers: GatewayRequestHandlers = {
let sessionEntry: SessionEntry | undefined;
let bestEffortDeliver = false;
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
let resolvedSessionKey = requestedSessionKey;
if (requestedSessionKey) {
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
@@ -220,7 +226,12 @@ export const agentHandlers: GatewayRequestHandlers = {
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const labelValue = request.label?.trim() || entry?.label;
spawnedByValue = spawnedByValue || entry?.spawnedBy;
const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey);
spawnedByValue = canonicalizeSpawnedByForAgent(
cfg,
sessionAgent,
spawnedByValue || entry?.spawnedBy,
);
let inheritedGroup:
| { groupId?: string; groupChannel?: string; groupSpace?: string }
| undefined;
@@ -268,7 +279,7 @@ export const agentHandlers: GatewayRequestHandlers = {
const sendPolicy = resolveSendPolicy({
cfg,
entry,
sessionKey: requestedSessionKey,
sessionKey: canonicalKey,
channel: entry?.channel,
chatType: entry?.chatType,
});
@@ -282,21 +293,32 @@ export const agentHandlers: GatewayRequestHandlers = {
}
resolvedSessionId = sessionId;
const canonicalSessionKey = canonicalKey;
resolvedSessionKey = canonicalSessionKey;
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
if (storePath) {
await updateSessionStore(storePath, (store) => {
const target = resolveGatewaySessionStoreTarget({
cfg,
key: requestedSessionKey,
store,
});
pruneLegacyStoreKeys({
store,
canonicalKey: target.canonicalKey,
candidates: target.storeKeys,
});
store[canonicalSessionKey] = nextEntry;
});
}
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
context.addChatRun(idem, {
sessionKey: requestedSessionKey,
sessionKey: canonicalSessionKey,
clientRunId: idem,
});
bestEffortDeliver = true;
}
registerAgentRunContext(idem, { sessionKey: requestedSessionKey });
registerAgentRunContext(idem, { sessionKey: canonicalSessionKey });
}
const runId = idem;
@@ -378,7 +400,7 @@ export const agentHandlers: GatewayRequestHandlers = {
images,
to: resolvedTo,
sessionId: resolvedSessionId,
sessionKey: requestedSessionKey,
sessionKey: resolvedSessionKey,
thinking: request.thinking,
deliver,
deliveryTargetMode,