From 1feb21bb02acf749331c7c056ff80911d21a41a6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 19 Apr 2026 17:19:18 -0400 Subject: [PATCH] fix: guard deleted main-agent sessions --- CHANGELOG.md | 1 + src/gateway/session-store-key.ts | 28 +++++++++++++++++++++++++--- src/gateway/session-utils.test.ts | 28 ++++++++++++++++++++++++++++ src/gateway/session-utils.ts | 11 +++++------ src/gateway/sessions-resolve.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd60b682b8f..7e3444582c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Active Memory: raise the blocking recall timeout ceiling to 120 seconds and reject larger config values during plugin schema validation. Fixes #68410. (#68480) Thanks @Bartok9. - Control UI/chat: keep history-backed user image uploads visible after chat reload while filtering blocked or non-image transcript media paths. (#68415) Thanks @mraleko. - Matrix/plugins: keep remaining Matrix event helpers on the canonical `matrix-js-sdk` subpath so build and plugin-load entrypoint checks stay consistent. (#68498) Thanks @masatohoshino. +- Gateway/sessions: reject stale agent-scoped sessions after an agent is removed from config while preserving legacy default-agent main-session aliases. (#65986) Thanks @bittoby. ## 2026.4.15 diff --git a/src/gateway/session-store-key.ts b/src/gateway/session-store-key.ts index 206a9a9566b..be08c9fbb35 100644 --- a/src/gateway/session-store-key.ts +++ b/src/gateway/session-store-key.ts @@ -1,13 +1,15 @@ -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { canonicalizeMainSessionAlias, resolveMainSessionKey, } from "../config/sessions/main-session.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, + type ParsedAgentSessionKey, } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -29,6 +31,22 @@ function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string { return normalizeAgentId(resolveDefaultAgentId(cfg)); } +function resolveParsedSessionStoreAgentId( + cfg: OpenClawConfig, + parsed: ParsedAgentSessionKey, +): string { + const agentId = normalizeAgentId(parsed.agentId); + if (agentId !== DEFAULT_AGENT_ID || listAgentIds(cfg).includes(DEFAULT_AGENT_ID)) { + return agentId; + } + const rest = normalizeLowercaseStringOrEmpty(parsed.rest); + const mainKey = normalizeMainKey(cfg.session?.mainKey); + if (rest === "main" || rest === mainKey) { + return resolveDefaultStoreAgentId(cfg); + } + return agentId; +} + export function resolveSessionStoreKey(params: { cfg: OpenClawConfig; sessionKey: string; @@ -44,8 +62,12 @@ export function resolveSessionStoreKey(params: { const parsed = parseAgentSessionKey(raw); if (parsed) { - const agentId = normalizeAgentId(parsed.agentId); - const lowered = normalizeLowercaseStringOrEmpty(raw); + const originalAgentId = normalizeAgentId(parsed.agentId); + const agentId = resolveParsedSessionStoreAgentId(params.cfg, parsed); + const lowered = + agentId === originalAgentId + ? normalizeLowercaseStringOrEmpty(raw) + : `agent:${agentId}:${normalizeLowercaseStringOrEmpty(parsed.rest)}`; const canonical = canonicalizeMainSessionAlias({ cfg: params.cfg, agentId, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 8a68652cbfd..300758aaaeb 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -16,6 +16,7 @@ import { migrateAndPruneGatewaySessionStoreKey, parseGroupKey, pruneLegacyStoreKeys, + resolveDeletedAgentIdFromSessionKey, resolveGatewayModelSupportsImages, resolveGatewaySessionStoreTarget, resolveSessionModelIdentityRef, @@ -105,9 +106,36 @@ describe("gateway session utils", () => { expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:main" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:MAIN" })).toBe("agent:ops:work"); + expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:main:main" })).toBe("agent:ops:work"); + expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:main:work" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "MAIN" })).toBe("agent:ops:work"); }); + test("resolveSessionStoreKey preserves non-alias agent:main keys for deleted-agent checks", () => { + const cfg = { + session: { mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:main:discord:direct:u1" })).toBe( + "agent:main:discord:direct:u1", + ); + }); + + test("resolveDeletedAgentIdFromSessionKey rejects non-alias main keys when main is absent", () => { + const cfg = { + session: { mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + const legacyMainAlias = resolveSessionStoreKey({ cfg, sessionKey: "agent:main:main" }); + + expect(legacyMainAlias).toBe("agent:ops:work"); + expect(resolveDeletedAgentIdFromSessionKey(cfg, legacyMainAlias)).toBeNull(); + expect(resolveDeletedAgentIdFromSessionKey(cfg, "global")).toBeNull(); + expect(resolveDeletedAgentIdFromSessionKey(cfg, "unknown")).toBeNull(); + expect(resolveDeletedAgentIdFromSessionKey(cfg, "main")).toBeNull(); + expect(resolveDeletedAgentIdFromSessionKey(cfg, "agent:main:discord:direct:u1")).toBe("main"); + }); + test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => { const cfg = { session: { mainKey: "main" }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 51933299c81..8c2216be861 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -42,11 +42,9 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { - DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, - resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { @@ -396,17 +394,18 @@ function resolveTranscriptUsageFallback(params: { /** * Returns the owning agent id if the session key belongs to an agent that is no - * longer present in config (deleted). Returns null for legacy/global keys, the - * default agent, or when the owning agent still exists (#65524). + * longer present in config (deleted). Returns null for non-agent legacy/global + * keys, or when the owning agent still exists (#65524). */ export function resolveDeletedAgentIdFromSessionKey( cfg: OpenClawConfig, sessionKey: string, ): string | null { - const agentId = resolveAgentIdFromSessionKey(sessionKey); - if (agentId === DEFAULT_AGENT_ID) { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { return null; } + const agentId = normalizeAgentId(parsed.agentId); if (listAgentIds(cfg).includes(agentId)) { return null; } diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index 9d188325070..a40fde36e3a 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -158,6 +158,32 @@ describe("resolveSessionKeyFromResolveParams", () => { }); }); + it("rejects non-alias agent:main sessions when main is no longer configured", async () => { + const staleMainKey = "agent:main:discord:direct:u1"; + hoisted.resolveGatewaySessionStoreTargetMock.mockReturnValue({ + canonicalKey: staleMainKey, + storeKeys: [staleMainKey], + storePath, + }); + hoisted.loadSessionStoreMock.mockReturnValue({ + [staleMainKey]: { sessionId: "sess-stale-main", updatedAt: 1 }, + }); + hoisted.listAgentIdsMock.mockReturnValue(["ops"]); + + const result = await resolveSessionKeyFromResolveParams({ + cfg: { agents: { list: [{ id: "ops", default: true }] } }, + p: { key: staleMainKey }, + }); + + expect(result).toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "main" no longer exists in configuration', + }, + }); + }); + it("rejects sessions belonging to a deleted agent (sessionId-based lookup)", async () => { const deletedAgentKey = "agent:deleted-agent:main"; hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({