mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: add OAuth support for WASM tools in web gateway Extract reusable OAuth functions (build_oauth_url, exchange_oauth_code, store_oauth_tokens, validate_oauth_token) from CLI into shared oauth_defaults module, then wire them into the web gateway's ExtensionManager. Key changes: - Install auto-activates WASM tools (no separate Activate button) - Configure button triggers OAuth flow via save_setup_secrets - Scope merging: installing a second Google tool triggers re-auth with merged scopes from all tools sharing the same secret_name - Cancel-and-retry: aborting stale OAuth listeners prevents port conflicts - Post-auth validation: wrong account detected via validation_endpoint - Reconfigure always re-auths (deletes old token before starting fresh) - UI shows error toast on OAuth failure, refreshes extension list Flow: Install → Active → Configure (enter client_id/secret) → Save → OAuth popup → authorize → done. Second Google tool install auto-triggers scope expansion OAuth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments - Add custom headers support to ValidationEndpointSchema (fixes missing Notion-Version header regression) - Guard activate handler auth check with status == "awaiting_authorization" to prevent unexpected OAuth popups - Add window dimensions to OAuth popup in activateExtension() - Simplify UTF-8 truncation boundary check Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot PR review comments (security, UX, bugs) - Add CSRF state parameter to OAuth flow (random state in auth URL, validated in callback) - Restore MCP server Activate button in web UI (was hidden for all non-channel extensions) - Abort JoinHandle in cleanup_expired_auths to prevent port 9876 conflicts - Fix Google-specific error message for non-Google OAuth providers - Add has_auth field to ExtensionInfo API response (fixes Configure button visibility) - Use oauth_defaults::callback_url() instead of hardcoded redirect_uri (both CLI and manager) - Update auth check comment to match actual behavior (scope expansion + first-time auth) - Add unit tests for build_oauth_url (basic, PKCE, extra params, state uniqueness) - Check all required setup secrets (client_id + client_secret) before starting OAuth Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
260 lines
8.4 KiB
Rust
260 lines
8.4 KiB
Rust
//! Lifecycle management for extensions: discovery, installation, authentication,
|
|
//! and activation of channels, tools, and MCP servers.
|
|
//!
|
|
//! Extensions are the user-facing abstraction that unifies three runtime kinds:
|
|
//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM)
|
|
//! - **Tools** — sandboxed capabilities (WASM)
|
|
//! - **MCP servers** — external API integrations via Model Context Protocol
|
|
//!
|
|
//! The agent can search a built-in registry (or discover online), install,
|
|
//! authenticate, and activate extensions at runtime without CLI commands.
|
|
//!
|
|
//! ```text
|
|
//! User: "add telegram"
|
|
//! -> tool_search("telegram") -> finds channel in registry
|
|
//! -> tool_install("telegram") -> copies bundled WASM to channels dir
|
|
//! -> tool_activate("telegram") -> configures credentials, starts channel
|
|
//! ```
|
|
|
|
pub mod discovery;
|
|
pub mod manager;
|
|
pub mod registry;
|
|
|
|
pub use discovery::OnlineDiscovery;
|
|
pub use manager::ExtensionManager;
|
|
pub use registry::ExtensionRegistry;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// The kind of extension, determining how it's installed, authenticated, and activated.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ExtensionKind {
|
|
/// Hosted MCP server, HTTP transport, OAuth 2.1 auth.
|
|
McpServer,
|
|
/// Sandboxed WASM module, file-based, capabilities auth.
|
|
WasmTool,
|
|
/// WASM channel module with hot-activation support.
|
|
WasmChannel,
|
|
}
|
|
|
|
impl std::fmt::Display for ExtensionKind {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ExtensionKind::McpServer => write!(f, "mcp_server"),
|
|
ExtensionKind::WasmTool => write!(f, "wasm_tool"),
|
|
ExtensionKind::WasmChannel => write!(f, "wasm_channel"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A registry entry describing a known or discovered extension.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RegistryEntry {
|
|
/// Unique identifier (e.g., "notion", "weather", "telegram").
|
|
pub name: String,
|
|
/// Human-readable name (e.g., "Notion", "Weather Tool").
|
|
pub display_name: String,
|
|
/// What kind of extension this is.
|
|
pub kind: ExtensionKind,
|
|
/// Short description of what this extension does.
|
|
pub description: String,
|
|
/// Search keywords beyond the name.
|
|
#[serde(default)]
|
|
pub keywords: Vec<String>,
|
|
/// Where to get this extension.
|
|
pub source: ExtensionSource,
|
|
/// Fallback source when the primary source fails (e.g., download 404 → build from source).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub fallback_source: Option<Box<ExtensionSource>>,
|
|
/// How authentication works.
|
|
pub auth_hint: AuthHint,
|
|
}
|
|
|
|
/// Where the extension binary or server lives.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ExtensionSource {
|
|
/// URL to a hosted MCP server.
|
|
McpUrl { url: String },
|
|
/// Downloadable WASM binary.
|
|
WasmDownload {
|
|
wasm_url: String,
|
|
#[serde(default)]
|
|
capabilities_url: Option<String>,
|
|
},
|
|
/// Build from local source directory.
|
|
WasmBuildable {
|
|
#[serde(alias = "repo_url")]
|
|
source_dir: String,
|
|
#[serde(default)]
|
|
build_dir: Option<String>,
|
|
/// Crate name used to locate the build artifact binary.
|
|
#[serde(default)]
|
|
crate_name: Option<String>,
|
|
},
|
|
/// Discovered online (not yet validated for a specific source type).
|
|
Discovered { url: String },
|
|
}
|
|
|
|
/// Hint about what authentication method is needed.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum AuthHint {
|
|
/// MCP server supports Dynamic Client Registration (zero-config OAuth).
|
|
Dcr,
|
|
/// MCP server needs a pre-configured OAuth client_id.
|
|
OAuthPreConfigured {
|
|
/// URL where the user can create an OAuth app.
|
|
setup_url: String,
|
|
},
|
|
/// WASM tool has auth defined in its capabilities.json file.
|
|
CapabilitiesAuth,
|
|
/// No authentication needed.
|
|
None,
|
|
}
|
|
|
|
/// Where a search result came from.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ResultSource {
|
|
/// From the built-in curated registry.
|
|
Registry,
|
|
/// From online discovery (validated).
|
|
Discovered,
|
|
}
|
|
|
|
/// Result of searching for extensions.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SearchResult {
|
|
/// The registry entry.
|
|
#[serde(flatten)]
|
|
pub entry: RegistryEntry,
|
|
/// Where this result came from.
|
|
pub source: ResultSource,
|
|
/// Whether the endpoint was validated (for discovered entries).
|
|
#[serde(default)]
|
|
pub validated: bool,
|
|
}
|
|
|
|
/// Result of installing an extension.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InstallResult {
|
|
pub name: String,
|
|
pub kind: ExtensionKind,
|
|
pub message: String,
|
|
}
|
|
|
|
/// Result of authenticating an extension.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AuthResult {
|
|
pub name: String,
|
|
pub kind: ExtensionKind,
|
|
/// OAuth URL to open (for OAuth flows).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub auth_url: Option<String>,
|
|
/// Whether using local or remote callback.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub callback_type: Option<String>,
|
|
/// Instructions for manual token entry (for WASM tools).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub instructions: Option<String>,
|
|
/// URL for manual token setup.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub setup_url: Option<String>,
|
|
/// Whether the tool is waiting for a token from the user.
|
|
#[serde(default)]
|
|
pub awaiting_token: bool,
|
|
/// Current auth status.
|
|
pub status: String,
|
|
}
|
|
|
|
/// Result of activating an extension.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ActivateResult {
|
|
pub name: String,
|
|
pub kind: ExtensionKind,
|
|
/// Names of tools that were loaded/registered.
|
|
pub tools_loaded: Vec<String>,
|
|
pub message: String,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// An installed extension with its current status.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
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).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
pub authenticated: bool,
|
|
pub active: bool,
|
|
/// Tool names if active.
|
|
#[serde(default)]
|
|
pub tools: Vec<String>,
|
|
/// Whether this extension has a setup schema (required_secrets) that can be configured.
|
|
#[serde(default)]
|
|
pub needs_setup: bool,
|
|
/// Whether this extension has an auth configuration (OAuth or manual token).
|
|
#[serde(default)]
|
|
pub has_auth: bool,
|
|
/// Whether this extension is installed locally (false = available in registry but not installed).
|
|
#[serde(default = "default_true")]
|
|
pub installed: bool,
|
|
/// Last activation error for WASM channels.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub activation_error: Option<String>,
|
|
}
|
|
|
|
/// Error type for extension operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ExtensionError {
|
|
#[error("Extension not found: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("Extension already installed: {0}")]
|
|
AlreadyInstalled(String),
|
|
|
|
#[error("Extension not installed: {0}")]
|
|
NotInstalled(String),
|
|
|
|
#[error("Authentication failed: {0}")]
|
|
AuthFailed(String),
|
|
|
|
#[error("Activation failed: {0}")]
|
|
ActivationFailed(String),
|
|
|
|
#[error("Installation failed: {0}")]
|
|
InstallFailed(String),
|
|
|
|
#[error("Discovery failed: {0}")]
|
|
DiscoveryFailed(String),
|
|
|
|
#[error("Invalid URL: {0}")]
|
|
InvalidUrl(String),
|
|
|
|
#[error("Download failed: {0}")]
|
|
DownloadFailed(String),
|
|
|
|
#[error("Config error: {0}")]
|
|
Config(String),
|
|
|
|
#[error("Primary install failed: {primary}; fallback install also failed: {fallback}")]
|
|
FallbackFailed {
|
|
primary: Box<ExtensionError>,
|
|
fallback: Box<ExtensionError>,
|
|
},
|
|
|
|
#[error("{0}")]
|
|
Other(String),
|
|
}
|