fix: guard deleted main-agent sessions

This commit is contained in:
Gustavo Madeira Santana
2026-04-19 17:19:18 -04:00
parent 386623191f
commit 1feb21bb02
5 changed files with 85 additions and 9 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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" },

View File

@@ -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;
}

View File

@@ -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({