fix(discord): handle SecretRefs in message actions

This commit is contained in:
Clawdbot
2026-05-01 12:16:00 +10:00
parent 456e1c0a6a
commit 2df54bd949
5 changed files with 136 additions and 41 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Auto-reply/docking: require `/dock-*` route switches to start from direct chats, so group or channel participants cannot reroute a shared session's future replies into a linked DM. Thanks @vincentkoc.
- Discord: keep text-DM main-session route updates pinned to the configured DM owner, matching component interactions so another direct-message sender cannot redirect future main-session replies. Thanks @vincentkoc.
- Mattermost/Matrix: keep direct-message main-session route updates pinned to the configured DM owner so paired or temporarily allowed senders cannot redirect future shared-session replies. Thanks @vincentkoc.
- Discord: keep SecretRef-backed bot tokens discoverable for message actions without resolving the token during schema generation, and resolve scoped channel SecretRefs before outbound agent message sends even when the tool is built from a config snapshot. Fixes #75324. Thanks @slideshow-dingo and @Conan-Scott.
- Updates: run package post-install doctor repair with the managed Gateway service profile and state paths when a daemon is installed, so shell/profile mismatches no longer repair the caller state while the restarted Gateway keeps stale config. Thanks @vincentkoc.
- Models/DeepInfra: declare DeepInfra manifest catalog discovery and derive its runtime fallback catalog from the manifest, restoring provider-filtered `models list --all --provider deepinfra` rows without duplicated static model data. Thanks @shakkernerd.
- CLI/update: verify managed gateway restarts against the installed service port instead of the caller shell port, so package updates do not report a healthy daemon as failed when profiles use different gateway ports. Thanks @vincentkoc.

View File

@@ -61,6 +61,58 @@ describe("discordMessageActions", () => {
expect(discovery?.actions).not.toContain("role-add");
});
it("describes actions when the Discord token is an unresolved SecretRef", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
actions: {
polls: true,
reactions: true,
},
},
},
} as unknown as OpenClawConfig,
});
expect(discovery?.capabilities).toEqual(["presentation"]);
expect(discovery?.actions).toEqual(
expect.arrayContaining(["send", "poll", "react", "reactions", "emoji-list"]),
);
});
it("describes scoped account actions when the account token is an unresolved SecretRef", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
polls: true,
reactions: false,
},
accounts: {
ops: {
token: { source: "file", provider: "filemain", id: "/DISCORD_BOT_TOKEN" },
actions: {
polls: false,
reactions: true,
},
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "ops",
});
expect(discovery?.actions).toEqual(
expect.arrayContaining(["send", "react", "reactions", "emoji-list"]),
);
expect(discovery?.actions).not.toContain("poll");
});
it("honors account-scoped action gates during discovery", () => {
const cfg = {
channels: {

View File

@@ -1,20 +1,14 @@
import {
createUnionActionGate,
listTokenSourcedAccounts,
} from "openclaw/plugin-sdk/channel-actions";
import { createUnionActionGate } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-types";
import type { DiscordActionConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import {
createDiscordActionGate,
listEnabledDiscordAccounts,
resolveDiscordAccount,
} from "./accounts.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
let discordChannelActionsRuntimePromise:
| Promise<typeof import("./channel-actions.runtime.js")>
@@ -25,8 +19,14 @@ async function loadDiscordChannelActionsRuntime() {
return await discordChannelActionsRuntimePromise;
}
function resolveDiscordActionDiscovery(cfg: Parameters<typeof listEnabledDiscordAccounts>[0]) {
const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg));
function listDiscoverableDiscordAccounts(cfg: OpenClawConfig) {
return listDiscordAccountIds(cfg)
.map((accountId) => inspectDiscordAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}
function resolveDiscordActionDiscovery(cfg: OpenClawConfig) {
const accounts = listDiscoverableDiscordAccounts(cfg);
if (accounts.length === 0) {
return null;
}
@@ -43,14 +43,14 @@ function resolveDiscordActionDiscovery(cfg: Parameters<typeof listEnabledDiscord
}
function resolveScopedDiscordActionDiscovery(params: {
cfg: Parameters<typeof listEnabledDiscordAccounts>[0];
cfg: OpenClawConfig;
accountId?: string | null;
}) {
if (!params.accountId) {
return resolveDiscordActionDiscovery(params.cfg);
}
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.token.trim()) {
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.configured) {
return null;
}
const gate = createDiscordActionGate({

View File

@@ -280,6 +280,51 @@ describe("message tool secret scoping", () => {
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
);
});
it("resolves scoped channel SecretRefs even when constructed with a config snapshot", async () => {
mockSendResult({ channel: "discord", to: "channel:123" });
const rawConfig = {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
};
const resolvedConfig = {
channels: {
discord: {
token: "resolved-discord-token",
},
},
};
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig,
diagnostics: [],
});
const tool = createMessageTool({
config: rawConfig as never,
currentChannelProvider: "discord",
currentChannelId: "channel:123",
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
runMessageAction: mocks.runMessageAction as never,
});
await tool.execute("1", {
action: "send",
message: "hi",
});
const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls.at(-1)?.[0] as {
config?: unknown;
targetIds?: Set<string>;
allowedPaths?: Set<string>;
};
expect(secretResolveCall.config).toBe(rawConfig);
expect(secretResolveCall.targetIds).toEqual(new Set(["channels.discord.token"]));
expect(secretResolveCall.allowedPaths).toEqual(new Set(["channels.discord.token"]));
expect(mocks.runMessageAction.mock.calls[0]?.[0]?.cfg).toBe(resolvedConfig);
});
});
describe("message tool agent routing", () => {

View File

@@ -703,32 +703,29 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
}
let cfg = options?.config;
if (!cfg) {
const loadedRaw = loadConfigForTool();
const scope = resolveMessageSecretScope({
channel: params.channel,
target: params.target,
targets: params.targets,
fallbackChannel: options?.currentChannelProvider,
accountId: params.accountId,
fallbackAccountId: agentAccountId,
});
const scopedTargets = getScopedSecretTargetsForTool({
config: loadedRaw,
channel: scope.channel,
accountId: scope.accountId,
});
cfg = (
await resolveSecretRefsForTool({
config: loadedRaw,
commandName: "tools.message",
targetIds: scopedTargets.targetIds,
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
mode: "enforce_resolved",
})
).resolvedConfig;
}
const rawConfig = options?.config ?? loadConfigForTool();
const scope = resolveMessageSecretScope({
channel: params.channel,
target: params.target,
targets: params.targets,
fallbackChannel: options?.currentChannelProvider,
accountId: params.accountId,
fallbackAccountId: agentAccountId,
});
const scopedTargets = getScopedSecretTargetsForTool({
config: rawConfig,
channel: scope.channel,
accountId: scope.accountId,
});
const cfg = (
await resolveSecretRefsForTool({
config: rawConfig,
commandName: "tools.message",
targetIds: scopedTargets.targetIds,
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
mode: "enforce_resolved",
})
).resolvedConfig;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
if (accountId) {