fix: guard sessions.send/steer against deleted agents (#65524)

This commit is contained in:
bittoby
2026-04-17 14:58:16 +02:00
committed by Gustavo Madeira Santana
parent b87d619072
commit 386623191f
4 changed files with 106 additions and 16 deletions

View File

@@ -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<typeof import("../session-utils.js")>("../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<string>(),
} 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',
});
});
}
});

View File

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

View File

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

View File

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