From d0ef9dee912c5cb8975da2e7332cfb5ac42d8c08 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 9 Oct 2026 15:45:08 +0200 Subject: [PATCH] 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 --- backend/ee-repo-ref.txt | 2 +- backend/src/monitor.rs | 6 +++ backend/windmill-api/openapi.yaml | 2 +- backend/windmill-api/src/lib.rs | 32 ++++++++++- backend/windmill-api/src/slack_agents.rs | 4 +- backend/windmill-api/src/slack_approvals.rs | 2 +- .../windmill-common/src/instance_config.rs | 14 +++-- backend/windmill-oauth/src/lib.rs | 8 +++ .../src/lib/components/OAuthSetting.svelte | 44 +++++++++++---- .../common/slack/slackAppManifest.ts | 54 +++++++++++++++++++ .../flows/content/AgentUsePanel.svelte | 54 ++++++++++--------- .../(logged)/workspace_settings/+page.svelte | 24 ++++++++- 12 files changed, 197 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/components/common/slack/slackAppManifest.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 0e36815063..38b3a228c6 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -80c4c3a29f60d04baad601cd5bbac13ed8d33d17 +e6c86a632b05cc0561392cb8cdbe9e63f2ae4f28 diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 8d0af4915d..115e46fa4f 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -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)) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e8551ffc55..fcd4b54566 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index bde592fba4..70ab3fa852 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -246,15 +246,43 @@ pub use windmill_oauth::OAUTH_CLIENTS; #[cfg(feature = "oauth2")] lazy_static::lazy_static! { - pub static ref SLACK_SIGNING_SECRET: Option = std::env::var("SLACK_SIGNING_SECRET") + static ref SLACK_SIGNING_SECRET_ENV: Option = 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> = + 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 { + 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. diff --git a/backend/windmill-api/src/slack_agents.rs b/backend/windmill-api/src/slack_agents.rs index 448ecb11bf..734a4de589 100644 --- a/backend/windmill-api/src/slack_agents.rs +++ b/backend/windmill-api/src/slack_agents.rs @@ -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 { // 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); diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs index 204b30010d..226b68278b 100644 --- a/backend/windmill-api/src/slack_approvals.rs +++ b/backend/windmill-api/src/slack_approvals.rs @@ -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()) diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 700ba71df5..15b590b7a0 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -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("****")); } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 15f5ac6b9e..cbb99776f9 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -254,6 +254,9 @@ pub struct OAuthClient { pub tenant: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// Slack only: the app's signing secret, verifying the requests Slack sends. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signing_secret: Option, } 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(); diff --git a/frontend/src/lib/components/OAuthSetting.svelte b/frontend/src/lib/components/OAuthSetting.svelte index 60f8e8ef5f..454a330aed 100644 --- a/frontend/src/lib/components/OAuthSetting.svelte +++ b/frontend/src/lib/components/OAuthSetting.svelte @@ -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']} /> + {#if name == 'slack'} + + {/if} {#if name == 'microsoft' || name == 'teams'}