From 4225206f0cc139e76d080ff9f000d37723d542b0 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Fri, 13 Feb 2026 16:42:24 -0300 Subject: [PATCH] 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 --- src/gateway/server-methods/agent.test.ts | 68 ++++++- src/gateway/server-methods/agent.ts | 34 +++- src/gateway/server-methods/sessions.ts | 76 ++++---- src/gateway/server-node-events.ts | 25 ++- ...ions.gateway-server-sessions-a.e2e.test.ts | 123 +++++++++++++ src/gateway/session-utils.test.ts | 105 +++++++++++ src/gateway/session-utils.ts | 166 +++++++++++++++--- src/gateway/sessions-resolve.ts | 23 ++- 8 files changed, 544 insertions(+), 76 deletions(-) diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 797309d21c5..6ea54fcd76e 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -10,9 +10,13 @@ const mocks = vi.hoisted(() => ({ loadConfigReturn: {} as Record, })); -vi.mock("../session-utils.js", () => ({ - loadSessionEntry: mocks.loadSessionEntry, -})); +vi.mock("../session-utils.js", async () => { + const actual = await vi.importActual("../session-utils.js"); + return { + ...actual, + loadSessionEntry: mocks.loadSessionEntry, + }; +}); vi.mock("../../config/sessions.js", async () => { const actual = await vi.importActual( @@ -23,7 +27,13 @@ vi.mock("../../config/sessions.js", async () => { updateSessionStore: mocks.updateSessionStore, resolveAgentIdFromSessionKey: () => "main", resolveExplicitAgentSessionKey: () => undefined, - resolveAgentMainSessionKey: () => "agent:main:main", + resolveAgentMainSessionKey: ({ + cfg, + agentId, + }: { + cfg?: { session?: { mainKey?: string } }; + agentId: string; + }) => `agent:${agentId}:${cfg?.session?.mainKey ?? "main"}`, }; }); @@ -213,4 +223,54 @@ describe("gateway agent handler", () => { expect(capturedEntry?.cliSessionIds).toBeUndefined(); expect(capturedEntry?.claudeCliSessionId).toBeUndefined(); }); + + it("prunes legacy main alias keys when writing a canonical session entry", async () => { + mocks.loadSessionEntry.mockReturnValue({ + cfg: { + session: { mainKey: "work" }, + agents: { list: [{ id: "main", default: true }] }, + }, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "agent:main:work", + }); + + let capturedStore: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + "agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 }, + "agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 }, + }; + await updater(store); + capturedStore = store; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "test", + agentId: "main", + sessionKey: "main", + idempotencyKey: "test-idem-alias-prune", + }, + respond, + context: makeContext(), + req: { type: "req", id: "3", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedStore).toBeDefined(); + expect(capturedStore?.["agent:main:work"]).toBeDefined(); + expect(capturedStore?.["agent:main:MAIN"]).toBeUndefined(); + }); }); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6319a610255..5ae0df12e44 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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 | 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, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 5c3c4fe30ff..9dbe051a71e 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -31,6 +31,7 @@ import { listSessionsFromStore, loadCombinedSessionStoreForGateway, loadSessionEntry, + pruneLegacyStoreKeys, readSessionPreviewItemsFromTranscript, resolveGatewaySessionStoreTarget, resolveSessionModelRef, @@ -42,6 +43,31 @@ import { import { applySessionsPatchToStore } from "../sessions-patch.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; +function migrateAndPruneSessionStoreKey(params: { + cfg: ReturnType; + key: string; + store: Record; +}) { + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.key, + store: params.store, + }); + const primaryKey = target.canonicalKey; + if (!params.store[primaryKey]) { + const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate])); + if (existingKey) { + params.store[primaryKey] = params.store[existingKey]; + } + } + pruneLegacyStoreKeys({ + store: params.store, + canonicalKey: primaryKey, + candidates: target.storeKeys, + }); + return { target, primaryKey, entry: params.store[primaryKey] }; +} + export const sessionsHandlers: GatewayRequestHandlers = { "sessions.list": ({ params, respond }) => { if (!validateSessionsListParams(params)) { @@ -104,12 +130,16 @@ export const sessionsHandlers: GatewayRequestHandlers = { for (const key of keys) { try { - const target = resolveGatewaySessionStoreTarget({ cfg, key }); - const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath); - storeCache.set(target.storePath, store); - const entry = - target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ?? - store[target.canonicalKey]; + const storeTarget = resolveGatewaySessionStoreTarget({ cfg, key, scanLegacyKeys: false }); + const store = + storeCache.get(storeTarget.storePath) ?? loadSessionStore(storeTarget.storePath); + storeCache.set(storeTarget.storePath, store); + const target = resolveGatewaySessionStoreTarget({ + cfg, + key, + store, + }); + const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean); if (!entry?.sessionId) { previews.push({ key, status: "missing", items: [] }); continue; @@ -134,7 +164,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined); }, - "sessions.resolve": ({ params, respond }) => { + "sessions.resolve": async ({ params, respond }) => { if (!validateSessionsResolveParams(params)) { respond( false, @@ -149,7 +179,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const p = params; const cfg = loadConfig(); - const resolved = resolveSessionKeyFromResolveParams({ cfg, p }); + const resolved = await resolveSessionKeyFromResolveParams({ cfg, p }); if (!resolved.ok) { respond(false, undefined, resolved.error); return; @@ -179,12 +209,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const storePath = target.storePath; const applied = await updateSessionStore(storePath, async (store) => { - const primaryKey = target.storeKeys[0] ?? key; - const existingKey = target.storeKeys.find((candidate) => store[candidate]); - if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { - store[primaryKey] = store[existingKey]; - delete store[existingKey]; - } + const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); return await applySessionsPatchToStore({ cfg, store, @@ -235,12 +260,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const storePath = target.storePath; const next = await updateSessionStore(storePath, (store) => { - const primaryKey = target.storeKeys[0] ?? key; - const existingKey = target.storeKeys.find((candidate) => store[candidate]); - if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { - store[primaryKey] = store[existingKey]; - delete store[existingKey]; - } + const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); const entry = store[primaryKey]; const now = Date.now(); const nextEntry: SessionEntry = { @@ -331,12 +351,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } await updateSessionStore(storePath, (store) => { - const primaryKey = target.storeKeys[0] ?? key; - const existingKey = target.storeKeys.find((candidate) => store[candidate]); - if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { - store[primaryKey] = store[existingKey]; - delete store[existingKey]; - } + const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); if (store[primaryKey]) { delete store[primaryKey]; } @@ -392,13 +407,8 @@ export const sessionsHandlers: GatewayRequestHandlers = { const storePath = target.storePath; // Lock + read in a short critical section; transcript work happens outside. const compactTarget = await updateSessionStore(storePath, (store) => { - const primaryKey = target.storeKeys[0] ?? key; - const existingKey = target.storeKeys.find((candidate) => store[candidate]); - if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { - store[primaryKey] = store[existingKey]; - delete store[existingKey]; - } - return { entry: store[primaryKey], primaryKey }; + const { entry, primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); + return { entry, primaryKey }; }); const entry = compactTarget.entry; const sessionId = entry?.sessionId; diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 10933485bbd..b841b58671f 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -8,7 +8,11 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; -import { loadSessionEntry } from "./session-utils.js"; +import { + loadSessionEntry, + pruneLegacyStoreKeys, + resolveGatewaySessionStoreTarget, +} from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => { @@ -41,6 +45,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const sessionId = entry?.sessionId ?? randomUUID(); if (storePath) { await updateSessionStore(storePath, (store) => { + const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey, store }); + pruneLegacyStoreKeys({ + store, + canonicalKey: target.canonicalKey, + candidates: target.storeKeys, + }); store[canonicalKey] = { sessionId, updatedAt: now, @@ -58,7 +68,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt // Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send). // This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId). ctx.addChatRun(sessionId, { - sessionKey, + sessionKey: canonicalKey, clientRunId: `voice-${randomUUID()}`, }); @@ -66,7 +76,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt { message: text, sessionId, - sessionKey, + sessionKey: canonicalKey, thinking: "low", deliver: false, messageChannel: "node", @@ -113,11 +123,18 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const sessionKeyRaw = (link?.sessionKey ?? "").trim(); const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; + const cfg = loadConfig(); const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); if (storePath) { await updateSessionStore(storePath, (store) => { + const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey, store }); + pruneLegacyStoreKeys({ + store, + canonicalKey: target.canonicalKey, + candidates: target.storeKeys, + }); store[canonicalKey] = { sessionId, updatedAt: now, @@ -136,7 +153,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt { message, sessionId, - sessionKey, + sessionKey: canonicalKey, thinking: link?.thinking ?? undefined, deliver, to, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts index aad712f8c06..d7b2c1f3f71 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts @@ -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) => { + 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>; + + 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"); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index db1d0928f9e..aa0d518712b 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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 = { + "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", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 16299c6a11f..1c51a91e135 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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, + ...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, + 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; + canonicalKey: string; + candidates: Iterable; +}) { + const keysToDelete = new Set(); + 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; +}): { 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; 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, diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 1bf8edfd233..21b6779573c 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -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 { 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 }; }