feat(extensions): add OAuth setup UI for WASM tools + display name labels (#437)

Add setup.required_secrets to tool capabilities.json files so users can
configure OAuth client credentials (Google, Slack, Okta, Telegram) through
the Extensions UI Setup modal instead of environment variables.

- Add ToolSetupSchema/ToolSecretSetupSchema types to capabilities_schema.rs
- Extend get_setup_schema(), save_setup_secrets(), list() to handle WasmTool
- Extract load_tool_capabilities() helper to reduce duplication
- Auto-activate tools after saving setup secrets
- Show display_name labels (Channel/Tool/MCP) in extension cards
- Update button labels: "Setup" when unconfigured, "Reconfigure" when set
- Replace "Set" badge with checkmark in configure modal
- Fix innerHTML XSS pattern in slash autocomplete (use textContent)
- Add tests for ToolSetupSchema parsing and resolve_nested promotion
- Update registry display names (e.g. "Telegram Channel" vs "Telegram Tool")

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Henry Park
2026-02-28 19:57:55 -08:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 9b25e7566c
commit afb49597ac
22 changed files with 418 additions and 75 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "discord",
"display_name": "Discord",
"display_name": "Discord Channel",
"kind": "channel",
"version": "0.1.0",
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
"description": "Talk to your agent in Discord",
"keywords": ["messaging", "chat", "discord", "bot"],
"source": {
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "slack",
"display_name": "Slack",
"display_name": "Slack Channel",
"kind": "channel",
"version": "0.1.0",
"description": "Slack Events API channel for receiving and responding to Slack messages",
"description": "Talk to your agent in Slack",
"keywords": ["messaging", "chat", "workspace", "slack"],
"source": {
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "telegram",
"display_name": "Telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.1.0",
"description": "Telegram Bot API channel for receiving and responding to messages",
"description": "Talk to your agent through a Telegram bot",
"keywords": ["messaging", "bot", "chat", "telegram"],
"source": {
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "whatsapp",
"display_name": "WhatsApp",
"display_name": "WhatsApp Channel",
"kind": "channel",
"version": "0.1.0",
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
"description": "Talk to your agent through WhatsApp",
"keywords": ["messaging", "chat", "whatsapp", "meta"],
"source": {
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "slack-tool",
"display_name": "Slack",
"display_name": "Slack Tool",
"kind": "tool",
"version": "0.1.0",
"description": "Post messages, read channels, and manage conversations via Slack API",
"description": "Your agent uses Slack to post and read messages in your workspace",
"keywords": ["messaging", "chat", "workspace"],
"source": {
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "telegram-mtproto",
"display_name": "Telegram",
"display_name": "Telegram Tool",
"kind": "tool",
"version": "0.1.0",
"description": "Telegram user-mode integration via MTProto for messages and contacts",
"description": "Your agent uses your Telegram account to read and send messages",
"keywords": ["messaging", "chat", "telegram", "mtproto"],
"source": {
+1
View File
@@ -51,6 +51,7 @@ pub async fn extensions_list_handler(
};
ExtensionInfo {
name: ext.name,
display_name: ext.display_name,
kind: ext.kind.to_string(),
description: ext.description,
url: ext.url,
+1
View File
@@ -1740,6 +1740,7 @@ async fn extensions_list_handler(
};
ExtensionInfo {
name: ext.name,
display_name: ext.display_name,
kind: ext.kind.to_string(),
description: ext.description,
url: ext.url,
+17 -8
View File
@@ -324,8 +324,14 @@ function showSlashAutocomplete(matches) {
const row = document.createElement('div');
row.className = 'slash-ac-item';
row.dataset.index = i;
row.innerHTML = '<span class="slash-ac-cmd">' + escapeHtml(item.cmd) + '</span>'
+ '<span class="slash-ac-desc">' + escapeHtml(item.desc) + '</span>';
var cmdSpan = document.createElement('span');
cmdSpan.className = 'slash-ac-cmd';
cmdSpan.textContent = item.cmd;
var descSpan = document.createElement('span');
descSpan.className = 'slash-ac-desc';
descSpan.textContent = item.desc;
row.appendChild(cmdSpan);
row.appendChild(descSpan);
row.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent blur
selectSlashItem(item.cmd);
@@ -1643,6 +1649,8 @@ function loadServerLogLevel() {
// --- Extensions ---
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
function loadExtensions() {
const extList = document.getElementById('extensions-list');
const wasmList = document.getElementById('available-wasm-list');
@@ -1718,7 +1726,7 @@ function renderAvailableExtensionCard(entry) {
const kind = document.createElement('span');
kind.className = 'ext-kind kind-' + entry.kind;
kind.textContent = entry.kind;
kind.textContent = kindLabels[entry.kind] || entry.kind;
header.appendChild(kind);
card.appendChild(header);
@@ -1784,7 +1792,7 @@ function renderMcpServerCard(entry, installedExt) {
var kind = document.createElement('span');
kind.className = 'ext-kind kind-mcp_server';
kind.textContent = 'mcp_server';
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
header.appendChild(kind);
if (installedExt) {
@@ -1868,12 +1876,12 @@ function renderExtensionCard(ext) {
const name = document.createElement('span');
name.className = 'ext-name';
name.textContent = ext.name;
name.textContent = ext.display_name || ext.name;
header.appendChild(name);
const kind = document.createElement('span');
kind.className = 'ext-kind kind-' + ext.kind;
kind.textContent = ext.kind;
kind.textContent = kindLabels[ext.kind] || ext.kind;
header.appendChild(kind);
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
@@ -1981,7 +1989,7 @@ function renderExtensionCard(ext) {
if (ext.needs_setup) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Setup';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
@@ -2102,7 +2110,8 @@ function renderConfigureModal(name, secrets) {
if (secret.provided) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = 'Set';
badge.textContent = '\u2713';
badge.title = 'Already configured';
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
+2
View File
@@ -367,6 +367,8 @@ pub struct TransitionInfo {
#[derive(Debug, Serialize)]
pub struct ExtensionInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub kind: String,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+158 -14
View File
@@ -323,9 +323,15 @@ impl ExtensionManager {
Vec::new()
};
let display_name = self
.registry
.get_with_kind(&server.name, Some(ExtensionKind::McpServer))
.await
.map(|e| e.display_name);
extensions.push(InstalledExtension {
name: server.name.clone(),
kind: ExtensionKind::McpServer,
display_name,
description: server.description.clone(),
url: Some(server.url.clone()),
authenticated,
@@ -352,15 +358,22 @@ impl ExtensionManager {
for (name, _discovered) in tools {
let active = self.tool_registry.has(&name).await;
let display_name = self
.registry
.get_with_kind(&name, Some(ExtensionKind::WasmTool))
.await
.map(|e| e.display_name);
let (authenticated, needs_setup) = self.check_tool_auth_status(&name).await;
extensions.push(InstalledExtension {
name: name.clone(),
kind: ExtensionKind::WasmTool,
display_name,
description: None,
url: None,
authenticated: true, // WASM tools don't always need auth
authenticated,
active,
tools: if active { vec![name] } else { Vec::new() },
needs_setup: false,
needs_setup,
installed: true,
activation_error: None,
});
@@ -385,9 +398,15 @@ impl ExtensionManager {
let (authenticated, needs_setup) =
self.check_channel_auth_status(&name).await;
let activation_error = errors.get(&name).cloned();
let display_name = self
.registry
.get_with_kind(&name, Some(ExtensionKind::WasmChannel))
.await
.map(|e| e.display_name);
extensions.push(InstalledExtension {
name,
kind: ExtensionKind::WasmChannel,
display_name,
description: None,
url: None,
authenticated,
@@ -424,6 +443,7 @@ impl ExtensionManager {
extensions.push(InstalledExtension {
name: entry.name,
kind: entry.kind,
display_name: Some(entry.display_name),
description: Some(entry.description),
url: None,
authenticated: false,
@@ -1442,6 +1462,51 @@ impl ExtensionManager {
(all_provided, true)
}
/// Load and parse a WASM tool's capabilities file.
///
/// Returns `None` if the file doesn't exist or can't be parsed.
async fn load_tool_capabilities(
&self,
name: &str,
) -> Option<crate::tools::wasm::CapabilitiesFile> {
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
}
/// Check whether a WASM tool's required setup secrets are provided.
///
/// Returns `(authenticated, needs_setup)` — same semantics as `check_channel_auth_status`.
async fn check_tool_auth_status(&self, name: &str) -> (bool, bool) {
let Some(cap_file) = self.load_tool_capabilities(name).await else {
return (true, false);
};
let Some(setup) = &cap_file.setup else {
return (true, false);
};
if setup.required_secrets.is_empty() {
return (true, false);
}
let mut all_provided = true;
for secret in &setup.required_secrets {
if secret.optional {
continue;
}
if !self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false)
{
all_provided = false;
break;
}
}
(all_provided, true)
}
async fn auth_wasm_channel(
&self,
name: &str,
@@ -2179,6 +2244,30 @@ impl ExtensionManager {
}
Ok(fields)
}
ExtensionKind::WasmTool => {
let Some(cap_file) = self.load_tool_capabilities(name).await else {
return Ok(Vec::new());
};
let mut fields = Vec::new();
if let Some(setup) = &cap_file.setup {
for secret in &setup.required_secrets {
let provided = self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false);
fields.push(crate::channels::web::types::SecretFieldInfo {
name: secret.name.clone(),
prompt: secret.prompt.clone(),
optional: secret.optional,
provided,
auto_generate: false,
});
}
}
Ok(fields)
}
_ => Ok(Vec::new()),
}
}
@@ -2193,12 +2282,10 @@ impl ExtensionManager {
secrets: &std::collections::HashMap<String, String>,
) -> Result<SetupResult, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
if kind != ExtensionKind::WasmChannel {
return Err(ExtensionError::Other(
"Setup is only supported for WASM channels".to_string(),
));
}
// Load allowed secret names from the extension's capabilities file
let allowed: std::collections::HashSet<String> = match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
@@ -2211,16 +2298,36 @@ impl ExtensionManager {
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Build allowed secret names from capabilities
let allowed: std::collections::HashSet<String> = cap_file
cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.collect();
.collect()
}
ExtensionKind::WasmTool => {
let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| {
ExtensionError::Other(format!("Capabilities file not found for '{}'", name))
})?;
match cap_file.setup {
Some(s) => s.required_secrets.iter().map(|s| s.name.clone()).collect(),
None => {
return Err(ExtensionError::Other(format!(
"Tool '{}' has no setup schema — no secrets to configure",
name
)));
}
}
}
_ => {
return Err(ExtensionError::Other(
"Setup is only supported for WASM channels and tools".to_string(),
));
}
};
// Validate and store each submitted secret
for (secret_name, secret_value) in secrets {
@@ -2241,7 +2348,15 @@ impl ExtensionManager {
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
}
// Auto-generate any missing secrets that have auto_generate set
// Auto-generate any missing secrets (channel-only feature)
if kind == ExtensionKind::WasmChannel {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
{
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
@@ -2256,7 +2371,8 @@ impl ExtensionManager {
use rand::RngCore;
let mut bytes = vec![0u8; auto_gen.length];
rand::thread_rng().fill_bytes(&mut bytes);
let hex_value: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let hex_value: String =
bytes.iter().map(|b| format!("{b:02x}")).collect();
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
@@ -2271,6 +2387,34 @@ impl ExtensionManager {
}
}
}
}
}
// For tools, save and attempt auto-activation
if kind == ExtensionKind::WasmTool {
match self.activate_wasm_tool(name).await {
Ok(result) => {
return Ok(SetupResult {
message: format!(
"Configuration saved and tool '{}' activated. {}",
name, result.message
),
activated: true,
});
}
Err(e) => {
tracing::debug!(
"Auto-activation of tool '{}' after setup failed: {}",
name,
e
);
return Ok(SetupResult {
message: format!("Configuration saved for '{}'.", name),
activated: false,
});
}
}
}
// Try to hot-activate the channel now that secrets are saved
match self.activate_wasm_channel(name).await {
+3
View File
@@ -187,6 +187,9 @@ fn default_true() -> bool {
pub struct InstalledExtension {
pub name: String,
pub kind: ExtensionKind,
/// Human-readable display name (e.g. "Telegram Channel" vs "Telegram Tool").
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Server or source URL (e.g. MCP server endpoint).
+75
View File
@@ -62,6 +62,11 @@ pub struct CapabilitiesFile {
#[serde(default)]
pub auth: Option<AuthCapabilitySchema>,
/// Setup schema: secrets the user must provide before the tool can be used.
/// Mirrors the channel `setup.required_secrets` pattern.
#[serde(default)]
pub setup: Option<ToolSetupSchema>,
/// Nested capabilities wrapper for channel-level JSON compatibility.
///
/// Channel capabilities files nest tool capabilities under a `"capabilities"` key.
@@ -95,6 +100,7 @@ impl CapabilitiesFile {
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
self.workspace = self.workspace.or(inner.workspace);
self.auth = self.auth.or(inner.auth);
self.setup = self.setup.or(inner.setup);
}
self
}
@@ -516,6 +522,26 @@ fn default_success_status() -> u16 {
200
}
/// Setup schema for WASM tools: secrets the user must provide via the UI.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolSetupSchema {
/// Secrets the user must provide before the tool can be used.
#[serde(default)]
pub required_secrets: Vec<ToolSecretSetupSchema>,
}
/// A single secret required during tool setup.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSecretSetupSchema {
/// Secret name in the secrets store (e.g. "google_oauth_client_id").
pub name: String,
/// User-facing prompt (e.g. "Google OAuth Client ID").
pub prompt: String,
/// If true, the user may skip this secret.
#[serde(default)]
pub optional: bool,
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema};
@@ -976,6 +1002,55 @@ mod tests {
assert_eq!(caps.auth.unwrap().secret_name, "my_auth_token");
}
#[test]
fn test_parse_tool_setup_schema() {
let json = r#"{
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret",
"optional": true
}
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let setup = caps.setup.unwrap();
assert_eq!(setup.required_secrets.len(), 2);
assert_eq!(setup.required_secrets[0].name, "google_oauth_client_id");
assert_eq!(setup.required_secrets[0].prompt, "Google OAuth Client ID");
assert!(!setup.required_secrets[0].optional);
assert_eq!(setup.required_secrets[1].name, "google_oauth_client_secret");
assert!(setup.required_secrets[1].optional);
}
#[test]
fn test_resolve_nested_setup_promoted() {
// setup inside capabilities wrapper should be promoted to top level
let json = r#"{
"capabilities": {
"setup": {
"required_secrets": [
{ "name": "my_secret", "prompt": "Enter secret" }
]
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(
caps.setup.is_some(),
"setup should be promoted from inner capabilities"
);
assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret");
}
#[test]
fn test_resolve_nested_empty_capabilities_noop() {
// Empty inner capabilities should not clobber outer http
@@ -42,5 +42,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -41,5 +41,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -41,5 +41,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -46,5 +46,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -41,5 +41,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -41,5 +41,17 @@
}
},
"env_var": "GOOGLE_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID (from console.cloud.google.com/apis/credentials)"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret"
}
]
}
}
@@ -89,5 +89,17 @@
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/",
"token_hint": "OAuth2 access token (JWT)",
"env_var": "OKTA_OAUTH_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "okta_oauth_client_id",
"prompt": "Okta OAuth Client ID"
},
{
"name": "okta_oauth_client_secret",
"prompt": "Okta OAuth Client Secret"
}
]
}
}
@@ -46,5 +46,17 @@
"setup_url": "https://api.slack.com/apps",
"token_hint": "Starts with 'xoxb-'",
"env_var": "SLACK_BOT_TOKEN"
},
"setup": {
"required_secrets": [
{
"name": "slack_oauth_client_id",
"prompt": "Slack OAuth Client ID (from api.slack.com/apps)"
},
{
"name": "slack_oauth_client_secret",
"prompt": "Slack OAuth Client Secret"
}
]
}
}
@@ -24,5 +24,17 @@
"display_name": "Telegram",
"instructions": "1. Go to https://my.telegram.org/apps and create an app\n2. Store your API ID and hash in the workspace:\n - Write your numeric API ID to telegram/api_id\n - Write your API hash string to telegram/api_hash\n3. Use the 'login' action with your phone number\n4. Use 'submit_auth_code' with the code you receive\n5. Use 'submit_2fa_password' if you have 2FA enabled\n6. Save the returned session JSON to telegram/session.json",
"setup_url": "https://my.telegram.org/apps"
},
"setup": {
"required_secrets": [
{
"name": "telegram_api_id",
"prompt": "Telegram API ID (from my.telegram.org/apps)"
},
{
"name": "telegram_api_hash",
"prompt": "Telegram API Hash"
}
]
}
}