Gateway: reject delivered inter_session sentinel

This commit is contained in:
Rai Butera 2026-03-12 14:20:39 +00:00
parent 7e38aee263
commit d32cd5340e
3 changed files with 68 additions and 3 deletions

View File

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

View File

@ -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()

View File

@ -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()) &&