feat: opt into the AI agent Slack scopes at connect, store the signing secret in instance settings, surface the integration prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-09 15:45:08 +02:00
co-authored by Claude Opus 5.5
parent 44029ccba2
commit d0ef9dee91
12 changed files with 197 additions and 49 deletions
+1 -1
View File
@@ -1 +1 @@
80c4c3a29f60d04baad601cd5bbac13ed8d33d17
e6c86a632b05cc0561392cb8cdbe9e63f2ae4f28
+6
View File
@@ -5854,6 +5854,12 @@ pub async fn apply_base_url_setting(
#[cfg(feature = "oauth2")]
{
windmill_api::set_slack_signing_secret_setting(
oauths
.as_ref()
.and_then(|o| o.get("slack"))
.and_then(|c| c.signing_secret.as_deref()),
);
if let Some(db) = conn.as_sql() {
let clients = windmill_api::oauth2_oss::build_oauth_clients(&base_url, oauths, db).await
.map_err(|e| tracing::error!("Error building oauth clients (is the oauth.json mounted and in correct format? Use '{}' as minimal oauth.json): {}", "{}", e))
+1 -1
View File
@@ -13470,7 +13470,7 @@ paths:
description: the connected Slack workspace; absent when Slack is not connected
signing_secret_set:
type: boolean
description: agents answer in Slack only when the instance sets SLACK_SIGNING_SECRET
description: agents answer in Slack only when the instance has the Slack app's signing secret (SLACK_SIGNING_SECRET or the Slack OAuth instance setting)
bot_name:
type: string
description: the bot's Slack handle; absent when Slack can't be reached
+30 -2
View File
@@ -246,15 +246,43 @@ pub use windmill_oauth::OAUTH_CLIENTS;
#[cfg(feature = "oauth2")]
lazy_static::lazy_static! {
pub static ref SLACK_SIGNING_SECRET: Option<SlackVerifier> = std::env::var("SLACK_SIGNING_SECRET")
static ref SLACK_SIGNING_SECRET_ENV: Option<SlackVerifier> = std::env::var("SLACK_SIGNING_SECRET")
.ok()
.map(|x| SlackVerifier::new(x).unwrap());
/// The `signing_secret` of the instance settings' Slack OAuth entry.
static ref SLACK_SIGNING_SECRET_SETTING: std::sync::RwLock<Option<SlackVerifier>> =
std::sync::RwLock::new(None);
// Comma-separated Slack v2 OAuth bot scopes requested when connecting a workspace.
// Must be a subset of the bot scopes declared in the Slack app manifest. Default matches
// Windmill's recommended manifest at docs.windmill.dev/docs/misc/setup_oauth.
pub static ref SLACK_OAUTH_SCOPES: String = std::env::var("SLACK_OAUTH_SCOPES")
.unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read,users:read,users:read.email,channels:read".to_string());
.unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read".to_string());
}
/// Added to `SLACK_OAUTH_SCOPES` when a workspace connects with AI agents enabled: who sent a
/// message (by email) and the channel picker. Opt-in, as a Slack app must declare every scope
/// it is asked for.
#[cfg(feature = "oauth2")]
pub const SLACK_AGENT_SCOPES: &[&str] = &["users:read", "users:read.email", "channels:read"];
/// The env var wins over the instance setting.
#[cfg(feature = "oauth2")]
pub fn slack_signing_secret() -> Option<SlackVerifier> {
SLACK_SIGNING_SECRET_ENV
.clone()
.or_else(|| SLACK_SIGNING_SECRET_SETTING.read().ok()?.clone())
}
#[cfg(feature = "oauth2")]
pub fn set_slack_signing_secret_setting(secret: Option<&str>) {
let verifier = secret
.filter(|s| !s.is_empty())
.and_then(|s| SlackVerifier::new(s).ok());
if let Ok(mut current) = SLACK_SIGNING_SECRET_SETTING.write() {
*current = verifier;
}
}
// Compliance with cloud events spec.
+2 -2
View File
@@ -116,7 +116,7 @@ async fn list_agent_channels(
.await?;
tx.commit().await?;
#[cfg(feature = "oauth2")]
let signing_secret_set = crate::SLACK_SIGNING_SECRET.is_some();
let signing_secret_set = crate::slack_signing_secret().is_some();
#[cfg(not(feature = "oauth2"))]
let signing_secret_set = false;
let bot_name = match slack_team_name {
@@ -345,7 +345,7 @@ pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result<bool> {
// Without it the Slack endpoints take unsigned requests, and the sender's `user_id` would
// pick whose permissions the agent runs with.
#[cfg(feature = "oauth2")]
if crate::SLACK_SIGNING_SECRET.is_none() {
if crate::slack_signing_secret().is_none() {
return Ok(false);
}
let text = decode_entities(&msg.text);
+1 -1
View File
@@ -145,7 +145,7 @@ struct PrivateMetadata {
// `verify_slack_payload`), which holds even when this check is a no-op.
#[cfg(feature = "oauth2")]
fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> {
if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() {
if let Some(sv) = crate::slack_signing_secret() {
let sig = headers
.get("X-Slack-Signature")
.and_then(|v| v.to_str().ok())
@@ -1138,15 +1138,17 @@ fn mask_nested_sensitive(key: &str, value: &serde_json::Value) -> serde_json::Va
}
}
// Settings that are maps-of-objects where each child has a sensitive sub-field.
const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[("oauths", "secret")];
for &(parent_key, child_field) in NESTED_MAP_SENSITIVE {
const NESTED_MAP_SENSITIVE: &[(&str, &[&str])] = &[("oauths", &["secret", "signing_secret"])];
for &(parent_key, child_fields) in NESTED_MAP_SENSITIVE {
if key == parent_key {
if let serde_json::Value::Object(entries) = value {
let mut masked = entries.clone();
for (_entry_key, entry_val) in masked.iter_mut() {
if let serde_json::Value::Object(ref mut obj) = entry_val {
if let Some(v) = obj.get(child_field) {
obj.insert(child_field.to_string(), redact_json_value(v));
for &child_field in child_fields {
if let Some(v) = obj.get(child_field) {
obj.insert(child_field.to_string(), redact_json_value(v));
}
}
}
}
@@ -2760,10 +2762,12 @@ mod tests {
#[test]
fn format_setting_value_redacts_oauth_secrets() {
let val = serde_json::json!({
"google": {"id": "client-id", "secret": "my-super-secret-12345"}
"google": {"id": "client-id", "secret": "my-super-secret-12345"},
"slack": {"id": "slack-id", "secret": "x", "signing_secret": "my-signing-secret-678"}
});
let formatted = format_setting_value("oauths", &val);
assert!(!formatted.contains("my-super-secret-12345"));
assert!(!formatted.contains("my-signing-secret-678"));
assert!(formatted.contains("client-id"));
assert!(formatted.contains("****"));
}
+8
View File
@@ -254,6 +254,9 @@ pub struct OAuthClient {
pub tenant: Option<String>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
/// Slack only: the app's signing secret, verifying the requests Slack sends.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_secret: Option<String>,
}
impl std::fmt::Debug for OAuthClient {
@@ -267,6 +270,10 @@ impl std::fmt::Debug for OAuthClient {
.field("login_config", &self.login_config)
.field("tenant", &self.tenant)
.field("grant_types", &self.grant_types)
.field(
"signing_secret",
&self.signing_secret.as_ref().map(|_| "***"),
)
.finish()
}
}
@@ -517,6 +524,7 @@ pub async fn build_client_credentials_oauth_client(
.map(|e| e.grant_types.clone())
.unwrap_or_else(default_grant_types),
tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()),
signing_secret: None,
};
let base_url = (**BASE_URL.load()).clone();
+34 -10
View File
@@ -9,6 +9,10 @@
import TextInput from './text_input/TextInput.svelte'
import Password from './Password.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
import CopyableCodeBlock from './details/CopyableCodeBlock.svelte'
import { slackAppManifest } from './common/slack/slackAppManifest'
import { yaml } from 'svelte-highlight/languages'
import { base } from '$lib/base'
interface Props {
name: string
@@ -130,6 +134,21 @@
bind:password={value['secret']}
/>
</label>
{#if name == 'slack'}
<label for="slack_signing_secret" class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Signing Secret</span>
<span class="text-2xs text-secondary">
Under Basic Information in the Slack app. Windmill checks with it that requests come
from Slack, and AI agents answer in Slack only once it is set. The SLACK_SIGNING_SECRET
environment variable takes precedence.
</span>
<Password
id="slack_signing_secret"
placeholder="Signing Secret"
bind:password={value['signing_secret']}
/>
</label>
{/if}
{#if name == 'microsoft' || name == 'teams'}
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Tenant Id</span>
@@ -203,16 +222,21 @@
</CollapseLink>
{:else if name == 'slack'}
<CollapseLink text="Set up slack">
<div class="text-xs text-primary rounded-md">
To use Slack OAuth, create a new Slack app <a
href="https://api.slack.com/apps?new_app=1"
target="_blank"
>in slack API console
<ExternalLink size={12} class="inline-block" />
</a>. Pick "From a manifest", then YAML and paste manifest template found on
<a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank"
>Windmill docs <ExternalLink size={12} class="inline-block" /></a
> and then paste Client ID and Client Secret here.
<div class="flex flex-col gap-2 text-xs text-primary">
<span>
Create a Slack app <a href="https://api.slack.com/apps?new_app=1" target="_blank"
>in the Slack API console <ExternalLink size={12} class="inline-block" /></a
>: pick "From a manifest", then YAML, and paste the manifest below. Then copy the
Client ID, Client Secret and Signing Secret here. Socket Mode and token rotation must
stay off. More in the
<a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank"
>Windmill docs <ExternalLink size={12} class="inline-block" /></a
>.
</span>
<CopyableCodeBlock
code={slackAppManifest(`${window.location.origin}${base}`)}
language={yaml}
/>
</div>
</CollapseLink>
{:else if name == 'microsoft'}
@@ -0,0 +1,54 @@
/** Requested on top of the usual ones when a workspace connects with AI agents enabled. */
export const SLACK_AGENT_SCOPES = ['users:read', 'users:read.email', 'channels:read']
/**
* The Slack app manifest Windmill expects, for `baseUrl` (the instance's public URL). It declares
* the agent scopes too: an app may declare more than a connection asks for, never fewer.
*/
export function slackAppManifest(baseUrl: string): string {
return `display_information:
name: Windmill
description: Run Windmill scripts, flows and AI agents from Slack
background_color: '#3b82f6'
features:
app_home:
home_tab_enabled: true
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Windmill
always_online: true
slash_commands:
- command: /windmill
url: ${baseUrl}/api/oauth/slack_command
description: Trigger the script set in your workspace settings for Slack
usage_hint: the text that will be passed to the script
should_escape: false
oauth_config:
redirect_urls:
- ${baseUrl}
scopes:
bot:
- chat:write
- chat:write.public
- channels:join
- files:write
- commands
- app_mentions:read
- im:history
- im:read
${SLACK_AGENT_SCOPES.map((s) => ` - ${s}`).join('\n')}
settings:
event_subscriptions:
request_url: ${baseUrl}/api/oauth/slack_events
bot_events:
- app_mention
- message.im
interactivity:
is_enabled: true
request_url: ${baseUrl}/api/slack
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
`
}
@@ -180,15 +180,15 @@
<span class="text-xs text-hint">Loading…</span>
{:else if !slackTeamName}
<span class="text-xs text-secondary">
Connect Slack to the workspace to ask this agent from Slack, by its name or as a
channel's default.
Connect Slack to the workspace, with AI agents enabled, to ask this agent from Slack,
by its name or as a channel's default.
</span>
{#if isAdmin}
<div>
<Button
variant="default"
unifiedSize="md"
href="{base}/workspace_settings?tab=slack"
href="{base}/workspace_settings?tab=slack&agents=true"
>
Connect Slack
</Button>
@@ -199,8 +199,8 @@
{:else}
{#if !signingSecretSet}
<Alert type="warning" title="Agents can't answer in Slack yet" size="xs">
The instance needs SLACK_SIGNING_SECRET set to the Slack app's signing secret, so
Windmill can tell who sent a message.
The instance needs the Slack app's signing secret, so Windmill can tell who sent a
message. A superadmin sets it in the instance settings, under OAuth, Slack.
</Alert>
{/if}
{#if !botName}
@@ -208,7 +208,8 @@
Slack refused the workspace's bot token or couldn't be reached, so agents can't
answer there.
{#if isAdmin}
<a href="{base}/workspace_settings?tab=slack">Reconnect Slack</a> to fix it.
<a href="{base}/workspace_settings?tab=slack&agents=true">Reconnect Slack</a> to fix
it.
{:else}
A workspace admin can reconnect Slack to fix it.
{/if}
@@ -292,27 +293,30 @@
</TabContent>
<TabContent value="api" class="flex flex-col gap-6 p-4">
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">
Each call runs the agent as the token's owner and returns the id of the run, whose
answer streams as it is written.{#if chat}{' '}Calls that share a
<code>memory_id</code>, any string you choose, are one conversation.{/if}
</span>
<div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Sparkles }}
onClick={() => copyToClipboard(integrationPrompt(target))}
<span class="text-xs text-secondary">
Each call runs the agent as the token's owner and returns the id of the run, whose
answer streams as it is written.{#if chat}{' '}Calls that share a
<code>memory_id</code>, any string you choose, are one conversation.{/if}
</span>
<div class="flex items-center gap-4 rounded-md border p-3">
<div class="flex flex-col gap-1 grow">
<span class="text-xs font-semibold text-emphasis">Integrate with an AI assistant</span
>
Copy a prompt for your AI assistant
<Tooltip light>
Everything a coding assistant needs to integrate this agent: the endpoints,
streaming{chat ? ', conversations and the chat library' : ''}. The token is left
as a placeholder.
</Tooltip>
</Button>
<span class="text-2xs text-secondary">
A prompt with the endpoints and streaming{chat
? ', conversations and the chat SDK'
: ''}, for your coding assistant to write the integration. The token stays a
placeholder.
</span>
</div>
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{ icon: Sparkles }}
onClick={() => copyToClipboard(integrationPrompt(target))}
>
Copy prompt
</Button>
</div>
<Label label="Token">
<div class="flex flex-col gap-2">
@@ -50,6 +50,7 @@
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { SLACK_AGENT_SCOPES } from '$lib/components/common/slack/slackAppManifest'
import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte'
import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte'
@@ -164,6 +165,9 @@
await goto('/user/workspaces')
}
let useCustomSlackApp: boolean = $state(false)
// Opt-in: Slack refuses a connection asking for a scope its app doesn't declare. An agent's page
// links here with `agents=true`.
let slackAgents = $state($page.url.searchParams.get('agents') === 'true')
let slackAppType: 'instance' | 'workspace' = $state('instance')
// Keep slackAppType and useCustomSlackApp in sync
@@ -827,7 +831,7 @@
})
// Redirect to OAuth flow
window.location.href = `${base}/api/oauth/connect_slack?workspace=${$workspaceStore}`
window.location.href = `${base}/api/oauth/connect_slack?workspace=${$workspaceStore}${slackAgents ? '&agents=true' : ''}`
} catch (e) {
sendUserToast('Failed to save Slack OAuth configuration', true)
console.error(e)
@@ -1644,7 +1648,7 @@
}
}}
onSelect={editSlackCommand}
connectHref="{base}/api/oauth/connect_slack"
connectHref="{base}/api/oauth/connect_slack{slackAgents ? '?agents=true' : ''}"
createScriptHref="{base}/scripts/add?hub=hub%2F28071%2Fslack%2Fexample_of_responding_to_a_slack_command_slack"
createFlowHref="{base}/flows/add?hub=28"
documentationLink="https://www.windmill.dev/docs/integrations/slack"
@@ -1679,6 +1683,22 @@
you want to use a specific Slack app for this workspace.</div
>
</div>
<div class="flex flex-col gap-1">
<Toggle
size="xs"
bind:checked={slackAgents}
options={{ right: 'Let AI agents answer in Slack' }}
/>
<div class="text-2xs text-hint">
Also asks Slack for {SLACK_AGENT_SCOPES.join(', ')}, to know who is
asking and to list channels. The Slack app must declare them, subscribe
to the <code>app_mention</code> and
<code>message.im</code> bot events at
<code>{window.location.origin}{base}/api/oauth/slack_events</code>, and
its signing secret must be set in the instance settings. Without these
scopes Slack refuses the connection.
</div>
</div>
{/if}
{#if slackOAuthConfigLoaded}
<!-- Show saved config with delete button -->