From 6831f05184406a15b66adc38bbde04bbccde13fb Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:58:08 -0500 Subject: [PATCH] fix(message): authorize fallback broadcast provider --- src/agents/tools/message-tool.test.ts | 45 +++++++++++++++++++++++++++ src/agents/tools/message-tool.ts | 31 +++++++++++------- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 17ec649f36f1..7994314d7357 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -404,6 +404,7 @@ function createChannelPlugin(params: { config?: Partial; message?: ChannelMessageAdapterShape; messaging?: ChannelPlugin["messaging"]; + outbound?: ChannelPlugin["outbound"]; }): ChannelPlugin { return { id: params.id as ChannelPlugin["id"], @@ -423,6 +424,7 @@ function createChannelPlugin(params: { }, ...(params.message ? { message: params.message } : {}), ...(params.messaging ? { messaging: params.messaging } : {}), + ...(params.outbound ? { outbound: params.outbound } : {}), actions: { describeMessageTool: params.describeMessageTool ?? @@ -1771,6 +1773,44 @@ describe("message tool secret scoping", () => { broadcastChannel: "slack", broadcastTargets: ["slack:channel:one", "slack:channel:two"], }, + { + name: "delegated fallback-resolved current-provider broadcast", + channel: "googlechat", + accountId: "alternate", + requesterAccountId: "current", + trusted: true, + origin: undefined, + rejected: true, + broadcast: true, + broadcastChannel: "last", + broadcastTargets: ["googlechat:spaces/current"], + }, + { + name: "delegated fallback-resolved broadcast with matching current account", + channel: "googlechat", + accountId: "current", + requesterAccountId: "current", + trusted: true, + origin: undefined, + rejected: false, + broadcast: true, + broadcastChannel: "last", + broadcastTargets: ["googlechat:spaces/current"], + expectedRunnerChannel: "googlechat", + }, + { + name: "direct fallback-resolved broadcast with alternate account", + channel: "googlechat", + accountId: "alternate", + requesterAccountId: undefined, + trusted: false, + origin: "direct-operator" as const, + rejected: false, + broadcast: true, + broadcastChannel: "last", + broadcastTargets: ["googlechat:spaces/current"], + expectedRunnerChannel: "googlechat", + }, { name: "delegated channel-less broadcast with matching current account", channel: "googlechat", @@ -1806,6 +1846,7 @@ describe("message tool secret scoping", () => { listAccountIds: () => ["current", "alternate"], resolveAccount: () => ({ enabled: true }), }, + outbound: { deliveryMode: "direct", sendText: vi.fn() as never }, }); const slackPlugin = createChannelPlugin({ id: "slack", @@ -1817,6 +1858,7 @@ describe("message tool secret scoping", () => { listAccountIds: () => ["alternate"], resolveAccount: () => ({ enabled: true }), }, + outbound: { deliveryMode: "direct", sendText: vi.fn() as never }, }); setActivePluginRegistry( createTestRegistry([ @@ -1905,6 +1947,9 @@ describe("message tool secret scoping", () => { await expect(invocation).resolves.toBeDefined(); expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledOnce(); expect(mocks.runMessageAction).toHaveBeenCalledOnce(); + if ("expectedRunnerChannel" in testCase) { + expect(firstRunMessageActionInput()?.params?.channel).toBe(testCase.expectedRunnerChannel); + } }, ); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4b9572d33d3c..35cff9476557 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -51,6 +51,7 @@ import { import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; import { createAbortError } from "../../infra/abort-signal.js"; import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; +import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import { resolveMessageBroadcastAccountPlan, validateExplicitMessageAccountSelection, @@ -1589,12 +1590,26 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const gatewayOpts = readGatewayCallOptions(params); const rawConfig = options?.config ?? loadConfigForTool(); const requestedAccountId = readStringParam(params, "accountId"); - const requestedScope = resolveMessageSecretScope({ - channel: params.channel, - target: params.target, - targets: params.targets, + validateExplicitMessageAccountSelection({ + cfg: rawConfig, accountId: requestedAccountId, + checkResolvedAccount: false, }); + const requestedBroadcastChannel = normalizeOptionalLowercaseString(params.channel); + if ( + action === "broadcast" && + requestedBroadcastChannel && + requestedBroadcastChannel !== "all" + ) { + // Authorize and execute the same canonical provider. Otherwise an unavailable + // hint can fall back to the current provider only after account authorization. + const selection = await resolveMessageChannelSelection({ + cfg: rawConfig, + channel: requestedBroadcastChannel, + fallbackChannel: effectiveCurrentChannel.currentChannelProvider, + }); + params.channel = selection.channel; + } const scope = resolveMessageSecretScope({ channel: params.channel, target: params.target, @@ -1603,7 +1618,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { accountId: requestedAccountId, fallbackAccountId: agentAccountId, }); - const requestedBroadcastChannel = normalizeOptionalLowercaseString(params.channel); // Broadcast execution only narrows on an explicit non-all channel. Target // prefixes cannot authorize fewer providers than the runner will execute. const unscopedExplicitBroadcast = @@ -1627,12 +1641,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { explicitAccountId, selectedChannels: broadcastAccountPlan ? broadcastAccountPlan.candidateChannels - : [ - requestedScope.channel ?? - (action === "broadcast" - ? undefined - : trustedTurnContext?.toolContext?.currentChannelProvider), - ], + : [scope.channel], trustedCurrentChannel: trustedTurnContext?.toolContext?.currentChannelProvider, trustedRequesterAccountId: trustedTurnContext?.requesterAccountId, hasTrustedTurnContext: trustedTurnContext !== undefined,