mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
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:
committed by
GitHub
parent
fb0e8b145e
commit
fcb499a4ce
@@ -185,7 +185,8 @@ function resolveRuntimeDiscordMessageActions() {
|
||||
}
|
||||
}
|
||||
|
||||
const discordMessageActions = {
|
||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
providerOwnedReadGates: true,
|
||||
resolveExecutionMode: (
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["resolveExecutionMode"]>>[0],
|
||||
) =>
|
||||
|
||||
@@ -1056,6 +1056,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
actions: {
|
||||
providerOwnedReadGates: true,
|
||||
messageActionTargetAliases,
|
||||
describeMessageTool: describeFeishuMessageTool,
|
||||
handleAction: async (ctx) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -585,6 +585,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
providerOwnedReadGates: true,
|
||||
describeMessageTool: describeMSTeamsMessageTool,
|
||||
extractToolSendResult: ({ result, send }) => extractMSTeamsToolSendResult(result, send),
|
||||
requiresTrustedRequesterSender: ({ action, toolContext }) =>
|
||||
|
||||
@@ -62,6 +62,7 @@ export function createSlackActions(
|
||||
options?: { invoke?: SlackActionInvoke },
|
||||
): ChannelMessageActionAdapter {
|
||||
return {
|
||||
providerOwnedReadGates: true,
|
||||
describeMessageTool: describeSlackMessageTool,
|
||||
extractToolSend: ({ args }) => extractSlackToolSend(args),
|
||||
isToolDeliveryAction: ({ args }) =>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user