refactor(channels): move read-gate policy to channel plugins (#125136)

* refactor(channels): move read-gate policy to plugins

* test(channels): declare bundled Discord read gates
This commit is contained in:
Peter Steinberger
2026-08-17 01:38:30 -07:00
committed by GitHub
parent fb0e8b145e
commit fcb499a4ce
12 changed files with 110 additions and 59 deletions
+2 -1
View File
@@ -185,7 +185,8 @@ function resolveRuntimeDiscordMessageActions() {
}
}
const discordMessageActions = {
const discordMessageActions: ChannelMessageActionAdapter = {
providerOwnedReadGates: true,
resolveExecutionMode: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["resolveExecutionMode"]>>[0],
) =>
+1
View File
@@ -1056,6 +1056,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
collectRuntimeConfigAssignments,
},
actions: {
providerOwnedReadGates: true,
messageActionTargetAliases,
describeMessageTool: describeFeishuMessageTool,
handleAction: async (ctx) => {
+1
View File
@@ -129,6 +129,7 @@ function resolveMatrixActionAccount(params: { cfg: CoreConfig; accountId?: strin
}
export const matrixMessageActions: ChannelMessageActionAdapter = {
providerOwnedReadGates: true,
describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
const resolvedCfg = cfg as CoreConfig;
const account = resolveMatrixActionAccount({ cfg: resolvedCfg, accountId });
+1
View File
@@ -388,6 +388,7 @@ async function listMattermostDirectoryPeers(params: MattermostDirectoryListParam
}
const mattermostMessageActions: ChannelMessageActionAdapter = {
providerOwnedReadGates: ["read"],
describeMessageTool: describeMattermostMessageTool,
extractToolSend: ({ args }) => extractMattermostToolSend(args),
extractToolSendResult: ({ result, send }) => extractMattermostToolSendResult(result, send),
+1
View File
@@ -585,6 +585,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
},
},
actions: {
providerOwnedReadGates: true,
describeMessageTool: describeMSTeamsMessageTool,
extractToolSendResult: ({ result, send }) => extractMSTeamsToolSendResult(result, send),
requiresTrustedRequesterSender: ({ action, toolContext }) =>
+1
View File
@@ -62,6 +62,7 @@ export function createSlackActions(
options?: { invoke?: SlackActionInvoke },
): ChannelMessageActionAdapter {
return {
providerOwnedReadGates: true,
describeMessageTool: describeSlackMessageTool,
extractToolSend: ({ args }) => extractSlackToolSend(args),
isToolDeliveryAction: ({ args }) =>
+1
View File
@@ -276,6 +276,7 @@ const telegramMessageAdapter = createChannelMessageAdapterFromOutbound<OpenClawC
});
const telegramMessageActions: ChannelMessageActionAdapter = {
providerOwnedReadGates: ["react", "edit", "delete"],
messageActionTargetAliases: telegramMessageActionsImpl.messageActionTargetAliases,
resolveExecutionMode: (ctx) =>
getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.resolveExecutionMode?.(ctx) ??
@@ -45,6 +45,15 @@ const SHARED_SANITIZER_CHANNEL_IDS = [
const MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS = ["imessage", "slack"] as const;
const SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS = ["feishu", "telegram"] as const;
const THREAD_BINDING_ARTIFACT_PLUGIN_IDS = ["discord", "matrix"] as const;
const PROVIDER_OWNED_READ_GATE_PLUGINS = [
["discord", true],
["feishu", true],
["matrix", true],
["msteams", true],
["slack", true],
["mattermost", ["read"]],
["telegram", ["react", "edit", "delete"]],
] as const;
type ExplicitSessionKeyNormalizer = (
sessionKey: string,
@@ -185,6 +194,13 @@ describe("bundled channel plugin shape coherence", () => {
},
);
it.each(PROVIDER_OWNED_READ_GATE_PLUGINS)(
"keeps the %s provider-owned read gate declaration on its registered plugin surface",
(id, expected) => {
expect(plugins.get(id)?.actions?.providerOwnedReadGates).toEqual(expected);
},
);
describe.each(bundledChannelPluginIds)("%s", (id) => {
it("keeps plugin identity aligned with the catalog id", () => {
const plugin = plugins.get(id);
@@ -14,26 +14,6 @@ import type {
ChannelPlugin,
} from "./types.js";
// These bundled adapters have host-reviewed provider-side current/configured
// gates. Other bundled adapters retain the exact-current compatibility limit.
const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES: ReadonlySet<string> = new Set([
"discord",
"feishu",
"matrix",
"msteams",
"slack",
]);
// Telegram owns exact topic/account binding for message mutations only. Other
// Telegram reads retain the host gate, including targetless sticker cache reads.
const BUNDLED_PROVIDER_READ_GATE_ACTIONS: ReadonlyMap<
string,
ReadonlySet<ChannelMessageActionName>
> = new Map([
["mattermost", new Set<ChannelMessageActionName>(["read"])],
["telegram", new Set<ChannelMessageActionName>(["react", "edit", "delete"])],
]);
declare const serverOwnedConversationReadOrigin: unique symbol;
type ServerOwnedConversationReadOrigin = ReturnType<
@@ -156,13 +136,13 @@ type MessageActionReadEnforcement =
function resolveMessageActionReadEnforcement(params: {
action: ChannelMessageActionName;
channel: string;
actions: ChannelPlugin["actions"];
pluginOrigin: string | undefined;
}): MessageActionReadEnforcement {
const providerOwnedReadGates = params.actions?.providerOwnedReadGates;
if (
params.pluginOrigin === "bundled" &&
(BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel) ||
BUNDLED_PROVIDER_READ_GATE_ACTIONS.get(params.channel)?.has(params.action) === true)
(providerOwnedReadGates === true || providerOwnedReadGates?.includes(params.action) === true)
) {
return { kind: "provider-owned" };
}
@@ -571,7 +551,7 @@ function prepareMessageActionReadContext(
actionPolicy,
enforcement: resolveMessageActionReadEnforcement({
action,
channel: actionContext.channel,
actions: registration.plugin.actions,
pluginOrigin: registration.origin,
}),
};
@@ -17,7 +17,11 @@ function dispatchTestChannelMessageAction(
...overrides,
});
}
import type { ChannelMessageActionContext, ChannelPlugin } from "./types.js";
import type {
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelPlugin,
} from "./types.js";
const handleAction = vi.fn(async (_ctx: ChannelMessageActionContext) => jsonResult({ ok: true }));
@@ -95,9 +99,9 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
function setReadPlugin(params?: {
channel?: ChannelPlugin["id"];
origin?: string;
strayPolicy?: string;
normalizeTarget?: (raw: string) => string | undefined;
targetPrefixes?: readonly string[];
providerOwnedReadGates?: true | readonly ChannelMessageActionName[];
messageActionTargetAliases?: NonNullable<
NonNullable<ChannelPlugin["actions"]>["messageActionTargetAliases"]
>;
@@ -121,9 +125,7 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
}
: {}),
actions: {
...(params?.strayPolicy
? ({ conversationReadPolicy: params.strayPolicy } as Record<string, unknown>)
: {}),
providerOwnedReadGates: params?.providerOwnedReadGates,
describeMessageTool: () => ({ actions: ["read", "send"] }),
supportsAction,
requiresTrustedRequesterSender,
@@ -511,8 +513,54 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
expect(handleAction).toHaveBeenCalledOnce();
});
it.each([
{
name: "declared bundled adapter",
channel: "declared-bundled",
origin: "bundled",
providerOwnedReadGates: true,
allowed: true,
},
{
name: "undeclared bundled adapter",
channel: "undeclared-bundled",
origin: "bundled",
providerOwnedReadGates: undefined,
allowed: false,
},
{
name: "declared external adapter",
channel: "declared-external",
origin: "workspace",
providerOwnedReadGates: true,
allowed: false,
},
] as const)("applies provider-owned read gates for a $name", async (testCase) => {
setReadPlugin(testCase);
const dispatch = dispatchTestChannelMessageAction({
channel: testCase.channel,
action: "read",
params: { channelId: "configured" },
accountId: "default",
requesterAccountId: "default",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelProvider: testCase.channel,
currentChannelId: "current",
},
});
if (testCase.allowed) {
await dispatch;
expect(handleAction).toHaveBeenCalledOnce();
return;
}
await expect(dispatch).rejects.toThrow("requires the exact current conversation and account");
expect(handleAction).not.toHaveBeenCalled();
});
it("delegates configured-target policy to a bundled adapter", async () => {
setReadPlugin({ origin: "bundled" });
setReadPlugin({ origin: "bundled", providerOwnedReadGates: true });
await dispatchTestChannelMessageAction({
channel: "discord",
@@ -525,7 +573,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
});
it("delegates Mattermost cross-channel policy to its bundled provider gate", async () => {
setReadPlugin({ channel: "mattermost", origin: "bundled" });
setReadPlugin({
channel: "mattermost",
origin: "bundled",
providerOwnedReadGates: ["read"],
});
await dispatchChannelMessageAction({
channel: "mattermost",
@@ -539,7 +591,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
});
it("keeps Mattermost reactions behind the host exact-current gate", async () => {
setReadPlugin({ channel: "mattermost", origin: "bundled" });
setReadPlugin({
channel: "mattermost",
origin: "bundled",
providerOwnedReadGates: ["read"],
});
await expect(
dispatchChannelMessageAction({
@@ -560,7 +616,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
});
it("keeps unaudited bundled adapters on the exact-current host limit", async () => {
setReadPlugin({ channel: "telegram", origin: "bundled" });
setReadPlugin({
channel: "telegram",
origin: "bundled",
providerOwnedReadGates: ["react", "edit", "delete"],
});
await expect(
dispatchTestChannelMessageAction({
@@ -582,7 +642,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
it.each(["react", "edit", "delete"] as const)(
"delegates Telegram %s topic binding to the bundled provider",
async (action) => {
setReadPlugin({ channel: "telegram", origin: "bundled" });
setReadPlugin({
channel: "telegram",
origin: "bundled",
providerOwnedReadGates: ["react", "edit", "delete"],
});
await dispatchTestChannelMessageAction({
channel: "telegram",
@@ -603,7 +667,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
);
it("does not grant Telegram mutation enforcement to an external override", async () => {
setReadPlugin({ channel: "telegram", origin: "workspace" });
setReadPlugin({
channel: "telegram",
origin: "workspace",
providerOwnedReadGates: ["react", "edit", "delete"],
});
await expect(
dispatchTestChannelMessageAction({
@@ -1522,29 +1590,6 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
expect(handleAction).not.toHaveBeenCalled();
});
it("does not let an external adapter opt into bundled behavior with a stray property", async () => {
setReadPlugin({
origin: "workspace",
strayPolicy: "current-or-configured-v1",
});
await expect(
dispatchTestChannelMessageAction({
channel: "discord",
action: "read",
params: { channelId: "configured" },
accountId: "default",
requesterAccountId: "default",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "current",
},
}),
).rejects.toThrow("requires the exact current conversation and account");
expect(handleAction).not.toHaveBeenCalled();
});
it.each([undefined, "unknown", "global", "workspace", "config"] as const)(
"treats %s channel provenance as non-bundled",
async (origin) => {
+2
View File
@@ -747,6 +747,8 @@ export type ChannelMessageActionAdapter = {
describeMessageTool: (
params: ChannelMessageActionDiscoveryContext,
) => ChannelMessageToolDiscovery | null | undefined;
/** Delegate conversation-read authorization to this adapter for bundled registrations only. */
providerOwnedReadGates?: true | readonly ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
resolveExecutionMode?: (params: { action: ChannelMessageActionName }) => "local" | "gateway";
resolveCliActionRequest?: (params: {
@@ -495,6 +495,7 @@ describe("runMessageAction plugin dispatch", () => {
},
actions: {
describeMessageTool: () => ({ actions: ["channel-delete", "channel-info"] }),
providerOwnedReadGates: true,
supportsAction: ({ action }) => action === "channel-delete" || action === "channel-info",
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(toolContext) && action === "channel-delete",