mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 23:16:37 +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:
@@ -419,6 +419,129 @@ describe("gateway server sessions", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-preview-alias-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
testState.sessionStorePath = storePath;
|
||||
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
|
||||
testState.sessionConfig = { mainKey: "work" };
|
||||
const sessionId = "sess-legacy-main";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Legacy alias transcript" } }),
|
||||
];
|
||||
await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:ops:MAIN": {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { ws } = await openClient();
|
||||
const preview = await rpcReq<{
|
||||
previews: Array<{
|
||||
key: string;
|
||||
status: string;
|
||||
items: Array<{ role: string; text: string }>;
|
||||
}>;
|
||||
}>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 });
|
||||
|
||||
expect(preview.ok).toBe(true);
|
||||
const entry = preview.payload?.previews[0];
|
||||
expect(entry?.key).toBe("main");
|
||||
expect(entry?.status).toBe("ok");
|
||||
expect(entry?.items[0]?.text).toContain("Legacy alias transcript");
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.resolve and mutators clean legacy main-alias ghost keys", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-cleanup-alias-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
testState.sessionStorePath = storePath;
|
||||
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
|
||||
testState.sessionConfig = { mainKey: "work" };
|
||||
const sessionId = "sess-alias-cleanup";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
await fs.writeFile(
|
||||
transcriptPath,
|
||||
`${Array.from({ length: 8 })
|
||||
.map((_, idx) => JSON.stringify({ role: "assistant", content: `line ${idx}` }))
|
||||
.join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const writeRawStore = async (store: Record<string, unknown>) => {
|
||||
await fs.writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf-8");
|
||||
};
|
||||
const readStore = async () =>
|
||||
JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, Record<string, unknown>>;
|
||||
|
||||
await writeRawStore({
|
||||
"agent:ops:MAIN": { sessionId, updatedAt: Date.now() - 2_000 },
|
||||
"agent:ops:Main": { sessionId, updatedAt: Date.now() - 1_000 },
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
|
||||
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
|
||||
key: "main",
|
||||
});
|
||||
expect(resolved.ok).toBe(true);
|
||||
expect(resolved.payload?.key).toBe("agent:ops:work");
|
||||
let store = await readStore();
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
|
||||
await writeRawStore({
|
||||
...store,
|
||||
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||
});
|
||||
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
|
||||
key: "main",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
expect(patched.ok).toBe(true);
|
||||
expect(patched.payload?.key).toBe("agent:ops:work");
|
||||
store = await readStore();
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
expect(store["agent:ops:work"]?.thinkingLevel).toBe("medium");
|
||||
|
||||
await writeRawStore({
|
||||
...store,
|
||||
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||
});
|
||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", {
|
||||
key: "main",
|
||||
maxLines: 3,
|
||||
});
|
||||
expect(compacted.ok).toBe(true);
|
||||
expect(compacted.payload?.compacted).toBe(true);
|
||||
store = await readStore();
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
|
||||
await writeRawStore({
|
||||
...store,
|
||||
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||
});
|
||||
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", { key: "main" });
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.key).toBe("agent:ops:work");
|
||||
store = await readStore();
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.delete rejects main and aborts active runs", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
Reference in New Issue
Block a user