From d32cd5340eed5f25285e5d9804cd20a68722bee8 Mon Sep 17 00:00:00 2001 From: Rai Butera Date: Thu, 12 Mar 2026 14:20:39 +0000 Subject: [PATCH] Gateway: reject delivered inter_session sentinel --- src/gateway/server-methods/agent.test.ts | 50 ++++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 14 +++++++ src/infra/outbound/agent-delivery.ts | 7 ++-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index f8f06efa996..4ecc540b36e 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js"; +import * as channelSelection from "../../infra/outbound/channel-selection.js"; import { agentHandlers } from "./agent.js"; import type { GatewayRequestContext } from "./types.js"; @@ -477,6 +478,55 @@ describe("gateway agent handler", () => { expect(callArgs?.runContext?.messageChannel).toBe("inter_session"); }); + it("rejects deliver=true when backend callers use the inter_session sentinel", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const selectionSpy = vi.spyOn(channelSelection, "resolveMessageChannelSelection"); + selectionSpy.mockResolvedValue({ + channel: "telegram", + configured: ["telegram"], + source: "single-configured", + }); + + const respond = await invokeAgent( + { + message: "strict delivery", + agentId: "main", + sessionKey: "agent:main:main", + channel: "inter_session", + deliver: true, + idempotencyKey: "test-inter-session-backend-deliver", + }, + { + reqId: "inter-session-backend-deliver-1", + client: { + connect: { + role: "operator", + scopes: ["operator.write"], + client: { + id: "gateway-client", + mode: "backend", + version: "1.0.0", + platform: "node", + }, + }, + } as unknown as AgentHandlerArgs["client"], + }, + ); + + selectionSpy.mockRestore(); + + expect(respond).toHaveBeenCalledTimes(1); + const [ok, payload, error] = respond.mock.calls[0] ?? []; + expect(ok).toBe(false); + expect(payload).toBeUndefined(); + expect(error).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("inter_session"), + }); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + }); + it("only forwards workspaceDir for spawned subagent runs", async () => { primeMainAgentRun(); mocks.agentCommand.mockClear(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 29b619ea794..7f34062ae84 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -481,6 +481,20 @@ export const agentHandlers: GatewayRequestHandlers = { } const wantsDelivery = request.deliver === true; + const requestedInterSessionForDelivery = [request.channel, request.replyChannel].some((value) => + isInterSessionChannel(normalizeMessageChannel(value)), + ); + if (wantsDelivery && requestedInterSessionForDelivery) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "delivery channel cannot use the inter_session sentinel", + ), + ); + return; + } const explicitTo = typeof request.replyTo === "string" && request.replyTo.trim() ? request.replyTo.trim() diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index d5d1e4322a3..16e530a4e09 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -88,9 +88,10 @@ export function resolveAgentDeliveryPlan(params: { }); const resolvedChannel = (() => { - // Hard-reject internal sentinel channels. INTER_SESSION_CHANNEL is excluded - // from listGatewayMessageChannels() so external callers are already blocked, - // but defend here too in case the channel reaches delivery via another path. + // Internal sentinels must never resolve to a deliverable channel. Keep + // them on the internal/webchat path here so non-delivery flows stay + // internal; callers that request real delivery must reject sentinels + // before reaching this planner. if ( requestedChannel && RESERVED_CHANNEL_IDS.has(requestedChannel.toLowerCase()) &&