mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 06:57:26 +00:00
fix(gateway): block cross-session fallback in node event delivery
This commit is contained in:
@@ -30,18 +30,20 @@ vi.mock("./session-utils.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
|
||||||
import type { HealthSummary } from "../commands/health.js";
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
|
import type { NodeEventContext } from "./server-node-events-types.js";
|
||||||
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { updateSessionStore } from "../config/sessions.js";
|
import { updateSessionStore } from "../config/sessions.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import type { NodeEventContext } from "./server-node-events-types.js";
|
|
||||||
import { handleNodeEvent } from "./server-node-events.js";
|
import { handleNodeEvent } from "./server-node-events.js";
|
||||||
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
||||||
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
||||||
const agentCommandMock = vi.mocked(agentCommand);
|
const agentCommandMock = vi.mocked(agentCommand);
|
||||||
const updateSessionStoreMock = vi.mocked(updateSessionStore);
|
const updateSessionStoreMock = vi.mocked(updateSessionStore);
|
||||||
|
const loadSessionEntryMock = vi.mocked(loadSessionEntry);
|
||||||
|
|
||||||
function buildCtx(): NodeEventContext {
|
function buildCtx(): NodeEventContext {
|
||||||
return {
|
return {
|
||||||
@@ -267,3 +269,80 @@ describe("voice transcript events", () => {
|
|||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("voice session-store update failed"));
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("voice session-store update failed"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("agent request events", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
agentCommandMock.mockReset();
|
||||||
|
updateSessionStoreMock.mockReset();
|
||||||
|
loadSessionEntryMock.mockReset();
|
||||||
|
agentCommandMock.mockResolvedValue({ status: "ok" } as never);
|
||||||
|
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
|
||||||
|
update({});
|
||||||
|
});
|
||||||
|
loadSessionEntryMock.mockImplementation((sessionKey: string) => ({
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
entry: { sessionId: `sid-${sessionKey}` },
|
||||||
|
canonicalKey: sessionKey,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables delivery when route is unresolved instead of falling back globally", async () => {
|
||||||
|
const warn = vi.fn();
|
||||||
|
const ctx = buildCtx();
|
||||||
|
ctx.logGateway = { warn };
|
||||||
|
|
||||||
|
await handleNodeEvent(ctx, "node-route-miss", {
|
||||||
|
event: "agent.request",
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
message: "summarize this",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
deliver: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [opts] = agentCommandMock.mock.calls[0] ?? [];
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
message: "summarize this",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
deliver: false,
|
||||||
|
channel: undefined,
|
||||||
|
to: undefined,
|
||||||
|
});
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("agent delivery disabled node=node-route-miss"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the current session route when delivery target is omitted", async () => {
|
||||||
|
const ctx = buildCtx();
|
||||||
|
loadSessionEntryMock.mockReturnValueOnce({
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
entry: {
|
||||||
|
sessionId: "sid-current",
|
||||||
|
lastChannel: "telegram",
|
||||||
|
lastTo: "123",
|
||||||
|
},
|
||||||
|
canonicalKey: "agent:main:main",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleNodeEvent(ctx, "node-route-hit", {
|
||||||
|
event: "agent.request",
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
message: "route on session",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
deliver: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [opts] = agentCommandMock.mock.calls[0] ?? [];
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
message: "route on session",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
deliver: true,
|
||||||
|
channel: "telegram",
|
||||||
|
to: "123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
|
||||||
import { resolveSessionAgentId } from "../agents/agent-scope.js";
|
import { resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore } from "../config/sessions.js";
|
|
||||||
import { updateSessionStore } from "../config/sessions.js";
|
import { updateSessionStore } from "../config/sessions.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||||
@@ -14,7 +14,6 @@ import { normalizeMainKey } from "../routing/session-key.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { parseMessageWithAttachments } from "./chat-attachments.js";
|
import { parseMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
|
import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
|
||||||
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
|
|
||||||
import {
|
import {
|
||||||
loadSessionEntry,
|
loadSessionEntry,
|
||||||
pruneLegacyStoreKeys,
|
pruneLegacyStoreKeys,
|
||||||
@@ -185,45 +184,6 @@ function queueSessionStoreTouch(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFallbackDeliveryRoute(params: {
|
|
||||||
storePath: LoadedSessionEntry["storePath"];
|
|
||||||
preferredChannel?: string;
|
|
||||||
}): { channel?: string; to?: string } {
|
|
||||||
const { storePath, preferredChannel } = params;
|
|
||||||
if (!storePath) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetChannel = preferredChannel?.trim().toLowerCase();
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const candidates = Object.values(store)
|
|
||||||
.filter((entry) => {
|
|
||||||
if (!entry || typeof entry !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const channel = typeof entry.lastChannel === "string" ? entry.lastChannel.trim() : "";
|
|
||||||
const to = typeof entry.lastTo === "string" ? entry.lastTo.trim() : "";
|
|
||||||
if (!channel || !to) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (targetChannel && channel.toLowerCase() !== targetChannel) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
||||||
|
|
||||||
const winner = candidates[0];
|
|
||||||
if (!winner) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
channel: typeof winner.lastChannel === "string" ? winner.lastChannel.trim() : undefined,
|
|
||||||
to: typeof winner.lastTo === "string" ? winner.lastTo.trim() : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
|
function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -394,7 +354,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
|
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
|
||||||
let channel = normalizeChannelId(channelRaw) ?? undefined;
|
let channel = normalizeChannelId(channelRaw) ?? undefined;
|
||||||
let to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
|
let to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
|
||||||
const deliver = Boolean(link?.deliver);
|
const deliverRequested = Boolean(link?.deliver);
|
||||||
const wantsReceipt = Boolean(link?.receipt);
|
const wantsReceipt = Boolean(link?.receipt);
|
||||||
const receiptTextRaw = typeof link?.receiptText === "string" ? link.receiptText.trim() : "";
|
const receiptTextRaw = typeof link?.receiptText === "string" ? link.receiptText.trim() : "";
|
||||||
const receiptText =
|
const receiptText =
|
||||||
@@ -408,7 +368,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
await touchSessionStore({ cfg, sessionKey, storePath, canonicalKey, entry, sessionId, now });
|
await touchSessionStore({ cfg, sessionKey, storePath, canonicalKey, entry, sessionId, now });
|
||||||
|
|
||||||
if (deliver && (!channel || !to)) {
|
if (deliverRequested && (!channel || !to)) {
|
||||||
const entryChannel =
|
const entryChannel =
|
||||||
typeof entry?.lastChannel === "string"
|
typeof entry?.lastChannel === "string"
|
||||||
? normalizeChannelId(entry.lastChannel)
|
? normalizeChannelId(entry.lastChannel)
|
||||||
@@ -421,33 +381,30 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
to = entryTo;
|
to = entryTo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deliver && (!channel || !to)) {
|
const deliver = deliverRequested && Boolean(channel && to);
|
||||||
const fallback = resolveFallbackDeliveryRoute({
|
const deliveryChannel = deliver ? channel : undefined;
|
||||||
storePath,
|
const deliveryTo = deliver ? to : undefined;
|
||||||
preferredChannel: channel ?? cfg.channels?.default ?? "telegram",
|
|
||||||
});
|
if (deliverRequested && !deliver) {
|
||||||
if (!channel && fallback.channel) {
|
ctx.logGateway.warn(
|
||||||
channel = normalizeChannelId(fallback.channel) ?? channel;
|
`agent delivery disabled node=${nodeId}: missing session delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
|
||||||
}
|
);
|
||||||
if (!to && fallback.to) {
|
|
||||||
to = fallback.to;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsReceipt && channel && to) {
|
if (wantsReceipt && deliveryChannel && deliveryTo) {
|
||||||
void sendReceiptAck({
|
void sendReceiptAck({
|
||||||
cfg,
|
cfg,
|
||||||
deps: ctx.deps,
|
deps: ctx.deps,
|
||||||
sessionKey: canonicalKey,
|
sessionKey: canonicalKey,
|
||||||
channel,
|
channel: deliveryChannel,
|
||||||
to,
|
to: deliveryTo,
|
||||||
text: receiptText,
|
text: receiptText,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
ctx.logGateway.warn(`agent receipt failed node=${nodeId}: ${formatForLog(err)}`);
|
ctx.logGateway.warn(`agent receipt failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
});
|
});
|
||||||
} else if (wantsReceipt) {
|
} else if (wantsReceipt) {
|
||||||
ctx.logGateway.warn(
|
ctx.logGateway.warn(
|
||||||
`agent receipt skipped node=${nodeId}: missing delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
|
`agent receipt skipped node=${nodeId}: missing delivery route (channel=${deliveryChannel ?? "-"} to=${deliveryTo ?? "-"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +416,8 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
sessionKey: canonicalKey,
|
sessionKey: canonicalKey,
|
||||||
thinking: link?.thinking ?? undefined,
|
thinking: link?.thinking ?? undefined,
|
||||||
deliver,
|
deliver,
|
||||||
to,
|
to: deliveryTo,
|
||||||
channel,
|
channel: deliveryChannel,
|
||||||
timeout:
|
timeout:
|
||||||
typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined,
|
typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined,
|
||||||
messageChannel: "node",
|
messageChannel: "node",
|
||||||
|
|||||||
Reference in New Issue
Block a user