mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
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:
co-authored by
Claude Opus 5.5
parent
44029ccba2
commit
d0ef9dee91
@@ -1 +1 @@
|
||||
80c4c3a29f60d04baad601cd5bbac13ed8d33d17
|
||||
e6c86a632b05cc0561392cb8cdbe9e63f2ae4f28
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("****"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user