From 386623191f4b29050731e338e6c97abfd8addd95 Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:58:16 +0200 Subject: [PATCH] fix: guard sessions.send/steer against deleted agents (#65524) --- .../sessions.send-deleted-agent.test.ts | 59 +++++++++++++++++++ src/gateway/server-methods/sessions.ts | 16 ++++- src/gateway/session-utils.ts | 22 +++++++ src/gateway/sessions-resolve.ts | 25 ++++---- 4 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 src/gateway/server-methods/sessions.send-deleted-agent.test.ts diff --git a/src/gateway/server-methods/sessions.send-deleted-agent.test.ts b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts new file mode 100644 index 00000000000..447cc639467 --- /dev/null +++ b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../protocol/index.js"; +import type { GatewayRequestContext, RespondFn } from "./types.js"; + +const loadSessionEntryMock = vi.fn(); +const resolveDeletedAgentIdFromSessionKeyMock = vi.fn(); + +vi.mock("../session-utils.js", async () => { + const actual = await vi.importActual("../session-utils.js"); + return { + ...actual, + loadSessionEntry: (...args: unknown[]) => loadSessionEntryMock(...args), + resolveDeletedAgentIdFromSessionKey: (...args: unknown[]) => + resolveDeletedAgentIdFromSessionKeyMock(...args), + }; +}); + +import { sessionsHandlers } from "./sessions.js"; + +describe("sessions.send / sessions.steer deleted-agent guard", () => { + beforeEach(() => { + loadSessionEntryMock.mockReset(); + resolveDeletedAgentIdFromSessionKeyMock.mockReset(); + }); + + for (const method of ["sessions.send", "sessions.steer"] as const) { + it(`${method} rejects keys belonging to a deleted agent`, async () => { + const orphanKey = "agent:deleted-agent:main"; + loadSessionEntryMock.mockReturnValue({ + cfg: {}, + canonicalKey: orphanKey, + storePath: "/tmp/sessions.json", + entry: { sessionId: "sess-orphan" }, + }); + resolveDeletedAgentIdFromSessionKeyMock.mockReturnValue("deleted-agent"); + + const respond = vi.fn() as unknown as RespondFn; + const context = { + chatAbortControllers: new Map(), + broadcastToConnIds: vi.fn(), + getSessionEventSubscriberConnIds: () => new Set(), + } as unknown as GatewayRequestContext; + + await sessionsHandlers[method]({ + req: { id: "req-1" } as never, + params: { key: orphanKey, message: "hi" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith(false, undefined, { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "deleted-agent" no longer exists in configuration', + }); + }); + } +}); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bb9ab29fc35..a83b71570f0 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -72,6 +72,7 @@ import { loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, readSessionPreviewItemsFromTranscript, + resolveDeletedAgentIdFromSessionKey, resolveFreshestSessionEntryFromStoreKeys, resolveGatewaySessionStoreTarget, resolveSessionModelRef, @@ -461,7 +462,20 @@ async function handleSessionSend(params: { if (!key) { return; } - const { entry, canonicalKey, storePath } = loadSessionEntry(key); + const { cfg, entry, canonicalKey, storePath } = loadSessionEntry(key); + // Reject sends/steers targeting sessions whose owning agent was deleted (#65524). + const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey); + if (deletedAgentId !== null) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Agent "${deletedAgentId}" no longer exists in configuration`, + ), + ); + return; + } if (!entry?.sessionId) { params.respond( false, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 282b93f79b6..51933299c81 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { + listAgentIds, resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride, resolveAgentWorkspaceDir, @@ -41,9 +42,11 @@ 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 { @@ -391,6 +394,25 @@ 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). + */ +export function resolveDeletedAgentIdFromSessionKey( + cfg: OpenClawConfig, + sessionKey: string, +): string | null { + const agentId = resolveAgentIdFromSessionKey(sessionKey); + if (agentId === DEFAULT_AGENT_ID) { + return null; + } + if (listAgentIds(cfg).includes(agentId)) { + return null; + } + return agentId; +} + export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index d3e565be98c..40e39e20f03 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -1,7 +1,5 @@ -import { listAgentIds } from "../agents/agent-scope.js"; import { loadSessionStore, updateSessionStore } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { DEFAULT_AGENT_ID, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { parseSessionLabel } from "../sessions/session-label.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { @@ -14,6 +12,7 @@ import { listSessionsFromStore, loadCombinedSessionStoreForGateway, migrateAndPruneGatewaySessionStoreKey, + resolveDeletedAgentIdFromSessionKey, resolveGatewaySessionStoreTarget, } from "./session-utils.js"; @@ -40,21 +39,17 @@ function validateSessionAgentExists( cfg: OpenClawConfig, key: string, ): SessionsResolveResult | null { - const agentId = resolveAgentIdFromSessionKey(key); - // DEFAULT_AGENT_ID cannot be deleted and is implied by legacy/global keys. - if (agentId === DEFAULT_AGENT_ID) { + const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, key); + if (deletedAgentId === null) { return null; } - if (!listAgentIds(cfg).includes(agentId)) { - return { - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - `Agent "${agentId}" no longer exists in configuration`, - ), - }; - } - return null; + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `Agent "${deletedAgentId}" no longer exists in configuration`, + ), + }; } function isResolvedSessionKeyVisible(params: {