From 13697976dbbc005ee95f18a571a01ac3ab0fd2db Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:57:10 -0800 Subject: [PATCH] feat(extensions): improve auth UX and add load-time validation (#536) * feat(extensions): add load-time validation for auth capabilities Catch common misconfigurations (missing auth section, missing setup_url, short prompts) at startup via tracing::warn instead of silently failing at auth time. * feat(extensions): improve auth prompts, setup_url, and showAuthCard Add setup_url and descriptive prompts to channel and tool capabilities files. Fix showAuthCard in web gateway and improve extension manager auth flow messaging. * refactor(extensions): extract MIN_PROMPT_LENGTH constant in validate() Address review feedback: replace magic number 30 with a named constant for readability and maintainability. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../discord/discord.capabilities.json | 7 +- channels-src/slack/slack.capabilities.json | 7 +- .../telegram/telegram.capabilities.json | 3 +- .../whatsapp/whatsapp.capabilities.json | 5 +- src/channels/wasm/loader.rs | 1 + src/channels/wasm/schema.rs | 94 +++++++++++++++ src/channels/web/static/app.js | 2 +- src/extensions/manager.rs | 4 +- src/tools/wasm/capabilities_schema.rs | 107 ++++++++++++++++++ src/tools/wasm/loader.rs | 1 + .../github/github-tool.capabilities.json | 10 +- 11 files changed, 228 insertions(+), 13 deletions(-) diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index f96d95e4f..b5708e70a 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -6,15 +6,16 @@ "required_secrets": [ { "name": "discord_bot_token", - "prompt": "Enter your Discord Bot Token (from Developer Portal)", + "prompt": "Enter your Discord Bot Token. Find it under Bot > Token in your Discord Application settings.", "optional": false }, { "name": "discord_public_key", - "prompt": "Enter your Discord Application Public Key (from Developer Portal > General Information)", + "prompt": "Enter your Discord Application Public Key (found under General Information in your Discord Application settings).", "optional": false } - ] + ], + "setup_url": "https://discord.com/developers/applications" }, "capabilities": { "http": { diff --git a/channels-src/slack/slack.capabilities.json b/channels-src/slack/slack.capabilities.json index cb48d153f..4a6fc19cb 100644 --- a/channels-src/slack/slack.capabilities.json +++ b/channels-src/slack/slack.capabilities.json @@ -6,15 +6,16 @@ "required_secrets": [ { "name": "slack_bot_token", - "prompt": "Enter your Slack Bot OAuth Token (xoxb-...)", + "prompt": "Enter your Slack Bot User OAuth Token (starts with xoxb-). Find it under OAuth & Permissions in your Slack App settings.", "optional": false }, { "name": "slack_signing_secret", - "prompt": "Enter your Slack Signing Secret (from App Credentials)", + "prompt": "Enter your Slack App Signing Secret (found under Basic Information > App Credentials in your Slack App settings).", "optional": false } - ] + ], + "setup_url": "https://api.slack.com/apps" }, "capabilities": { "http": { diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index bdc3e4f8d..e94009aa1 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -9,7 +9,8 @@ "prompt": "Enter your Telegram Bot API token (from @BotFather)", "optional": false } - ] + ], + "setup_url": "https://t.me/BotFather" }, "capabilities": { "http": { diff --git a/channels-src/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json index f86867d20..6a60a8d7e 100644 --- a/channels-src/whatsapp/whatsapp.capabilities.json +++ b/channels-src/whatsapp/whatsapp.capabilities.json @@ -6,7 +6,7 @@ "required_secrets": [ { "name": "whatsapp_access_token", - "prompt": "Enter your WhatsApp Cloud API access token (from Meta Developer Portal)", + "prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).", "validation": "^[A-Za-z0-9_-]+$" }, { @@ -16,7 +16,8 @@ "auto_generate": { "length": 32 } } ], - "validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}" + "validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}", + "setup_url": "https://developers.facebook.com/apps" }, "capabilities": { "http": { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 728a2bde4..2df7c4693 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -81,6 +81,7 @@ impl WasmChannelLoader { let cap_bytes = fs::read(cap_path).await?; let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?; + cap_file.validate(); // Debug: log raw capabilities tracing::debug!( diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index 8741be12a..7e9d56f58 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -90,6 +90,37 @@ impl ChannelCapabilitiesFile { serde_json::from_slice(bytes) } + /// Validate the capabilities file and emit warnings for common misconfigurations. + /// + /// Called once at load time to catch issues early. Warnings are emitted via + /// `tracing::warn` so they show up in startup logs without blocking loading. + pub fn validate(&self) { + const MIN_PROMPT_LENGTH: usize = 30; + + // Check for short prompts in required_secrets + for secret in &self.setup.required_secrets { + if secret.prompt.len() < MIN_PROMPT_LENGTH { + tracing::warn!( + channel = self.name, + secret = secret.name, + prompt = secret.prompt, + "setup.required_secrets prompt is shorter than {} chars — \ + consider a more descriptive prompt that tells the user where to find this value", + MIN_PROMPT_LENGTH + ); + } + } + + // Has required_secrets but no setup_url + if !self.setup.required_secrets.is_empty() && self.setup.setup_url.is_none() { + tracing::warn!( + channel = self.name, + "setup.required_secrets defined but no setup.setup_url — \ + user has no link to obtain credentials" + ); + } + } + /// Convert to runtime ChannelCapabilities. pub fn to_capabilities(&self) -> ChannelCapabilities { self.capabilities.to_channel_capabilities(&self.name) @@ -262,6 +293,10 @@ pub struct SetupSchema { /// Placeholders like {secret_name} are replaced with actual values. #[serde(default)] pub validation_endpoint: Option, + + /// User-facing URL where they can create/manage credentials. + #[serde(default)] + pub setup_url: Option, } /// Configuration for a secret required during setup. @@ -605,6 +640,65 @@ mod tests { // ── Category 5: Discord Capabilities Setup & Configuration ────────── + #[test] + fn test_validate_channel_short_prompt() { + // prompt < 30 chars — should not panic + let json = r#"{ + "name": "test-channel", + "setup": { + "required_secrets": [ + { "name": "bot_token", "prompt": "Bot token" } + ], + "setup_url": "https://example.com" + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + // Should not panic; warning emitted for short prompt + file.validate(); + } + + #[test] + fn test_validate_channel_missing_setup_url() { + // required_secrets without setup_url — should not panic + let json = r#"{ + "name": "test-channel", + "setup": { + "required_secrets": [ + { + "name": "bot_token", + "prompt": "Enter your bot token from the developer portal settings" + } + ] + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + // Should not panic; warning emitted for missing setup_url + file.validate(); + } + + #[test] + fn test_validate_clean_channel() { + // Well-configured channel — should not panic or warn + let json = r#"{ + "name": "good-channel", + "setup": { + "required_secrets": [ + { + "name": "bot_token", + "prompt": "Enter your bot token from https://example.com/bot-settings" + } + ], + "setup_url": "https://example.com/bot-settings" + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + // Should not panic and emits no warnings + file.validate(); + } + #[test] fn test_discord_capabilities_has_public_key_secret() { let json = include_str!("../../../channels-src/discord/discord.capabilities.json"); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index f0878585b..e1eb8b458 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -874,7 +874,7 @@ function showAuthCard(data) { const tokenInput = document.createElement('input'); tokenInput.type = 'password'; - tokenInput.placeholder = 'Paste your API key or token'; + tokenInput.placeholder = data.instructions || 'Paste your API key or token'; tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 9fafaee25..7f941ea49 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -2110,7 +2110,7 @@ impl ExtensionManager { auth_url: None, callback_type: None, instructions: Some(next.prompt.clone()), - setup_url: cap_file.setup.validation_endpoint.clone(), + setup_url: cap_file.setup.setup_url.clone(), awaiting_token: true, status: "awaiting_token".to_string(), }); @@ -2124,7 +2124,7 @@ impl ExtensionManager { auth_url: None, callback_type: None, instructions: Some(secret.prompt.clone()), - setup_url: cap_file.setup.validation_endpoint.clone(), + setup_url: cap_file.setup.setup_url.clone(), awaiting_token: true, status: "awaiting_token".to_string(), }) diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index 97561df66..e5ff556d0 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -105,6 +105,59 @@ impl CapabilitiesFile { self } + /// Validate the capabilities file and emit warnings for common misconfigurations. + /// + /// Called once at load time to catch issues early. Warnings are emitted via + /// `tracing::warn` so they show up in startup logs without blocking loading. + pub fn validate(&self, name: &str) { + const MIN_PROMPT_LENGTH: usize = 30; + + // setup.required_secrets present but no auth section → auth card won't display + if let Some(setup) = &self.setup { + if !setup.required_secrets.is_empty() && self.auth.is_none() { + tracing::warn!( + tool = name, + "setup.required_secrets defined but no 'auth' section — \ + chat-based auth card will not display for this tool" + ); + } + + // Check for short prompts + for secret in &setup.required_secrets { + if secret.prompt.len() < MIN_PROMPT_LENGTH { + tracing::warn!( + tool = name, + secret = secret.name, + prompt = secret.prompt, + "setup.required_secrets prompt is shorter than {} chars — \ + consider a more descriptive prompt that tells the user where to find this value", + MIN_PROMPT_LENGTH + ); + } + } + } + + // Manual auth (no OAuth) checks + if let Some(auth) = &self.auth + && auth.oauth.is_none() + { + if auth.setup_url.is_none() { + tracing::warn!( + tool = name, + "auth section has no OAuth and no setup_url — \ + user has no link to obtain credentials" + ); + } + if auth.instructions.is_none() { + tracing::warn!( + tool = name, + "auth section has no OAuth and no instructions — \ + user has no guidance on how to obtain credentials" + ); + } + } + } + /// Convert to runtime Capabilities. pub fn to_capabilities(&self) -> Capabilities { let mut caps = Capabilities::default(); @@ -1056,6 +1109,60 @@ mod tests { assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret"); } + #[test] + fn test_validate_setup_without_auth_warns() { + // setup.required_secrets with no auth section — should not panic + let json = r#"{ + "setup": { + "required_secrets": [ + { "name": "api_key", "prompt": "Enter your API key from the provider dashboard settings page" } + ] + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + // Should not panic; warning is emitted via tracing + caps.validate("test-tool"); + } + + #[test] + fn test_validate_manual_auth_missing_fields() { + // auth without OAuth, missing setup_url and instructions + let json = r#"{ + "auth": { + "secret_name": "my_api_key" + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + // Should not panic; warnings emitted for missing setup_url and instructions + caps.validate("test-tool"); + } + + #[test] + fn test_validate_clean_tool() { + // Well-configured tool with auth, setup_url, instructions, and good prompts + let json = r#"{ + "auth": { + "secret_name": "my_api_key", + "setup_url": "https://example.com/api-keys", + "instructions": "Go to example.com/api-keys and create a new key" + }, + "setup": { + "required_secrets": [ + { + "name": "my_api_key", + "prompt": "Enter your API key from https://example.com/api-keys" + } + ] + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + // Should not panic and emits no warnings (has auth, setup_url, instructions, long prompt) + caps.validate("clean-tool"); + } + #[test] fn test_resolve_nested_empty_capabilities_noop() { // Empty inner capabilities should not clobber outer http diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index d0cb46871..d332e25c5 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -126,6 +126,7 @@ impl WasmToolLoader { let cap_bytes = fs::read(cap_path).await?; let cap_file = CapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?; + cap_file.validate(name); let caps = cap_file.to_capabilities(); let oauth = resolve_oauth_refresh_config(&cap_file); (caps, oauth) diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 0763d08f5..0c37b0063 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -34,11 +34,19 @@ ] } }, + "auth": { + "secret_name": "github_token", + "display_name": "GitHub", + "instructions": "Create a Personal Access Token at github.com/settings/tokens with repo scope, then paste it here.", + "setup_url": "https://github.com/settings/tokens", + "token_hint": "Starts with 'ghp_' or 'github_pat_'", + "env_var": "GITHUB_TOKEN" + }, "setup": { "required_secrets": [ { "name": "github_token", - "prompt": "GitHub Personal Access Token (from github.com/settings/tokens)" + "prompt": "GitHub Personal Access Token (create one at github.com/settings/tokens with 'repo' scope)" } ] },