Skip to content

feat(extensions): unify auth and configure into single entrypoint#677

Merged
henrypark133 merged 5 commits intostagingfrom
unify-extension-lifecycle
Mar 11, 2026
Merged

feat(extensions): unify auth and configure into single entrypoint#677
henrypark133 merged 5 commits intostagingfrom
unify-extension-lifecycle

Conversation

@henrypark133
Copy link
Copy Markdown
Collaborator

@henrypark133 henrypark133 commented Mar 7, 2026

Summary

Refactors the extension lifecycle to eliminate divergence between chat and gateway auth paths.

  • Single configure() entrypoint: Rename save_setup_secrets()configure(). All secret storage flows through one method that validates, stores, auto-generates webhook secrets, and activates. Handles WasmChannel, WasmTool, and McpServer.
  • configure_token() convenience wrapper: For single-token callers (chat auth card, WebSocket, agent auth mode). Picks the first missing non-optional secret.
  • auth() is now read-only: Removed token parameter. Pure status check — no secret storage, no side effects. Callers use configure() to provide secrets.
  • Generic validation_endpoint: Replaced hardcoded Telegram token validation with capabilities-driven URL template. Any channel can declare validation in its capabilities.json.
  • ExtensionError::ValidationFailed: Typed error variant for token validation failures. Callers match on variant instead of string contents.
  • SSRF protection: validate_fetch_url() called before validation_endpoint HTTP requests.
  • WebSocket parity: WS error path re-emits AuthRequired on ValidationFailed (matching HTTP handler).

Problem

Chat auth path (tool_authauth_wasm_channel()) stored tokens directly without validation, auto-generation of webhook secrets, or full hot activation. The Extensions tab used a separate method (save_setup_secrets()) that did all of these. Result: Telegram setup via chat didn't work properly.

Test plan

  • cargo clippy --all --all-features — zero warnings
  • cargo test extensions::manager::tests — 12 tests pass
  • cargo test — full suite passes
  • Manual E2E: install telegram via chat, enter token through auth card, verify full activation

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 7, 2026 19:49
@github-actions github-actions Bot added scope: extensions Extension management size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 7, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the robustness and consistency of extension management, particularly for chat channels. It addresses issues with lingering secrets after extension removal and unifies the authentication process, making it more reliable and extensible. The changes ensure that authentication flows are standardized and that sensitive data is properly cleaned up, improving the overall security and user experience of extensions.

Highlights

  • Secret Cleanup on Removal: The remove() function now proactively deletes all associated secrets (setup secrets, webhook secrets, OAuth tokens) for an extension. This prevents stale secrets from persisting across uninstall/reinstall cycles by using capabilities-file enumeration and a provider-tag fallback.
  • Unified Authentication Path: The auth_wasm_channel() method now delegates to save_setup_secrets() when a token is provided. This unifies the authentication flow for chat channels with the Extensions tab, enabling consistent token validation, webhook secret auto-generation, and auto-activation.
  • Generic Token Validation: Hardcoded Telegram-specific token validation has been replaced with a generic, capabilities-driven validation_endpoint URL template. Any channel can now declare its validation endpoint in its capabilities.json file, allowing for flexible and extensible token validation.
  • Improved Error Handling: Authentication errors are now separated: token validation failures (e.g., invalid token, API rejection) will re-prompt the user, while infrastructure errors (e.g., database issues, file read failures) are propagated to the caller for appropriate handling.
Changelog
  • channels-src/telegram/telegram.capabilities.json
    • Added a validation_endpoint to the Telegram capabilities file, enabling generic token validation.
  • src/extensions/manager.rs
    • Modified the remove method to call cleanup_extension_secrets before deleting capabilities files, ensuring secrets are removed.
    • Refactored auth_wasm_channel to utilize save_setup_secrets for token handling, unifying the authentication logic.
    • Implemented specific error handling in auth_wasm_channel to differentiate between token validation errors (re-prompt) and infrastructure errors (propagate).
    • Replaced hardcoded Telegram token validation in save_setup_secrets with a generic mechanism using validation_endpoint from capabilities files.
    • Added Box::pin to an auth call within activate_wasm_channel to resolve potential async recursion issues.
    • Introduced a new cleanup_extension_secrets asynchronous function to systematically delete secrets based on extension kind and a provider-tag fallback.
    • Added new test cases to verify secret cleanup on removal, fallback secret deletion, and correct authentication behavior after reinstallation.
Activity
  • The pull request includes a detailed summary of changes and the problem being addressed.
  • A comprehensive test plan is provided, including unit tests for secret cleanup and reinstallation scenarios.
  • The author confirmed that cargo clippy --all --all-features passed with zero warnings.
  • A manual E2E test plan is outlined for verifying Telegram installation via chat, activation, removal, and reinstallation.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant improvements to the extension authentication and removal process, unifying the auth paths for chat and the Extensions tab for greater consistency. However, it introduces a High-severity SSRF vulnerability in the new secret validation logic and misses path traversal validation in the save_setup_secrets method. Additionally, there are suggestions to improve the robustness of the new error handling, enhance performance by using a more efficient data structure, and address code duplication for better maintainability.

Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses inconsistent authentication behavior between chat-driven setup and the Extensions UI, and fixes stale secrets persisting across uninstall/reinstall cycles for extensions.

Changes:

  • Add secret cleanup during ExtensionManager::remove() using capabilities-based secret enumeration plus a provider-tag fallback.
  • Unify chat auth for WASM channels by routing token entry through save_setup_secrets() (enabling validation, auto-generation, and activation).
  • Replace Telegram-specific token validation with a generic validation_endpoint declared in channel capabilities (Telegram updated accordingly).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/extensions/manager.rs Implements secret cleanup on remove, unifies WASM channel auth path via save_setup_secrets, and adds capabilities-driven token validation.
channels-src/telegram/telegram.capabilities.json Adds validation_endpoint for Telegram bot token validation via the Telegram API.
Comments suppressed due to low confidence (1)

src/extensions/manager.rs:2988

  • save_setup_secrets reads and parses the channel capabilities file multiple times (allowed-secret collection, validation_endpoint, and auto-generate blocks). This adds redundant I/O/JSON parsing and makes the logic harder to keep consistent. Consider loading cap_file once for the channel path and reusing it for allowed names, validation, and auto-generation.
        // 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 secrets against the validation_endpoint if declared in capabilities.
        // The endpoint URL template uses {secret_name} placeholders that are
        // substituted with the provided secret value before making the request.
        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)
                && let Some(ref endpoint_template) = cap_file.setup.validation_endpoint
                && let Some(secret_def) = cap_file
                    .setup
                    .required_secrets
                    .iter()
                    .find(|s| !s.optional && secrets.contains_key(&s.name))
                && let Some(token_value) = secrets.get(&secret_def.name)
            {
                let token = token_value.trim();
                if !token.is_empty() {
                    let encoded =
                        url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
                    let url =
                        endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded);
                    let resp = reqwest::Client::builder()
                        .timeout(std::time::Duration::from_secs(10))
                        .build()
                        .map_err(|e| ExtensionError::Other(e.to_string()))?
                        .get(&url)
                        .send()
                        .await
                        .map_err(|e| {
                            ExtensionError::Other(format!("Failed to validate token: {}", e))
                        })?;
                    if !resp.status().is_success() {
                        return Err(ExtensionError::Other(format!(
                            "Invalid token (API returned {})",
                            resp.status()
                        )));
                    }
                }
            }
        }

        // Validate and store each submitted secret
        for (secret_name, secret_value) in secrets {
            if !allowed.contains(secret_name.as_str()) {
                return Err(ExtensionError::Other(format!(
                    "Unknown secret '{}' for extension '{}'",
                    secret_name, name
                )));
            }
            if secret_value.trim().is_empty() {
                continue;
            }
            let params =
                CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
            self.secrets
                .create(&self.user_id, params)
                .await
                .map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
        }

        // 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)
            {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
let kind = self.determine_installed_kind(name).await?;

// Clean up secrets before deleting capabilities files
self.cleanup_extension_secrets(name, kind).await;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove() calls cleanup_extension_secrets() but discards any failures inside that cleanup (most operations are ok()/ignored). If the secrets backend is unavailable, remove will still succeed while leaving stale secrets behind—the exact class of issue this PR is trying to prevent. Consider returning Result<()> from cleanup_extension_secrets and either failing remove() on cleanup errors or at least logging warnings when deletions/listing fail.

Suggested change
self.cleanup_extension_secrets(name, kind).await;
if let Err(err) = self.cleanup_extension_secrets(name, kind).await {
eprintln!(
"Warning: failed to clean up secrets for extension '{}': {}",
name, err
);
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N/A in current commit. cleanup_extension_secrets() was removed. remove() no longer touches secrets.

@henrypark133 henrypark133 force-pushed the unify-extension-lifecycle branch from 64a72a0 to 974d916 Compare March 7, 2026 21:17
@github-actions github-actions Bot added size: L 200-499 changed lines scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: tool/builtin Built-in tools size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Mar 7, 2026
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The architectural direction is sound -- splitting auth() into read-only status check and configure() mutation. Four blocking issues:

  1. SSRF vulnerability in validation_endpoint -- The URL from capabilities.json is used for an outbound HTTP request with zero SSRF protection. A malicious capabilities file could target http://169.254.169.254/latest/meta-data/ or internal services. The project already has validate_fetch_url() -- use it here.

  2. String-based error discrimination -- Three call sites distinguish errors via msg.contains("Invalid token") || msg.contains("API returned"). Use typed error variants (TokenValidationFailed) instead of string matching.

  3. Missing regression tests -- PR description claims three tests but none appear in the diff.

  4. Secret cleanup on remove() not implemented -- The PR title says "clean up secrets on remove" but the remove() method has no changes in the diff.

Non-blocking: Capabilities file parsed multiple times in configure(), MissingSecret struct defined but never populated, WebSocket error path missing AuthRequired re-emission.

Copilot AI review requested due to automatic review settings March 9, 2026 15:42
@github-actions github-actions Bot removed the size: L 200-499 changed lines label Mar 9, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (1)

src/channels/web/ws.rs:259

  • On WebSocket auth token submission, validation failures are only returned as WsServerMessage::Error, so the UI won’t get an auth_required SSE event to re-show the auth card. Consider detecting ExtensionError::ValidationFailed(_) here and broadcasting SseEvent::AuthRequired (similar to the HTTP chat auth handler) so retries work consistently over WS.
                    Err(e) => {
                        let _ = direct_tx
                            .send(WsServerMessage::Error {
                                message: format!("Auth failed: {}", e),
                            })

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extensions/manager.rs Outdated
Comment on lines +3060 to +3064
.setup
.required_secrets
.iter()
.find(|s| !s.optional)
.map(|s| s.name.clone())
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For WASM channels, configure_token() selects the first non-optional secret in required_secrets rather than the first missing required secret. This breaks channels with multiple required secrets (e.g. discord/slack): when the UI prompts for the second secret, the submitted value will be stored under the wrong name. Compute the missing required secrets using the secrets store (same logic as auth_wasm_channel_status) and choose the first missing one.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs Outdated
Comment thread src/channels/web/handlers/chat.rs
Comment thread src/agent/thread_ops.rs
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs Outdated
Comment thread src/extensions/manager.rs
Comment thread src/channels/web/server.rs
Comment thread src/extensions/manager.rs Outdated
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (after 2e9b595)

Good progress. The ValidationFailed error variant (item #2 from my previous review) is properly addressed. The architecture -- splitting auth() into read-only status check and configure() mutation -- is clean and well-structured. Callers are simpler and the code deduplication is significant.

Remaining blocking issues:

1. SSRF in validation_endpoint -- still unresolved

Henry's reply says capabilities files are trusted (registry or local filesystem). That's true today, but:

  • The capabilities file is JSON on disk. Any future code that writes/modifies capabilities (registry sync, user config edits, etc.) becomes an SSRF vector.
  • The project already has validate_fetch_url() in src/tools/builtin/skill_tools.rs that blocks private IPs, localhost, and cloud metadata endpoints. The cost of calling it here is one function call. Defense in depth is a core project principle per CLAUDE.md.
  • The NETWORK_SECURITY.md doc explicitly lists SSRF protection as a review checklist item.

Minimal fix: call validate_fetch_url() (or extract its core logic into a shared util) on the resolved URL before reqwest::get(). This is a one-liner addition after the endpoint_template.replace() call.

2. Transport errors mapped to ValidationFailed

In configure(), the .map_err on the send().await maps network/timeout/DNS errors to ExtensionError::ValidationFailed. Downstream callers treat ValidationFailed as "bad token, re-prompt the user" -- so a transient network failure will tell the user their token is invalid and ask them to re-enter it. This was flagged by both Copilot reviews.

Fix: Map transport errors to ExtensionError::Other (or a new ExtensionError::NetworkError variant). Only map non-success HTTP status codes to ValidationFailed.

// Transport errors -> infrastructure error
.map_err(|e| ExtensionError::Other(format!("Token validation request failed: {}", e)))?;
// API rejection -> validation error  
if !resp.status().is_success() {
    return Err(ExtensionError::ValidationFailed(...));
}

3. PR description/title mismatch with actual changes

The title says "clean up secrets on remove" and the PR description lists three test functions, but neither the secret cleanup code nor the tests appear in the diff. Henry confirmed these were intentionally dropped. The PR title and description need to be updated to match reality, otherwise reviewers (and future git-log readers) will be misled.

4. configure_token() picks first non-optional secret, not first missing secret

For channels with multiple required secrets (e.g., if discord/slack were added), configure_token() always maps the token to the first non-optional secret in required_secrets, even if that secret already exists. This means re-configuring the second secret is impossible through this path. Copilot flagged this as well.

Fix: Query the secrets store for existing secrets and pick the first missing non-optional secret, matching the logic in auth_wasm_channel_status().

5. WebSocket path missing AuthRequired re-emission on ValidationFailed

In src/channels/web/ws.rs, the error path broadcasts WsServerMessage::Error but does not re-emit AuthRequired for ValidationFailed errors like the HTTP chat handler does. This means WebSocket users won't see the auth card re-appear on a bad token.

Non-blocking observations

  • Capabilities file parsed 3x in configure(): Once for allowed names, once for validation_endpoint, once for auto-generation. Should be loaded once and reused. Not blocking but worth cleaning up.
  • MissingSecret struct defined but never populated: All ConfigureResult returns have missing_secrets: vec![]. Either populate it (useful for multi-secret flows) or remove it to avoid dead code. If keeping it, populate it so callers can drive multi-step auth UX.
  • auth() doc comment still says "no side effects": The MCP auth path can initiate OAuth listeners and persist env-var tokens. The doc should reflect actual behavior.
  • Box::pin for async recursion: The comment on line ~2744 is helpful. Consider whether the recursion depth is bounded (it appears to be, since auth() no longer calls configure()).

@henrypark133 henrypark133 changed the title feat(extensions): unify chat and gateway auth paths, clean up secrets on remove feat(extensions): unify auth and configure into single entrypoint Mar 9, 2026
@henrypark133
Copy link
Copy Markdown
Collaborator Author

Addressed in a54b760

All 5 blocking items resolved:

1. SSRF protectionvalidate_fetch_url() is now called on the resolved URL before the HTTP request in configure(). Blocks private IPs, localhost, cloud metadata endpoints.

2. Transport errors vs ValidationFailed — Transport/network errors now map to ExtensionError::Other (infrastructure failure). Only HTTP non-success status codes map to ExtensionError::ValidationFailed (bad token). Callers re-prompt on ValidationFailed, propagate Other.

3. PR title/description — Updated to match actual changes. Removed "clean up secrets on remove" from title.

4. configure_token() picks first missing secret — Now queries the secrets store and picks the first non-optional secret that doesn't already exist. Falls back to first non-optional if all exist (overwrite case). Matches auth_wasm_channel_status() logic.

5. WebSocket AuthRequired re-emission — WS error path now checks for ValidationFailed and broadcasts AuthRequired SSE event, matching the HTTP chat handler pattern.

Non-blocking items noted for follow-up:

  • Capabilities file parsed 3x in configure() — worth consolidating
  • MissingSecret struct defined but not populated — will populate or remove
  • auth() doc comment re: MCP OAuth side effects

Copilot AI review requested due to automatic review settings March 9, 2026 18:15
@henrypark133 henrypark133 force-pushed the unify-extension-lifecycle branch from a54b760 to 525533f Compare March 9, 2026 18:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

src/channels/web/server.rs:1853

  • extensions_setup_submit_handler always broadcasts AuthCompleted { success: true } even when configure() returns activated: false (activation failed). Since the response already surfaces result.activated, consider aligning the SSE event’s success flag (and/or using a different event) so the UI doesn’t treat a failed activation as a completed successful auth.
    match ext_mgr.configure(&name, &req.secrets).await {
        Ok(result) => {
            // Broadcast auth_completed so the chat UI can dismiss any in-progress
            // auth card or setup modal that was triggered by tool_auth/tool_activate.
            state.sse.broadcast(SseEvent::AuthCompleted {
                extension_name: name.clone(),
                success: true,
                message: result.message.clone(),
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/thread_ops.rs
Comment on lines +1324 to +1346
match ext_mgr
.configure_token(&pending.extension_name, token)
.await
{
Ok(result) => {
tracing::info!(
"Extension '{}' authenticated via auth mode",
pending.extension_name
"Extension '{}' configured via auth mode: {}",
pending.extension_name,
result.message
);

// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
let tool_count = activate_result.tools_loaded.len();
let tool_list = if activate_result.tools_loaded.is_empty() {
String::new()
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
pending.extension_name,
e
);
let msg = format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
}
}
Ok(result) => {
// Invalid token, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let msg = result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
success: true,
message: result.message.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
Ok(Some(result.message))
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_auth_token() reports AuthCompleted { success: true } for any Ok(ConfigureResult), but configure_token() can return activated: false when activation fails. This can incorrectly mark auth as successful and exit auth mode even though the extension still isn’t usable. Consider setting success based on result.activated and deciding whether to keep/re-enter auth mode when activated is false.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +3377 to +3382
if !self
.secrets
.exists(&self.user_id, &s.name)
.await
.unwrap_or(false)
{
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() uses .exists(...).await.unwrap_or(false) when selecting which secret to populate. If the secrets backend errors, this silently treats it as “missing” and may pick the wrong secret (and then fail later in a less clear way). Consider propagating SecretsStore::exists errors (or returning an ExtensionError) instead of swallowing them.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +3284 to 3289
return Ok(ConfigureResult {
message,
activated: true,
auth_url,
missing_secrets: vec![],
});
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigureResult includes missing_secrets, but all current return paths set it to vec![] regardless of whether activation failed. This makes the field/documentation misleading and prevents callers from guiding users through multi-step setup. Consider either populating missing_secrets (e.g., by reusing the same missing-secret detection used in auth_wasm_channel_status) or removing the field until it’s supported.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/ws.rs
Comment on lines +258 to 267
match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => {
crate::channels::web::server::clear_auth_mode(state).await;
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: msg,
});
}
Ok(result) => {
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
message: result.message,
});
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() can return Ok(ConfigureResult { activated: false, .. }) when secrets were stored but hot-activation failed. This handler always broadcasts AuthCompleted { success: true } and clears auth mode on any Ok, which can incorrectly report success and prevent retry. Consider setting success based on result.activated (or broadcasting AuthRequired/keeping auth mode when activated is false).

Copilot uses AI. Check for mistakes.
Comment on lines +812 to +827
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};

// Clear auth mode on the active thread
clear_auth_mode(&state).await;
{
Ok(result) => {
// Clear auth mode on the active thread
clear_auth_mode(&state).await;

state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});

Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() may return Ok even if activation failed (result.activated == false). This handler always clears auth mode and broadcasts AuthCompleted { success: true } on any Ok, which can misreport authentication success and hide activation/setup failures. Consider using result.activated to set success and only clearing auth mode when fully activated (or re-emitting AuthRequired when not activated).

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +153
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};

// Clear auth mode on the active thread
clear_auth_mode(&state).await;
{
Ok(result) => {
clear_auth_mode(&state).await;

state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});

Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint treats any Ok(ConfigureResult) as a successful auth completion (success: true), but configure_token() can succeed at storing secrets while returning activated: false (activation failed). Consider setting success based on result.activated and avoiding clear_auth_mode() when activation didn’t complete, so the UI can retry or show next steps.

Copilot uses AI. Check for mistakes.
@henrypark133 henrypark133 changed the base branch from main to staging March 10, 2026 02:19
Copilot AI review requested due to automatic review settings March 10, 2026 22:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extensions/manager.rs
@@ -3155,13 +3080,20 @@ impl ExtensionManager {

/// Save setup secrets for an extension, validating names against the capabilities schema.
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configure() doc comment still starts with “Save setup secrets…”, which is now misleading after the rename/unification. Update/remove that first sentence so the comment consistently describes configure() as the single configuration entrypoint.

Suggested change
/// Save setup secrets for an extension, validating names against the capabilities schema.
/// Configure an extension, validating provided secret names against the capabilities schema.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +3405 to +3410
if !self
.secrets
.exists(&self.user_id, &s.name)
.await
.unwrap_or(false)
{
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secrets.exists(...).await.unwrap_or(false) silently treats secrets-store errors as “secret missing”. If the store is unavailable, configure_token() can pick the wrong target secret (or overwrite unexpectedly) instead of surfacing an infrastructure error. Prefer propagating the SecretError (map to ExtensionError) rather than defaulting to false.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines +3376 to +3385
/// Convenience wrapper: configure a single token for an extension.
///
/// Determines the primary secret name from the extension's capabilities,
/// then delegates to [`configure()`]. Use this when the caller only has
/// a bare token value (e.g., from the chat auth card or WebSocket auth).
pub async fn configure_token(
&self,
name: &str,
token: &str,
) -> Result<ConfigureResult, ExtensionError> {
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New configure() / configure_token() behavior (validation_endpoint handling, multi-secret selection, and ValidationFailed error) isn’t covered by tests in this file. Since src/extensions/manager.rs already has unit tests, please add coverage for at least: (1) multi-secret channel prompting progression (e.g., Discord), and (2) ValidationFailed vs transport errors classification.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/ws.rs
Comment on lines +258 to 268
match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => {
crate::channels::web::server::clear_auth_mode(state).await;
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: msg,
});
}
Ok(result) => {
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
message: result.message,
});
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() can return Ok(ConfigureResult) even when further user action is required (e.g., multi-secret channels like Discord/Slack still missing another secret, or tools returning an OAuth auth_url). This handler always clears auth mode and emits AuthCompleted on any Ok, which can prematurely dismiss the auth UI and block completion. Consider branching on result.activated / result.auth_url (and/or missing_secrets) and emitting AuthRequired when additional steps are needed.

Copilot uses AI. Check for mistakes.
Comment thread src/agent/thread_ops.rs
Comment on lines +1352 to +1374
match ext_mgr
.configure_token(&pending.extension_name, token)
.await
{
Ok(result) => {
tracing::info!(
"Extension '{}' authenticated via auth mode",
pending.extension_name
"Extension '{}' configured via auth mode: {}",
pending.extension_name,
result.message
);

// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
let tool_count = activate_result.tools_loaded.len();
let tool_list = if activate_result.tools_loaded.is_empty() {
String::new()
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
pending.extension_name,
e
);
let msg = format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
}
}
Ok(result) => {
// Invalid token, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let msg = result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
success: true,
message: result.message.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
Ok(Some(result.message))
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After configure_token() returns Ok, this code always emits StatusUpdate::AuthCompleted { success: true } and leaves auth mode cleared. For multi-secret extensions or OAuth tools, configuration may be partial (activated == false or auth_url present), and the agent should stay in auth mode / send AuthRequired for the next step instead of reporting completion. Consider branching on the ConfigureResult to either keep prompting (re-enter auth mode) or only send AuthCompleted once activation/auth is truly complete.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs Outdated
),
activated: true,
auth_url: None,
missing_secrets: vec![],
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigureResult.missing_secrets is always returned as an empty vec, even though the repo has multi-secret channel setup schemas (e.g., Discord/Slack). That makes it hard for callers to know whether they should re-prompt for another required secret after a configure_token() call. Consider populating missing_secrets (and/or returning a distinct result) by re-checking the capabilities’ required secrets against the secrets store after writes.

Suggested change
missing_secrets: vec![],
// Surface any secrets that are still missing after activation.
missing_secrets: result.missing_secrets,

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +162
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};

// Clear auth mode on the active thread
clear_auth_mode(&state).await;
{
Ok(result) => {
clear_auth_mode(&state).await;

state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});

Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler treats any Ok(ConfigureResult) as full success and emits AuthCompleted, but configure_token() can succeed at storing a secret while still requiring more steps (OAuth via auth_url, or another required secret for channels like Discord/Slack). Consider checking result.activated / result.auth_url (and/or missing secrets) and re-emitting AuthRequired when the user still needs to act, rather than always completing the flow.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/server.rs Outdated
@@ -1631,7 +1617,7 @@ async fn extensions_activate_handler(
// already has a token but missing the documents scope).
// Initial OAuth setup is triggered via save_setup_secrets.
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment still refers to save_setup_secrets, but the setup entrypoint was renamed to configure(). Please update the comment to match the new API to avoid confusion when tracing OAuth/setup flows.

Suggested change
// Initial OAuth setup is triggered via save_setup_secrets.
// Initial OAuth setup is triggered via configure().

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: All 5 blocking issues addressed

Verified each issue from the previous two reviews:

  1. SSRF in validation_endpoint -- Fixed. validate_fetch_url() is called on the resolved URL before the HTTP request. Token is URL-encoded before template substitution. Correct.

  2. Transport errors mapped to ValidationFailed -- Fixed. The .send().await error now maps to ExtensionError::Other ("Token validation request failed: ..."). Only non-success HTTP status codes produce ExtensionError::ValidationFailed. Callers can now correctly distinguish "bad token" from "network failure".

  3. PR title/description mismatch -- Fixed. Title and description now accurately describe the actual changes.

  4. configure_token() first-missing secret selection -- Fixed. The code queries self.secrets.exists() in a loop over non-optional secrets and picks the first missing one. Falls back to first non-optional if all exist (for overwrite scenarios). Correct for multi-secret channels.

  5. WebSocket AuthRequired re-emission -- Fixed. The WS error path in ws.rs now checks matches!(e, ExtensionError::ValidationFailed(_)) and broadcasts AuthRequired, matching the HTTP handler behavior.

Remaining non-blocking observations (carry forward, not blocking merge)

  • auth() doc says "no side effects" but auth_mcp() still runs OAuth flows (opens browser, starts callback listener). The doc comment should say "read-only for WASM extensions; may initiate OAuth for MCP servers".
  • missing_secrets always vec![] -- The MissingSecret type is defined but never populated. Either populate it to drive multi-step auth UX or remove the dead type. Low priority.
  • Capabilities file parsed multiple times in configure() -- Once for allowed names, once for validation_endpoint. Could be loaded once. Minor performance nit.
  • validation_endpoint only checked for WasmChannel -- If a WasmTool capabilities file declares a validation_endpoint, it won't be validated. Fine for now since no tools use it, but worth documenting the restriction.

The architecture is clean. Splitting auth() into a read-only status check and configure() as the mutation entrypoint eliminates the code duplication between chat and gateway paths. Callers are significantly simpler. Approving.

zmanian
zmanian previously approved these changes Mar 11, 2026
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: All 5 blocking issues addressed

Verified each issue from the previous two reviews:

  1. SSRF in validation_endpoint -- Fixed. validate_fetch_url() is called on the resolved URL before the HTTP request. Token is URL-encoded before template substitution. Correct.

  2. Transport errors mapped to ValidationFailed -- Fixed. The .send().await error now maps to ExtensionError::Other. Only non-success HTTP status codes produce ExtensionError::ValidationFailed. Callers can now correctly distinguish bad token from network failure.

  3. PR title/description mismatch -- Fixed. Title and description now accurately describe the actual changes.

  4. configure_token() first-missing secret selection -- Fixed. The code queries self.secrets.exists() in a loop over non-optional secrets and picks the first missing one. Falls back to first non-optional if all exist (for overwrite scenarios). Correct for multi-secret channels.

  5. WebSocket AuthRequired re-emission -- Fixed. The WS error path in ws.rs now checks matches!(e, ExtensionError::ValidationFailed(_)) and broadcasts AuthRequired, matching the HTTP handler behavior.

Remaining non-blocking observations (carry forward, not blocking merge)

  • auth() doc says no side effects but auth_mcp() still runs OAuth flows (opens browser, starts callback listener). The doc comment should say read-only for WASM extensions; may initiate OAuth for MCP servers.
  • missing_secrets always vec![]. The MissingSecret type is defined but never populated. Either populate it to drive multi-step auth UX or remove the dead type. Low priority.
  • Capabilities file parsed multiple times in configure(). Once for allowed names, once for validation_endpoint. Could be loaded once. Minor performance nit.
  • validation_endpoint only checked for WasmChannel. If a WasmTool capabilities file declares a validation_endpoint, it will not be validated. Fine for now since no tools use it, but worth documenting the restriction.

The architecture is clean. Splitting auth() into a read-only status check and configure() as the mutation entrypoint eliminates the code duplication between chat and gateway paths. Callers are significantly simpler.

henrypark133 and others added 3 commits March 11, 2026 12:17
Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).

Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
  providing secrets to any extension (WasmChannel, WasmTool, MCP).
  Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
  (chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
  delete token-storing branches from auth_mcp/auth_wasm_tool,
  rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
  validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…election, WS auth

1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- test_configure_token_picks_first_missing_secret: verifies multi-secret
  channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
  effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
  error variant can be pattern-matched (commit a318161)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 11, 2026 20:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1065 to +1093
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};

// Clear auth mode on the active thread
clear_auth_mode(&state).await;
{
Ok(result) => {
// Clear auth mode on the active thread
clear_auth_mode(&state).await;

state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});

Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
// Re-emit auth_required for retry on validation errors
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler clears auth mode and broadcasts AuthCompleted { success: true } for any Ok from configure_token(), even when result.activated is false. That can dismiss the auth UI even though activation/auth didn’t complete (and prevents retry for channels that don’t have a validation_endpoint). Gate success/clearing auth mode on result.activated (or re-check ext_mgr.auth() and re-emit AuthRequired when still awaiting token/OAuth).

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +174
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};

// Clear auth mode on the active thread
clear_auth_mode(&state).await;
{
Ok(result) => {
clear_auth_mode(&state).await;

state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});

Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() returning Ok is currently treated as auth completion and clears auth mode, but ConfigureResult can represent partial/incomplete outcomes (activated == false, or OAuth still required). This can dismiss the auth card prematurely and prevents retries. Consider using result.activated and/or calling ext_mgr.auth() after configuration to decide whether to emit AuthCompleted vs AuthRequired and whether to clear auth mode.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs Outdated
Comment on lines +433 to +435
/// Check auth status for an installed extension (read-only, no side effects).
///
/// To provide secrets, use [`configure()`] instead.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auth() method is documented as “read-only, no side effects”, but it can start OAuth flows / allocate a pending auth listener (e.g., MCP/WASM OAuth). That’s a side effect and could mislead callers about when it’s safe to call. Please clarify the docs to reflect the actual behavior (e.g., “does not persist secrets, but may start OAuth/pending auth flows”).

Suggested change
/// Check auth status for an installed extension (read-only, no side effects).
///
/// To provide secrets, use [`configure()`] instead.
/// Check auth status for an installed extension.
///
/// This method does not persist secrets, but it may initiate or manage
/// OAuth / pending-auth flows (for example, MCP or WASM OAuth) and perform
/// in-memory cleanup of expired auth state. Callers should not assume it is
/// side-effect free.
///
/// To provide or update secrets, use [`configure()`] instead.

Copilot uses AI. Check for mistakes.
Comment thread src/agent/thread_ops.rs
Comment on lines +1362 to 1381
Ok(result) => {
tracing::info!(
"Extension '{}' authenticated via auth mode",
pending.extension_name
"Extension '{}' configured via auth mode: {}",
pending.extension_name,
result.message
);

// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
let tool_count = activate_result.tools_loaded.len();
let tool_list = if activate_result.tools_loaded.is_empty() {
String::new()
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
pending.extension_name,
e
);
let msg = format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
}
}
Ok(result) => {
// Invalid token, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let msg = result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
success: true,
message: result.message.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
Ok(Some(result.message))
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Ok(result) this reports AuthCompleted { success: true } unconditionally, but configure_token() can return activated == false (activation failed / more config required) or require further auth steps. Consider using result.activated (and/or re-checking ext_mgr.auth()) to decide between AuthCompleted vs re-entering auth mode and sending AuthRequired.

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
};

let mut secrets = std::collections::HashMap::new();
secrets.insert(secret_name, token.to_string());
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_token() forwards token verbatim into the secrets map. Since web/WS callers may include leading/trailing whitespace, consider trimming/normalizing token here before persisting so users don’t accidentally store an invalid credential.

Suggested change
secrets.insert(secret_name, token.to_string());
let normalized_token = token.trim().to_string();
secrets.insert(secret_name, normalized_token);

Copilot uses AI. Check for mistakes.
Comment thread src/extensions/manager.rs
Comment on lines 3143 to 3145
/// For Slack: initiates OAuth flow (redirect-based).
/// For Telegram: accepts a bot token, registers it with channel-relay,
/// and stores the returned stream token.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says channel-relay auth supports Telegram token registration, but the implementation here only initiates Slack OAuth (Slack callback path and RelayProvider::Slack). Since this function signature changed in this PR, please update the docs (and/or implement the Telegram branch) to avoid misleading behavior expectations.

Suggested change
/// For Slack: initiates OAuth flow (redirect-based).
/// For Telegram: accepts a bot token, registers it with channel-relay,
/// and stores the returned stream token.
/// Currently initiates a redirect-based OAuth flow via the relay service
/// (Slack-backed), storing a CSRF nonce for the OAuth state parameter and
/// returning a URL for the user to authorize.

Copilot uses AI. Check for mistakes.
Comment on lines +1091 to +1092
}
Ok(Json(ActionResponse::fail(msg)))
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler used to surface extension-manager failures as HTTP 5xx, but now all errors from configure_token() return 200 OK with ActionResponse::fail. That makes infra/config errors indistinguishable from user input errors for clients. Consider mapping non-validation failures to an appropriate HTTP error status (and keep 200 only for expected “invalid token” style cases).

Suggested change
}
Ok(Json(ActionResponse::fail(msg)))
// Validation errors are expected user input problems: return 200 with a failure payload.
Ok(Json(ActionResponse::fail(msg)))
} else {
// Non-validation errors indicate infra/configuration problems: surface as HTTP 5xx.
Err((StatusCode::INTERNAL_SERVER_ERROR, msg))
}

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +173
}
Ok(Json(ActionResponse::fail(msg)))
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors from configure_token() are always returned as 200 OK with ActionResponse::fail, whereas this handler previously returned HTTP errors for execution failures. Consider preserving an HTTP error status for infrastructure/config failures so clients can distinguish “invalid token” from server-side issues.

Suggested change
}
Ok(Json(ActionResponse::fail(msg)))
Ok(Json(ActionResponse::fail(msg)))
} else {
Err((StatusCode::INTERNAL_SERVER_ERROR, msg))
}

Copilot uses AI. Check for mistakes.
…onsolidation

- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
  instead of unconditionally calling activate_wasm_channel() for all
  non-WasmTool types (MCP servers and channel relays now use their
  correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
  populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
  and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (round 3): APPROVE

All 5 blocking issues from my previous two reviews are verified fixed:

  1. SSRF in validation_endpoint -- validate_fetch_url() called before HTTP request
  2. String matching for errors -- ExtensionError::ValidationFailed variant with matches!() in all callers
  3. Transport errors mistyped -- reqwest failures now map to ExtensionError::Other
  4. configure_token() wrong secret -- iterates to find first missing non-optional secret
  5. Activation dispatch bug -- dispatches by ExtensionKind (latest commit c175396)

Architecture is clean: auth() is read-only, configure() is the single mutation entrypoint, dead code removed, capabilities parsed once. 4 regression tests cover the critical invariants.

@henrypark133 henrypark133 merged commit 19d9562 into staging Mar 11, 2026
13 of 14 checks passed
@henrypark133 henrypark133 deleted the unify-extension-lifecycle branch March 11, 2026 23:01
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Mar 12, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
…arai#677)

* feat(extensions): unify auth and configure into single entrypoint

Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).

Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
  providing secrets to any extension (WasmChannel, WasmTool, MCP).
  Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
  (chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
  delete token-storing branches from auth_mcp/auth_wasm_tool,
  rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
  validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use ValidationFailed error variant instead of string matching

Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth

1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add regression tests for extension lifecycle refactoring

- test_configure_token_picks_first_missing_secret: verifies multi-secret
  channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
  effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
  error variant can be pattern-matched (commit a318161)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review comments — activation dispatch, dead code, caps consolidation

- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
  instead of unconditionally calling activate_wasm_channel() for all
  non-WasmTool types (MCP servers and channel relays now use their
  correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
  populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
  and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
…arai#677)

* feat(extensions): unify auth and configure into single entrypoint

Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).

Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
  providing secrets to any extension (WasmChannel, WasmTool, MCP).
  Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
  (chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
  delete token-storing branches from auth_mcp/auth_wasm_tool,
  rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
  validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use ValidationFailed error variant instead of string matching

Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth

1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add regression tests for extension lifecycle refactoring

- test_configure_token_picks_first_missing_secret: verifies multi-secret
  channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
  effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
  error variant can be pattern-matched (commit a318161)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review comments — activation dispatch, dead code, caps consolidation

- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
  instead of unconditionally calling activate_wasm_channel() for all
  non-WasmTool types (MCP servers and channel relays now use their
  correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
  populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
  and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: extensions Extension management scope: tool/builtin Built-in tools size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants