mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 18:04:32 +00:00
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:
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
deriveSessionTitle,
|
||||
listSessionsFromStore,
|
||||
parseGroupKey,
|
||||
pruneLegacyStoreKeys,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionStoreKey,
|
||||
} from "./session-utils.js";
|
||||
@@ -50,6 +52,9 @@ describe("gateway session utils", () => {
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:work");
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe("agent:ops:work");
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:main" })).toBe("agent:ops:work");
|
||||
// Mixed-case main alias must also resolve to the configured mainKey (idempotent)
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:MAIN" })).toBe("agent:ops:work");
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "MAIN" })).toBe("agent:ops:work");
|
||||
});
|
||||
|
||||
test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => {
|
||||
@@ -65,6 +70,23 @@ describe("gateway session utils", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSessionStoreKey normalizes session key casing", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
// Bare keys with different casing must resolve to the same canonical key
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "CoP" })).toBe(
|
||||
resolveSessionStoreKey({ cfg, sessionKey: "cop" }),
|
||||
);
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "MySession" })).toBe("agent:ops:mysession");
|
||||
// Prefixed agent keys with mixed-case rest must also normalize
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:CoP" })).toBe("agent:ops:cop");
|
||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:MySession" })).toBe(
|
||||
"agent:alpha:mysession",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSessionStoreKey honors global scope", () => {
|
||||
const cfg = {
|
||||
session: { scope: "global", mainKey: "work" },
|
||||
@@ -92,6 +114,89 @@ describe("gateway session utils", () => {
|
||||
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:main", "main"]));
|
||||
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
||||
});
|
||||
|
||||
test("resolveGatewaySessionStoreTarget includes legacy mixed-case store key", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-case-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
// Simulate a legacy store with a mixed-case key
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({ "agent:ops:MySession": { sessionId: "s1", updatedAt: 1 } }),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "main", store: storePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
// Client passes the lowercased canonical key (as returned by sessions.list)
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" });
|
||||
expect(target.canonicalKey).toBe("agent:ops:mysession");
|
||||
// storeKeys must include the legacy mixed-case key from the on-disk store
|
||||
expect(target.storeKeys).toEqual(
|
||||
expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]),
|
||||
);
|
||||
// The legacy key must resolve to the actual entry in the store
|
||||
const store = JSON.parse(fs.readFileSync(storePath, "utf8"));
|
||||
const found = target.storeKeys.some((k) => Boolean(store[k]));
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
test("resolveGatewaySessionStoreTarget includes all case-variant duplicate keys", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-dupes-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
// Simulate a store with both canonical and legacy mixed-case entries
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:ops:mysession": { sessionId: "s-lower", updatedAt: 2 },
|
||||
"agent:ops:MySession": { sessionId: "s-mixed", updatedAt: 1 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "main", store: storePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" });
|
||||
// storeKeys must include BOTH variants so delete/reset/patch can clean up all duplicates
|
||||
expect(target.storeKeys).toEqual(
|
||||
expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveGatewaySessionStoreTarget finds legacy main alias key when mainKey is customized", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-alias-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
// Legacy store has entry under "agent:ops:MAIN" but mainKey is "work"
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({ "agent:ops:MAIN": { sessionId: "s1", updatedAt: 1 } }),
|
||||
"utf8",
|
||||
);
|
||||
const cfg = {
|
||||
session: { mainKey: "work", store: storePath },
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:main" });
|
||||
expect(target.canonicalKey).toBe("agent:ops:work");
|
||||
// storeKeys must include the legacy mixed-case alias key
|
||||
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:MAIN"]));
|
||||
});
|
||||
|
||||
test("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => {
|
||||
const store: Record<string, unknown> = {
|
||||
"agent:ops:work": { sessionId: "canonical", updatedAt: 3 },
|
||||
"agent:ops:MAIN": { sessionId: "legacy-upper", updatedAt: 1 },
|
||||
"agent:ops:Main": { sessionId: "legacy-mixed", updatedAt: 2 },
|
||||
"agent:ops:main": { sessionId: "legacy-lower", updatedAt: 4 },
|
||||
};
|
||||
pruneLegacyStoreKeys({
|
||||
store,
|
||||
canonicalKey: "agent:ops:work",
|
||||
candidates: ["agent:ops:work", "agent:ops:main"],
|
||||
});
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionTitle", () => {
|
||||
|
||||
Reference in New Issue
Block a user