diff --git a/CHANGELOG.md b/CHANGELOG.md index e58206df890..2bf65b63cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 61de662839d..659fce77d78 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => ({ loadConfigReturn: {} as Record, 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, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 3e254bf11d4..f1041ce527b 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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; diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 0afbe341934..aab3d3cf7d7 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -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;