mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
9b25e7566c
commit
afb49597ac
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user