diff --git a/extensions/slack/src/channel-actions-setup-status.contract.test.ts b/extensions/slack/src/channel-actions-setup-status.contract.test.ts index 55ca2e4239da..6dfa6a649e2f 100644 --- a/extensions/slack/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/slack/src/channel-actions-setup-status.contract.test.ts @@ -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).", }, ], }); diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 4c0aca4fd45c..711768b0106c 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -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"; }, }), ], diff --git a/extensions/slack/src/setup-surface.test.ts b/extensions/slack/src/setup-surface.test.ts index 6e28eba434a8..9ba31b791aa8 100644 --- a/extensions/slack/src/setup-surface.test.ts +++ b/extensions/slack/src/setup-surface.test.ts @@ -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"],