fix(gateway,agent): only enforce session sendPolicy=deny when delivering (#76317)

Summary:
- This PR gates gateway `agent` send-policy rejection on `request.deliver === true`, adds denied non-delivery  ... plicit-delivery regression coverage, updates a gateway chat expectation, and adds a #73381 changelog entry.
- Reproducibility: yes. from source inspection: current main resolves `sendPolicy` and rejects before delivery ... agent` request with `deliver` omitted or false. I did not run local tests because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(gateway,agent): only enforce session sendPolicy=deny when delivering

Validation:
- ClawSweeper review passed for head 5cfcb1c584.
- Required merge gates passed before the squash merge.

Prepared head SHA: 5cfcb1c584
Review: https://github.com/openclaw/openclaw/pull/76317#issuecomment-4364987993

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: wenxu007 <270593229+wenxu007@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-03 02:05:23 +00:00
committed by GitHub
parent 3d64fcaf1f
commit c149046c45
4 changed files with 74 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.
- Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli.
- Gateway/agent: enforce `session.sendPolicy=deny` on gateway agent requests only when `deliver: true`, so non-delivery smoke checks and internal agent runs are no longer rejected with `send blocked by session policy` while outbound delivery remains gated. Fixes #73381. Thanks @wenxu007.
- Slack/reactions: treat missing no_reaction remove responses as idempotent success and route own-reaction cleanup through the remove helper, so concurrent cleanup no longer surfaces Slack race errors. Fixes #50733. (#76304) Thanks @martingarramon and @Hollychou924.
- Control UI/Gateway: avoid full session-list reloads for locally applied message-phase session updates, carry known session keys through transcript-file update events, and defer media provider listing when explicit generation model config is present. Refs #76236, #76203, #76188, #76107, and #76166. Thanks @BunsDev.
- Install/update: prune the obsolete `plugin-runtime-deps` state directory during packaged postinstall so upgrades from pre-2026.5.2 releases reclaim old bundled-plugin dependency caches without touching external plugin installs.

View File

@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => ({
loadConfigReturn: {} as Record<string, unknown>,
loadVoiceWakeRoutingConfig: vi.fn(),
resolveVoiceWakeRouteByTrigger: vi.fn(),
resolveSendPolicy: vi.fn(() => "allow"),
}));
vi.mock("../session-utils.js", async () => {
@@ -128,7 +129,8 @@ vi.mock("../../infra/voicewake-routing.js", () => ({
}));
vi.mock("../../sessions/send-policy.js", () => ({
resolveSendPolicy: () => "allow",
resolveSendPolicy: (...args: unknown[]) =>
(mocks.resolveSendPolicy as (...args: unknown[]) => unknown)(...args),
}));
vi.mock("../../utils/delivery-context.js", async () => {
@@ -410,6 +412,7 @@ describe("gateway agent handler", () => {
mocks.resolveExplicitAgentSessionKey.mockReset().mockReturnValue(undefined);
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
mocks.resolveSendPolicy.mockReset().mockReturnValue("allow");
});
it("preserves ACP metadata from the current stored session entry", async () => {
@@ -2792,6 +2795,53 @@ describe("gateway agent handler", () => {
);
});
it("allows non-delivery agent invocations when sendPolicy is deny", async () => {
mocks.agentCommand.mockClear();
primeMainAgentRun();
mocks.resolveSendPolicy.mockReturnValue("deny");
const respond = await runMainAgent("smoke", "non-delivery-deny");
expect(mocks.resolveSendPolicy).not.toHaveBeenCalled();
expect(respond).not.toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "send blocked by session policy" }),
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalledTimes(1));
});
it("blocks delivery agent invocations when sendPolicy is deny", async () => {
primeMainAgentRun();
mocks.resolveSendPolicy.mockReturnValue("deny");
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "smoke",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "delivery-deny",
deliver: true,
},
{ respond, reqId: "delivery-deny" },
);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "send blocked by session policy" }),
);
expect(mocks.resolveSendPolicy).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ sessionId: "existing-session-id" }),
sessionKey: "agent:main:main",
}),
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
describe("groupId session-entry persistence validation", () => {
async function captureGroupEntryFields(
sessionKey: string,

View File

@@ -1069,20 +1069,22 @@ export const agentHandlers: GatewayRequestHandlers = {
claudeCliSessionId: entry?.claudeCliSessionId,
};
sessionEntry = mergeSessionEntry(entry, nextEntryPatch);
const sendPolicy = resolveSendPolicy({
cfg,
entry,
sessionKey: canonicalKey,
channel: entry?.channel,
chatType: entry?.chatType,
});
if (sendPolicy === "deny") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy"),
);
return;
if (request.deliver === true) {
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionEntry,
sessionKey: canonicalKey,
channel: sessionEntry?.channel,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy"),
);
return;
}
}
resolvedSessionId = sessionId;
const canonicalSessionKey = canonicalKey;

View File

@@ -488,15 +488,16 @@ describe("gateway server chat", () => {
},
});
const agentBlockedRes = await rpcReq(ws, "agent", {
vi.mocked(agentCommand).mockClear();
const agentAllowedRes = await rpcReq(ws, "agent", {
sessionKey: "cron:job-1",
message: "hi",
idempotencyKey: "idem-2",
});
expect(agentBlockedRes.ok).toBe(false);
expect((agentBlockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
/send blocked/i,
);
expect(agentAllowedRes.ok).toBe(true);
expect(agentAllowedRes.payload?.status).toBe("accepted");
expect(agentAllowedRes.payload?.runId).toBe("idem-2");
await vi.waitFor(() => expect(agentCommand).toHaveBeenCalled());
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;