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
+199 -55
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,34 +2282,52 @@ 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(),
));
}
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Err(ExtensionError::Other(format!(
"Capabilities file not found for '{}'",
name
)));
}
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)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Build allowed secret names from capabilities
let allowed: std::collections::HashSet<String> = cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.collect();
// 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));
if !cap_path.exists() {
return Err(ExtensionError::Other(format!(
"Capabilities file not found for '{}'",
name
)));
}
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)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.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,33 +2348,70 @@ impl ExtensionManager {
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
}
// Auto-generate any missing secrets that have auto_generate set
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
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 params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
// 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
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
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 params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
);
}
}
}
}
}
// 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,
});
}
}
}
+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).