fix(slack): honor HTTP setup credentials without dropping shared secrets (#128659)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Leon-SK668
2026-08-25 15:16:44 +08:00
committed by GitHub
parent 79c2d77138
commit 2bdbfebd2e
3 changed files with 217 additions and 31 deletions
@@ -5,9 +5,12 @@ import {
installChannelStatusContractSuite,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { moveSingleAccountChannelSectionToDefaultAccount } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "../api.js";
import { SlackConfigSchema } from "../config-api.js";
import { slackSetupPlugin } from "../setup-plugin-api.js";
import { inspectSlackAccount } from "./account-inspect.js";
const slackDefaultActions = [
"send",
@@ -64,6 +67,95 @@ describe("slack actions contract", () => {
});
describe("slack setup contract", () => {
it("keeps a shared HTTP signing secret at the channel root during account promotion", () => {
const cfg = {
channels: {
slack: {
enabled: true,
mode: "http",
botToken: "xoxb-default",
signingSecret: "shared-signing-secret",
},
},
} as OpenClawConfig;
const next = moveSingleAccountChannelSectionToDefaultAccount({
cfg,
channelKey: "slack",
setupSurface: slackSetupPlugin.setupContract,
});
expect(next.channels?.slack?.signingSecret).toBe("shared-signing-secret");
expect(next.channels?.slack).not.toHaveProperty("botToken");
expect(next.channels?.slack?.accounts?.default).toMatchObject({
botToken: "xoxb-default",
});
expect(inspectSlackAccount({ cfg: next, accountId: "default" })).toMatchObject({
configured: true,
signingSecret: "shared-signing-secret",
});
expect(SlackConfigSchema.safeParse(next.channels?.slack).success).toBe(true);
});
it.each([
{
name: "an HTTP account with its own signing secret",
account: {
mode: "http" as const,
botToken: "xoxb-target",
signingSecret: "target-signing-secret",
},
},
{
name: "a Socket Mode account",
account: { mode: "socket" as const, botToken: "xoxb-target", appToken: "xapp-target" },
},
{
name: "a relay account",
account: {
mode: "relay" as const,
botToken: "xoxb-target",
relay: {
url: "https://relay.example.test",
authToken: "relay-auth-token",
gatewayId: "relay-gateway",
},
},
},
])("preserves sibling HTTP credentials when promoting $name", ({ account }) => {
const cfg = {
channels: {
slack: {
enabled: true,
mode: "http",
signingSecret: "shared-signing-secret",
accounts: {
default: account,
bot: { mode: "http", botToken: "xoxb-sibling" },
user: { mode: "http", postAs: "user", userToken: "xoxp-sibling" },
},
},
},
} as OpenClawConfig;
const next = moveSingleAccountChannelSectionToDefaultAccount({
cfg,
channelKey: "slack",
setupSurface: slackSetupPlugin.setupContract,
});
expect(next.channels?.slack?.signingSecret).toBe("shared-signing-secret");
expect(SlackConfigSchema.safeParse(next.channels?.slack).success).toBe(true);
expect(inspectSlackAccount({ cfg: next, accountId: "bot" })).toMatchObject({
configured: true,
signingSecret: "shared-signing-secret",
});
expect(inspectSlackAccount({ cfg: next, accountId: "user" })).toMatchObject({
configured: true,
signingSecret: "shared-signing-secret",
});
});
it("recognizes HTTP bot accounts at the setup plugin boundary without an app token", () => {
const cfg = {
channels: {
@@ -262,7 +354,28 @@ describe("slack setup contract", () => {
"Slack user identity setup does not support --use-env; configure userToken and the transport credential explicitly.",
},
{
name: "explicit bot identity keeps the bot and app token setup contract",
name: "HTTP bot identity stores the bot token and signing secret",
cfg: {} as OpenClawConfig,
input: {
identity: "bot",
mode: "http",
botToken: "test-bot-token",
signingSecret: "test-signing-secret",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.slack).toMatchObject({
enabled: true,
postAs: "bot",
mode: "http",
botToken: "test-bot-token",
signingSecret: "test-signing-secret",
});
expect(cfg.channels?.slack?.appToken).toBeUndefined();
},
},
{
name: "HTTP bot identity rejects an app token without a signing secret",
cfg: {} as OpenClawConfig,
input: {
identity: "bot",
@@ -271,15 +384,8 @@ describe("slack setup contract", () => {
appToken: "test-app-token",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.slack).toMatchObject({
enabled: true,
postAs: "bot",
botToken: "test-bot-token",
appToken: "test-app-token",
});
expect(cfg.channels?.slack?.mode).toBeUndefined();
},
expectedValidation:
"Slack HTTP mode requires --bot-token and --signing-secret (or --use-env).",
},
],
});
+21 -21
View File
@@ -163,14 +163,10 @@ function hasSlackSetupCredentials(params: {
identity: "bot" | "user";
mode: "socket" | "http" | "relay";
}): boolean {
if (params.identity !== "user") {
const { input } = params;
return Boolean(input.botToken && input.appToken);
}
if (params.mode === "http") {
return Boolean(params.input.userToken && params.input.signingSecret);
}
return params.mode === "socket" && Boolean(params.input.userToken && params.input.appToken);
const identityToken = params.identity === "user" ? params.input.userToken : params.input.botToken;
const transportCredential =
params.mode === "http" ? params.input.signingSecret : params.input.appToken;
return Boolean(identityToken && transportCredential);
}
const slackSetupAdapterBase = createPatchedAccountSetupAdapter({
@@ -214,13 +210,15 @@ const slackSetupAdapterBase = createPatchedAccountSetupAdapter({
? "Slack user identity requires --user-token and --signing-secret."
: "Slack user identity requires --user-token and --app-token.";
}
return "Slack requires --bot-token and --app-token (or --use-env).";
return mode === "http"
? "Slack HTTP mode requires --bot-token and --signing-secret (or --use-env)."
: "Slack requires --bot-token and --app-token (or --use-env).";
},
buildPatch: (input) => {
const setupInput = input as SlackSetupInput;
return {
...(setupInput.identity ? { postAs: setupInput.identity } : {}),
...(setupInput.identity === "user" && setupInput.mode ? { mode: setupInput.mode } : {}),
...(setupInput.mode ? { mode: setupInput.mode } : {}),
...(setupInput.botToken ? { botToken: setupInput.botToken } : {}),
...(setupInput.appToken ? { appToken: setupInput.appToken } : {}),
...(setupInput.userToken ? { userToken: setupInput.userToken } : {}),
@@ -379,12 +377,17 @@ export function createSlackSetupWizardBase(handlers: {
envShortcut: {
prompt: t("wizard.slack.envPrompt"),
preferredEnvVar: "SLACK_BOT_TOKEN",
isAvailable: ({ cfg, accountId }) =>
accountId === DEFAULT_ACCOUNT_ID &&
(inspectSlackAccount({ cfg, accountId }).config.postAs ?? "bot") === "bot" &&
Boolean(process.env.SLACK_BOT_TOKEN?.trim()) &&
Boolean(process.env.SLACK_APP_TOKEN?.trim()) &&
!inspectSlackAccount({ cfg, accountId }).configured,
isAvailable: ({ cfg, accountId }) => {
const account = inspectSlackAccount({ cfg, accountId });
return (
accountId === DEFAULT_ACCOUNT_ID &&
(account.config.postAs ?? "bot") === "bot" &&
(account.config.mode ?? "socket") === "socket" &&
Boolean(process.env.SLACK_BOT_TOKEN?.trim()) &&
Boolean(process.env.SLACK_APP_TOKEN?.trim()) &&
!account.configured
);
},
apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId),
},
credentials: [
@@ -417,10 +420,7 @@ export function createSlackSetupWizardBase(handlers: {
inputPrompt: t("wizard.slack.appTokenInput"),
shouldPrompt: ({ cfg, accountId }) => {
const account = inspectSlackAccount({ cfg, accountId });
return (
(account.config.postAs ?? "bot") === "bot" ||
(account.config.mode ?? "socket") === "socket"
);
return (account.config.mode ?? "socket") === "socket";
},
}),
createSlackTokenCredential({
@@ -431,7 +431,7 @@ export function createSlackSetupWizardBase(handlers: {
inputPrompt: "Enter Slack signing secret",
shouldPrompt: ({ cfg, accountId }) => {
const account = inspectSlackAccount({ cfg, accountId });
return account.config.postAs === "user" && account.config.mode === "http";
return account.config.mode === "http";
},
}),
],
@@ -252,6 +252,86 @@ describe("slackSetupWizard.prepare", () => {
expect(result.cfg.channels?.slack?.appToken).toBeUndefined();
});
it("collects a signing secret instead of an app token for HTTP bot identity", async () => {
vi.stubEnv("SLACK_BOT_TOKEN", "");
vi.stubEnv("SLACK_APP_TOKEN", "");
const queued = createQueuedWizardPrompter({
selectValues: ["bot"],
textValues: ["test-bot-token", "test-signing-secret"],
});
const configure = createSetupWizardAdapter({
plugin: {
id: "slack",
meta: { label: "Slack" },
config: {
listAccountIds: () => ["default"],
defaultAccountId: () => "default",
},
setupContract: slackSetupContract,
} as never,
wizard: credentialOnlySlackSetupWizard,
}).configure;
const result = await runSetupWizardConfigure({
configure,
cfg: { channels: { slack: { mode: "http" } } } as OpenClawConfig,
prompter: queued.prompter,
options: { secretInputMode: "plaintext" as const },
});
expect(result.cfg.channels?.slack).toMatchObject({
enabled: true,
mode: "http",
botToken: "test-bot-token",
signingSecret: "test-signing-secret",
});
expect(result.cfg.channels?.slack?.appToken).toBeUndefined();
expect(
queued.text.mock.calls.map(([params]) => (params as { message: string }).message),
).toEqual(["Enter Slack bot token (xoxb-...)", "Enter Slack signing secret"]);
});
it("does not use the Socket Mode environment shortcut for HTTP bot setup", async () => {
vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-env-test");
vi.stubEnv("SLACK_APP_TOKEN", "xapp-env-test");
const queued = createQueuedWizardPrompter({
selectValues: ["bot"],
confirmValues: [true],
textValues: ["test-signing-secret"],
});
const configure = createSetupWizardAdapter({
plugin: {
id: "slack",
meta: { label: "Slack" },
config: {
listAccountIds: () => ["default"],
defaultAccountId: () => "default",
},
setupContract: slackSetupContract,
} as never,
wizard: credentialOnlySlackSetupWizard,
}).configure;
const result = await runSetupWizardConfigure({
configure,
cfg: { channels: { slack: { mode: "http" } } } as OpenClawConfig,
prompter: queued.prompter,
options: { secretInputMode: "plaintext" as const },
});
expect(result.cfg.channels?.slack).toMatchObject({
enabled: true,
mode: "http",
signingSecret: "test-signing-secret",
});
expect(result.cfg.channels?.slack?.botToken).toBeUndefined();
expect(result.cfg.channels?.slack?.appToken).toBeUndefined();
expect(queued.confirm).toHaveBeenCalledTimes(1);
expect(
queued.text.mock.calls.map(([params]) => (params as { message: string }).message),
).toEqual(["Enter Slack signing secret"]);
});
it("continues user setup after preserving a user-token SecretRef", async () => {
const queued = createQueuedWizardPrompter({
selectValues: ["user"],