mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(slack): honor HTTP setup credentials without dropping shared secrets (#128659)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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).",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user