diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 01352005..fef89bae 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,7 +52,7 @@ jobs: - group: features files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py" - group: extensions - files: "tests/e2e/scenarios/test_extensions.py" + files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py" steps: - uses: actions/checkout@v6 diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 5039ad82..48ef452c 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -573,6 +573,14 @@ async fn oauth_callback_handler( extension = %flow.extension_name, "OAuth flow expired" ); + // Notify UI so auth card can show error instead of staying stuck + if let Some(ref sender) = flow.sse_sender { + let _ = sender.send(SseEvent::AuthCompleted { + extension_name: flow.extension_name.clone(), + success: false, + message: "OAuth flow expired. Please try again.".to_string(), + }); + } return oauth_error_page(&flow.display_name); } @@ -2706,6 +2714,7 @@ struct GatewayStatusResponse { #[cfg(test)] mod tests { use super::*; + use crate::cli::oauth_defaults; use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY; #[test] @@ -2823,6 +2832,11 @@ mod tests { .with_state(state) } + fn expired_flow_created_at() -> Option { + std::time::Instant::now() + .checked_sub(oauth_defaults::OAUTH_FLOW_EXPIRY + std::time::Duration::from_secs(1)) + } + #[tokio::test] async fn test_csp_header_present_on_responses() { use std::net::SocketAddr; @@ -2929,29 +2943,14 @@ mod tests { use tower::ServiceExt; // Build an ExtensionManager so the handler can look up flows - let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( - crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( - TEST_GATEWAY_CRYPTO_KEY.to_string(), - )) - .expect("crypto"), - ))); - let tool_registry = Arc::new(ToolRegistry::new()); - let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); - - let ext_mgr = Arc::new(ExtensionManager::new( - mcp_sm, - Arc::new(crate::tools::mcp::process::McpProcessManager::new()), - secrets, - tool_registry, - None, - None, - std::path::PathBuf::from("/tmp/wasm_tools"), - std::path::PathBuf::from("/tmp/wasm_channels"), - None, - "test".to_string(), - None, - vec![], - )); + let secrets: Arc = + Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( + crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( + TEST_GATEWAY_CRYPTO_KEY.to_string(), + )) + .expect("crypto"), + ))); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets); let state = test_gateway_state(Some(ext_mgr)); let app = test_oauth_router(state); @@ -2985,25 +2984,13 @@ mod tests { )) .expect("crypto"), ))); - let tool_registry = Arc::new(ToolRegistry::new()); - let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone()); + let Some(created_at) = expired_flow_created_at() else { + eprintln!("Skipping expired OAuth flow test: monotonic uptime below expiry window"); + return; + }; - let ext_mgr = Arc::new(ExtensionManager::new( - mcp_sm, - Arc::new(crate::tools::mcp::process::McpProcessManager::new()), - secrets.clone(), - tool_registry, - None, - None, - std::path::PathBuf::from("/tmp/wasm_tools"), - std::path::PathBuf::from("/tmp/wasm_channels"), - None, - "test".to_string(), - None, - vec![], - )); - - // Insert an expired flow (created 10 minutes ago) + // Insert an expired flow. let flow = crate::cli::oauth_defaults::PendingOAuthFlow { extension_name: "test_tool".to_string(), display_name: "Test Tool".to_string(), @@ -3023,9 +3010,7 @@ mod tests { gateway_token: None, resource: None, client_id_secret_name: None, - created_at: std::time::Instant::now() - .checked_sub(std::time::Duration::from_secs(600)) - .expect("System uptime is too low to run expired flow test"), + created_at, }; ext_mgr @@ -3055,6 +3040,80 @@ mod tests { assert!(html.contains("Authorization Failed")); } + #[tokio::test] + async fn test_oauth_callback_expired_flow_broadcasts_auth_completed_failure() { + use axum::body::Body; + use tower::ServiceExt; + + let secrets: Arc = + Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( + crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( + TEST_GATEWAY_CRYPTO_KEY.to_string(), + )) + .expect("crypto"), + ))); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone()); + + let (sender, mut receiver) = tokio::sync::broadcast::channel(4); + let Some(created_at) = expired_flow_created_at() else { + eprintln!("Skipping expired OAuth flow SSE test: monotonic uptime below expiry window"); + return; + }; + let flow = crate::cli::oauth_defaults::PendingOAuthFlow { + extension_name: "test_tool".to_string(), + display_name: "Test Tool".to_string(), + token_url: "https://example.com/token".to_string(), + client_id: "client123".to_string(), + client_secret: None, + redirect_uri: "https://example.com/oauth/callback".to_string(), + code_verifier: None, + access_token_field: "access_token".to_string(), + secret_name: "test_token".to_string(), + provider: None, + validation_endpoint: None, + scopes: vec![], + user_id: "test".to_string(), + secrets, + sse_sender: Some(sender), + gateway_token: None, + resource: None, + client_id_secret_name: None, + created_at, + }; + + ext_mgr + .pending_oauth_flows() + .write() + .await + .insert("expired_state".to_string(), flow); + + let state = test_gateway_state(Some(ext_mgr)); + let app = test_oauth_router(state); + + let req = axum::http::Request::builder() + .uri("/oauth/callback?code=test_code&state=expired_state") + .body(Body::empty()) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + + match receiver.recv().await.expect("auth_completed event") { + crate::channels::web::types::SseEvent::AuthCompleted { + extension_name, + success, + message, + } => { + assert_eq!(extension_name, "test_tool"); + assert!(!success, "expired OAuth flow should broadcast failure"); + assert_eq!(message, "OAuth flow expired. Please try again."); + } + event => panic!("expected AuthCompleted event, got {event:?}"), + } + } + #[tokio::test] async fn test_oauth_callback_no_extension_manager() { use axum::body::Body; @@ -3093,28 +3152,16 @@ mod tests { )) .expect("crypto"), ))); - let tool_registry = Arc::new(ToolRegistry::new()); - let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); - - let ext_mgr = Arc::new(ExtensionManager::new( - mcp_sm, - Arc::new(crate::tools::mcp::process::McpProcessManager::new()), - secrets.clone(), - tool_registry, - None, - None, - std::path::PathBuf::from("/tmp/wasm_tools"), - std::path::PathBuf::from("/tmp/wasm_channels"), - None, - "test".to_string(), - None, - vec![], - )); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone()); // Insert a flow keyed by raw nonce "test_nonce" (without instance prefix). // Use an expired flow so the handler exits before attempting a real HTTP // token exchange — we only need to verify that the instance prefix was // stripped and the flow was found by the raw nonce. + let Some(created_at) = expired_flow_created_at() else { + eprintln!("Skipping OAuth state-prefix test: monotonic uptime below expiry window"); + return; + }; let flow = crate::cli::oauth_defaults::PendingOAuthFlow { extension_name: "test_tool".to_string(), display_name: "Test Tool".to_string(), @@ -3135,9 +3182,7 @@ mod tests { resource: None, client_id_secret_name: None, // Expired — handler will reject after lookup (no network I/O) - created_at: std::time::Instant::now() - .checked_sub(std::time::Duration::from_secs(600)) - .expect("System uptime is too low to run expired flow test"), + created_at, }; ext_mgr @@ -3208,24 +3253,27 @@ mod tests { fn test_ext_mgr( secrets: Arc, - ) -> Arc { + ) -> (Arc, tempfile::TempDir, tempfile::TempDir) { let tool_registry = Arc::new(ToolRegistry::new()); let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new()); - Arc::new(ExtensionManager::new( + let wasm_tools_dir = tempfile::tempdir().expect("temp wasm tools dir"); + let wasm_channels_dir = tempfile::tempdir().expect("temp wasm channels dir"); + let ext_mgr = Arc::new(ExtensionManager::new( mcp_sm, mcp_pm, secrets, tool_registry, None, None, - std::path::PathBuf::from("/tmp/wasm_tools"), - std::path::PathBuf::from("/tmp/wasm_channels"), + wasm_tools_dir.path().to_path_buf(), + wasm_channels_dir.path().to_path_buf(), None, "test".to_string(), None, vec![], - )) + )); + (ext_mgr, wasm_tools_dir, wasm_channels_dir) } #[tokio::test] @@ -3234,7 +3282,7 @@ mod tests { use tower::ServiceExt; let secrets = test_secrets_store(); - let ext_mgr = test_ext_mgr(secrets); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); @@ -3278,7 +3326,7 @@ mod tests { .await .expect("store nonce"); - let ext_mgr = test_ext_mgr(secrets); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); @@ -3323,7 +3371,7 @@ mod tests { .await .expect("store nonce"); - let ext_mgr = test_ext_mgr(secrets.clone()); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone()); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index b151840f..081ae60d 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -358,29 +358,11 @@ function connectSSE() { }); eventSource.addEventListener('auth_required', (e) => { - const data = JSON.parse(e.data); - if (data.auth_url) { - // OAuth flow: show the auth card with an OAuth button + optional token paste field. - showAuthCard(data); - } else { - // Setup flow: fetch the extension's credential schema and show the multi-field - // configure modal (the same UI used by the Extensions tab "Setup" button). - showConfigureModal(data.extension_name); - } + handleAuthRequired(JSON.parse(e.data)); }); eventSource.addEventListener('auth_completed', (e) => { - const data = JSON.parse(e.data); - // Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup). - removeAuthCard(data.extension_name); - closeConfigureModal(); - showToast(data.message, data.success ? 'success' : 'error'); - if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) { - addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.'); - } - // Refresh extensions list so status indicators update - if (currentTab === 'extensions') loadExtensions(); - enableChatInput(); + handleAuthCompleted(JSON.parse(e.data)); }); eventSource.addEventListener('extension_status', (e) => { @@ -1139,13 +1121,71 @@ function showJobCard(data) { // --- Auth card --- +function handleAuthRequired(data) { + if (data.auth_url) { + // OAuth flow: show the global auth prompt with an OAuth button + optional token paste field. + showAuthCard(data); + } else { + // Setup flow: fetch the extension's credential schema and show the multi-field + // configure modal (the same UI used by the Extensions tab "Setup" button). + showConfigureModal(data.extension_name); + } +} + +function handleAuthCompleted(data) { + // Dismiss only the matching extension's UI so unrelated setup work is not interrupted. + removeAuthCard(data.extension_name); + closeConfigureModal(data.extension_name); + showToast(data.message, data.success ? 'success' : 'error'); + if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) { + addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.'); + } + if (currentTab === 'extensions') loadExtensions(); + enableChatInput(); +} + +function queryByDataAttribute(selector, attributeName, attributeValue) { + if (typeof attributeValue !== 'string') return document.querySelector(selector); + + if (window.CSS && typeof window.CSS.escape === 'function') { + return document.querySelector( + selector + '[' + attributeName + '="' + window.CSS.escape(attributeValue) + '"]' + ); + } + + const candidates = document.querySelectorAll(selector); + for (const candidate of candidates) { + if (candidate.getAttribute(attributeName) === attributeValue) return candidate; + } + return null; +} + +function getAuthOverlay(extensionName) { + return queryByDataAttribute('.auth-overlay', 'data-extension-name', extensionName); +} + +function getAuthCard(extensionName) { + return queryByDataAttribute('.auth-card', 'data-extension-name', extensionName); +} + +function getConfigureOverlay(extensionName) { + return queryByDataAttribute('.configure-overlay', 'data-extension-name', extensionName); +} + function showAuthCard(data) { - // Remove any existing card for this extension first - removeAuthCard(data.extension_name); + // Keep a single global auth prompt so the experience is consistent across tabs. + const existing = getAuthOverlay(); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'auth-overlay'; + overlay.setAttribute('data-extension-name', data.extension_name); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) cancelAuth(data.extension_name); + }); - const container = document.getElementById('chat-messages'); const card = document.createElement('div'); - card.className = 'auth-card'; + card.className = 'auth-card auth-modal'; card.setAttribute('data-extension-name', data.extension_name); const header = document.createElement('div'); @@ -1224,21 +1264,30 @@ function showAuthCard(data) { actions.appendChild(cancelBtn); card.appendChild(actions); - container.appendChild(card); - container.scrollTop = container.scrollHeight; + overlay.appendChild(card); + document.body.appendChild(overlay); tokenInput.focus(); } function removeAuthCard(extensionName) { - const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); - if (card) card.remove(); + const overlay = getAuthOverlay(extensionName); + if (overlay) { + overlay.remove(); + return; + } + const card = getAuthCard(extensionName); + if (card) { + const parentOverlay = card.closest('.auth-overlay'); + if (parentOverlay) parentOverlay.remove(); + else card.remove(); + } } function submitAuthToken(extensionName, tokenValue) { if (!tokenValue || !tokenValue.trim()) return; // Disable submit button while in flight - const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); + const card = getAuthCard(extensionName); if (card) { const btns = card.querySelectorAll('button'); btns.forEach((b) => { b.disabled = true; }); @@ -1249,8 +1298,10 @@ function submitAuthToken(extensionName, tokenValue) { body: { extension_name: extensionName, token: tokenValue.trim() }, }).then((result) => { if (result.success) { + // Close immediately for responsiveness; the authoritative success UX + // (toast + extensions refresh) still comes from auth_completed SSE. removeAuthCard(extensionName); - addMessage('system', result.message); + enableChatInput(); } else { showAuthCardError(extensionName, result.message); } @@ -1269,7 +1320,7 @@ function cancelAuth(extensionName) { } function showAuthCardError(extensionName, message) { - const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]'); + const card = getAuthCard(extensionName); if (!card) return; // Re-enable buttons const btns = card.querySelectorAll('button'); @@ -2199,6 +2250,10 @@ function renderAvailableExtensionCard(entry) { showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success'); // OAuth popup if auth started during install (builtin creds) if (res.auth_url) { + showAuthCard({ + extension_name: entry.name, + auth_url: res.auth_url, + }); showToast('Opening authentication for ' + entry.display_name, 'info'); openOAuthUrl(res.auth_url); } @@ -2464,6 +2519,10 @@ function activateExtension(name) { if (res.success) { // Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet) if (res.auth_url) { + showAuthCard({ + extension_name: name, + auth_url: res.auth_url, + }); showToast('Opening authentication for ' + name, 'info'); openOAuthUrl(res.auth_url); } @@ -2472,6 +2531,10 @@ function activateExtension(name) { } if (res.auth_url) { + showAuthCard({ + extension_name: name, + auth_url: res.auth_url, + }); showToast('Opening authentication for ' + name, 'info'); openOAuthUrl(res.auth_url); } else if (res.awaiting_token) { @@ -2514,6 +2577,7 @@ function renderConfigureModal(name, secrets) { closeConfigureModal(); const overlay = document.createElement('div'); overlay.className = 'configure-overlay'; + overlay.setAttribute('data-extension-name', name); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeConfigureModal(); }); @@ -2607,7 +2671,8 @@ function submitConfigureModal(name, fields) { } // Disable buttons to prevent double-submit - var btns = document.querySelectorAll('.configure-actions button'); + const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay'); + var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : []; btns.forEach(function(b) { b.disabled = true; }); apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', { @@ -2618,8 +2683,10 @@ function submitConfigureModal(name, fields) { if (res.success) { closeConfigureModal(); if (res.auth_url) { - // OAuth flow started — open consent popup. The auth_completed SSE will - // not arrive immediately (it fires after OAuth callback), so show a toast now. + showAuthCard({ + extension_name: name, + auth_url: res.auth_url, + }); showToast('Opening OAuth authorization for ' + name, 'info'); openOAuthUrl(res.auth_url); loadExtensions(); @@ -2638,8 +2705,9 @@ function submitConfigureModal(name, fields) { }); } -function closeConfigureModal() { - const existing = document.querySelector('.configure-overlay'); +function closeConfigureModal(extensionName) { + if (typeof extensionName !== 'string') extensionName = null; + const existing = getConfigureOverlay(extensionName); if (existing) existing.remove(); } diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index a7e8d4b1..b6e1cbdf 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -1219,7 +1219,21 @@ body { color: var(--danger); } -/* Auth card (inline in chat) */ +/* Auth prompt */ +.auth-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + .auth-card { align-self: flex-start; max-width: 80%; @@ -1234,6 +1248,16 @@ body { transition: border-color 0.2s; } +.auth-overlay .auth-card { + width: 460px; + max-width: min(460px, 90vw); + margin: 0; + align-self: auto; + background: var(--bg); + border-color: rgba(52, 211, 153, 0.35); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35); +} + .auth-card .auth-header { font-weight: 600; color: var(--accent); diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 2a6cc6d1..6488caa5 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -786,6 +786,19 @@ impl ExtensionManager { Self::validate_extension_name(name)?; let kind = self.determine_installed_kind(name).await?; + // Clean up any in-progress OAuth flows for this extension. + // TCP mode: abort the listener task so port 9876 is freed immediately. + // Gateway mode: remove stale pending flow entries. + if let Some(pending) = self.pending_auth.write().await.remove(name) + && let Some(handle) = pending.task_handle + { + handle.abort(); + } + self.pending_oauth_flows + .write() + .await + .retain(|_, flow| flow.extension_name != name); + match kind { ExtensionKind::McpServer => { // Unregister tools with this server's prefix @@ -819,6 +832,14 @@ impl ExtensionManager { // Unregister from tool registry self.tool_registry.unregister(name).await; + // Evict compiled module from runtime cache so reinstall uses fresh binary + if let Some(ref rt) = self.wasm_tool_runtime { + rt.remove(name).await; + } + + // Clear stale activation errors so reinstall starts clean + self.activation_errors.write().await.remove(name); + // Revoke credential mappings from the shared registry let cap_path = self .wasm_tools_dir @@ -859,6 +880,9 @@ impl ExtensionManager { self.active_channel_names.write().await.remove(name); self.persist_active_channels().await; + // Clear stale activation errors so reinstall starts clean + self.activation_errors.write().await.remove(name); + // Delete channel files let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name)); let cap_path = self @@ -2860,6 +2884,17 @@ impl ExtensionManager { }); } + // Check auth status — block activation if required secrets are missing. + // NeedsAuth (OAuth not yet completed) is allowed because configure() loads + // the tool first, then starts the OAuth flow to obtain the token. + let auth_state = self.check_tool_auth_status(name).await; + if auth_state == ToolAuthState::NeedsSetup { + return Err(ExtensionError::ActivationFailed(format!( + "Tool '{}' requires configuration. Use the setup form to provide credentials.", + name + ))); + } + let runtime = self.wasm_tool_runtime.as_ref().ok_or_else(|| { ExtensionError::ActivationFailed("WASM runtime not available".to_string()) })?; @@ -4495,14 +4530,18 @@ mod tests { // available" because the ExtensionManager had `wasm_tool_runtime: None`. /// Build a minimal ExtensionManager suitable for unit tests. - fn make_test_manager( + fn make_test_manager_with_dirs( wasm_runtime: Option>, tools_dir: std::path::PathBuf, + channels_dir: std::path::PathBuf, ) -> crate::extensions::manager::ExtensionManager { use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; use crate::tools::mcp::process::McpProcessManager; use crate::tools::mcp::session::McpSessionManager; + std::fs::create_dir_all(&tools_dir).ok(); + std::fs::create_dir_all(&channels_dir).ok(); + let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex()); let crypto = Arc::new(SecretsCrypto::new(key).expect("crypto")); let secrets: Arc = @@ -4517,15 +4556,22 @@ mod tests { tools, None, // hooks wasm_runtime, - tools_dir.clone(), - tools_dir, // channels dir (unused here) - None, // tunnel_url + tools_dir, + channels_dir, + None, // tunnel_url "test".to_string(), None, // db vec![], ) } + fn make_test_manager( + wasm_runtime: Option>, + tools_dir: std::path::PathBuf, + ) -> crate::extensions::manager::ExtensionManager { + make_test_manager_with_dirs(wasm_runtime, tools_dir.clone(), tools_dir) + } + #[tokio::test] async fn test_activate_wasm_tool_with_runtime_passes_runtime_check() { // When the ExtensionManager has a WASM runtime, activation should get @@ -4878,6 +4924,145 @@ mod tests { ); } + #[tokio::test] + async fn test_remove_wasm_tool_clears_pending_oauth_state_and_activation_error() { + let dir = tempfile::tempdir().expect("temp dir"); + let mgr = make_test_manager(None, dir.path().to_path_buf()); + + std::fs::write(dir.path().join("gmail.wasm"), b"fake-tool").expect("write tool"); + + let listener = tokio::spawn(async { + std::future::pending::<()>().await; + }); + let abort_handle = listener.abort_handle(); + mgr.pending_auth.write().await.insert( + "gmail".to_string(), + super::PendingAuth { + _name: "gmail".to_string(), + _kind: ExtensionKind::WasmTool, + created_at: std::time::Instant::now(), + task_handle: Some(listener), + }, + ); + + mgr.activation_errors + .write() + .await + .insert("gmail".to_string(), "cached failure".to_string()); + + let secrets = Arc::clone(&mgr.secrets); + mgr.pending_oauth_flows().write().await.insert( + "gmail-state".to_string(), + crate::cli::oauth_defaults::PendingOAuthFlow { + extension_name: "gmail".to_string(), + display_name: "Gmail".to_string(), + token_url: "https://example.com/token".to_string(), + client_id: "client123".to_string(), + client_secret: None, + redirect_uri: "https://example.com/oauth/callback".to_string(), + code_verifier: None, + access_token_field: "access_token".to_string(), + secret_name: "google_oauth_token".to_string(), + provider: None, + validation_endpoint: None, + scopes: vec![], + user_id: "test".to_string(), + secrets: Arc::clone(&secrets), + sse_sender: None, + gateway_token: None, + resource: None, + client_id_secret_name: None, + created_at: std::time::Instant::now(), + }, + ); + mgr.pending_oauth_flows().write().await.insert( + "other-state".to_string(), + crate::cli::oauth_defaults::PendingOAuthFlow { + extension_name: "web-search".to_string(), + display_name: "Web Search".to_string(), + token_url: "https://example.com/token".to_string(), + client_id: "client456".to_string(), + client_secret: None, + redirect_uri: "https://example.com/oauth/callback".to_string(), + code_verifier: None, + access_token_field: "access_token".to_string(), + secret_name: "other_token".to_string(), + provider: None, + validation_endpoint: None, + scopes: vec![], + user_id: "test".to_string(), + secrets, + sse_sender: None, + gateway_token: None, + resource: None, + client_id_secret_name: None, + created_at: std::time::Instant::now(), + }, + ); + + let result = mgr.remove("gmail").await; + assert!(result.is_ok(), "remove should succeed: {:?}", result.err()); + + tokio::task::yield_now().await; + + assert!( + mgr.pending_auth.read().await.get("gmail").is_none(), + "pending auth entry should be removed" + ); + assert!( + abort_handle.is_finished(), + "pending auth listener should be aborted" + ); + assert!( + !mgr.activation_errors.read().await.contains_key("gmail"), + "stale activation error should be cleared" + ); + + let flows = mgr.pending_oauth_flows().read().await; + assert!( + !flows.contains_key("gmail-state"), + "gateway OAuth flow for removed extension should be cleared" + ); + assert!( + flows.contains_key("other-state"), + "unrelated pending OAuth flows should be retained" + ); + } + + #[tokio::test] + async fn test_remove_wasm_channel_clears_activation_error_and_deletes_files() { + let dir = tempfile::tempdir().expect("temp dir"); + let tools_dir = dir.path().join("tools"); + let channels_dir = dir.path().join("channels"); + let mgr = make_test_manager_with_dirs(None, tools_dir, channels_dir.clone()); + + let wasm_path = channels_dir.join("telegram.wasm"); + let cap_path = channels_dir.join("telegram.capabilities.json"); + std::fs::write(&wasm_path, b"fake-channel").expect("write channel"); + std::fs::write(&cap_path, b"{}").expect("write capabilities"); + + mgr.activation_errors + .write() + .await + .insert("telegram".to_string(), "channel failed".to_string()); + + let result = mgr.remove("telegram").await; + assert!(result.is_ok(), "remove should succeed: {:?}", result.err()); + + assert!( + !mgr.activation_errors.read().await.contains_key("telegram"), + "channel activation error should be cleared on remove" + ); + assert!( + !wasm_path.exists(), + "channel wasm file should be deleted on remove" + ); + assert!( + !cap_path.exists(), + "channel capabilities file should be deleted on remove" + ); + } + #[test] fn test_sanitize_url_with_query_params() { let url = "https://api.example.com/path?api_key=secret123&token=abc"; @@ -5153,7 +5338,6 @@ mod tests { Some("https://my-gateway.example.com/oauth/callback".to_string()), ); } - // ── Regression tests for PR #677 (unify-extension-lifecycle) ───────── #[tokio::test] @@ -5303,7 +5487,6 @@ mod tests { "configure should have stored the relay stream token" ); } - #[test] fn test_validation_failed_is_distinct_error_variant() { // Regression: ValidationFailed must be a distinct error variant so diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 41a9fd29..9503136d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -20,9 +20,31 @@ from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready # Project root (two levels up from tests/e2e/) ROOT = Path(__file__).resolve().parent.parent.parent +# Git main repo root (for worktree support — WASM build artifacts live +# in the main repo's tools-src/*/target/ and aren't shared across worktrees) +_MAIN_ROOT = None +try: + import subprocess as _sp + _common = _sp.check_output( + ["git", "worktree", "list", "--porcelain"], + cwd=ROOT, text=True, stderr=_sp.DEVNULL, + ) + for line in _common.splitlines(): + if line.startswith("worktree "): + _MAIN_ROOT = Path(line.split(" ", 1)[1]) + break # first entry is always the main worktree +except Exception: + pass + # Temp directory for the libSQL database file (cleaned up automatically) _DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-") +# Temp directories for WASM extensions. These start empty and are populated by +# the install pipeline during tests; fixtures do not pre-populate dev build +# artifacts into them. +_WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools-") +_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-") + def _find_free_port() -> int: """Bind to port 0 and return the OS-assigned port.""" @@ -70,7 +92,53 @@ async def mock_llm_server(): @pytest.fixture(scope="session") -async def ironclaw_server(ironclaw_binary, mock_llm_server): +def wasm_tools_dir(_wasm_build_symlinks): + """Empty temp dir for WASM tools. + + Starts empty so the server has no pre-loaded extensions at boot. + The install API (POST /api/extensions/install) downloads and writes + WASM files here; tests exercise the full install pipeline. + + NOTE on capabilities file naming: Cargo builds with underscored stems + (web_search_tool.wasm) but capabilities use hyphens (web-search-tool. + capabilities.json). The loader expects matching stems. If you pre-load + files, rename caps: web-search-tool → web_search_tool. + """ + return str(Path(_WASM_TOOLS_TMPDIR.name)) + + +@pytest.fixture(scope="session", autouse=True) +def _wasm_build_symlinks(): + """Symlink WASM build artifacts from the main repo into the worktree. + + In a git worktree, tools-src/*/target/ directories don't exist because + Cargo build artifacts aren't shared. The install API's source fallback + checks these paths. Symlinking makes the fallback work without rebuilding. + """ + if _MAIN_ROOT is None or _MAIN_ROOT == ROOT: + yield + return + + created = [] + tools_src = ROOT / "tools-src" + main_tools_src = _MAIN_ROOT / "tools-src" + if tools_src.is_dir() and main_tools_src.is_dir(): + for tool_dir in tools_src.iterdir(): + if not tool_dir.is_dir(): + continue + target = tool_dir / "target" + main_target = main_tools_src / tool_dir.name / "target" + if not target.exists() and main_target.is_dir(): + target.symlink_to(main_target) + created.append(target) + yield + for link in created: + if link.is_symlink(): + link.unlink() + + +@pytest.fixture(scope="session") +async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): """Start the ironclaw gateway. Yields the base URL.""" gateway_port = _find_free_port() env = { @@ -95,8 +163,16 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server): "ROUTINES_ENABLED": "false", "HEARTBEAT_ENABLED": "false", "EMBEDDING_ENABLED": "false", + # WASM tool/channel support + "WASM_ENABLED": "true", + "WASM_TOOLS_DIR": wasm_tools_dir, + "WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name, # Prevent onboarding wizard from triggering "ONBOARD_COMPLETED": "true", + # Force gateway OAuth callback mode (non-loopback URL) and point + # token exchange at mock_llm.py so OAuth tests work without Google. + "IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback", + "IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server, } # Forward LLVM coverage instrumentation env vars when present # (allows cargo-llvm-cov to collect profraw data from E2E runs). diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index b6927dce..629205a1 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -133,3 +133,32 @@ async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> i if match := re.search(pattern, decoded): return int(match.group(1)) raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s") + + +# -- API helpers ----------------------------------------------------------- + +def auth_headers() -> dict[str, str]: + """Return Authorization header dict for authenticated API calls.""" + return {"Authorization": f"Bearer {AUTH_TOKEN}"} + + +async def api_get(base_url: str, path: str, **kwargs) -> httpx.Response: + """Make an authenticated GET request to the ironclaw API.""" + async with httpx.AsyncClient() as client: + return await client.get( + f"{base_url}{path}", + headers=auth_headers(), + timeout=kwargs.pop("timeout", 10), + **kwargs, + ) + + +async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response: + """Make an authenticated POST request to the ironclaw API.""" + async with httpx.AsyncClient() as client: + return await client.post( + f"{base_url}{path}", + headers=auth_headers(), + timeout=kwargs.pop("timeout", 10), + **kwargs, + ) diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index deb18bd7..0fa0ce9f 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -1,11 +1,16 @@ -"""Mock OpenAI-compatible LLM server for E2E tests.""" +"""Mock OpenAI-compatible LLM server for E2E tests. + +Serves OpenAI-compatible endpoints for chat completions and model listing. +Supports both streaming and non-streaming responses, plus function calling +via TOOL_CALL_PATTERNS. +""" import argparse +import asyncio import json import re import time import uuid - from aiohttp import web CANNED_RESPONSES = [ @@ -13,85 +18,207 @@ CANNED_RESPONSES = [ (re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."), (re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."), (re.compile(r"html.?test|injection.?test", re.IGNORECASE), - 'Here is some content: and and end of content.'), + 'Here is some content: and ' + ' and end of content.'), ] DEFAULT_RESPONSE = "I understand your request." +TOOL_CALL_PATTERNS = [ + (re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}), + (re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}), +] -def match_response(messages: list[dict]) -> str: - """Find canned response for the last user message.""" + +def _last_user_content(messages: list[dict]) -> str: for msg in reversed(messages): if msg.get("role") == "user": content = msg.get("content", "") - # Handle content that may be a list (multi-modal) if isinstance(content, list): content = " ".join( - part.get("text", "") for part in content if part.get("type") == "text" + p.get("text", "") for p in content if p.get("type") == "text" ) - for pattern, response in CANNED_RESPONSES: - if pattern.search(content): - return response - return DEFAULT_RESPONSE + return content + return "" + + +def match_response(messages: list[dict]) -> str: + content = _last_user_content(messages) + for pattern, response in CANNED_RESPONSES: + if pattern.search(content): + return response return DEFAULT_RESPONSE +def match_tool_call(messages: list[dict], has_tools: bool) -> dict | None: + if not has_tools: + return None + content = _last_user_content(messages) + for pattern, tool_name, args_fn in TOOL_CALL_PATTERNS: + m = pattern.search(content) + if m: + return {"tool_name": tool_name, "arguments": args_fn(m)} + return None + + +def _extract_tool_name(msg: dict) -> str: + """Extract tool name from a message, checking both 'name' field and XML content.""" + name = msg.get("name") + if name: + return name + # ironclaw wraps tool output as + content = msg.get("content", "") + m = re.search(r' dict | None: + """Find a pending tool result that appears after the last user message. + + Only returns a tool result if it's a fresh result the agent is waiting + for the LLM to summarize (i.e., it follows the most recent user message). + This prevents stale tool results from earlier conversation turns from + being re-processed. + """ + # Find the position of the last user message + last_user_idx = -1 + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + last_user_idx = i + break + + # Only look for tool results after the last user message + for i in range(len(messages) - 1, last_user_idx, -1): + if messages[i].get("role") == "tool": + return {"name": _extract_tool_name(messages[i]), + "content": messages[i].get("content", "")} + return None + + +def _make_base(completion_id: str) -> dict: + return {"id": completion_id, "object": "chat.completion.chunk", + "created": int(time.time()), "model": "mock-model"} + + +async def _send_sse(resp: web.StreamResponse, data: dict): + await resp.write(f"data: {json.dumps(data)}\n\n".encode()) + + async def chat_completions(request: web.Request) -> web.StreamResponse: - """Handle POST /v1/chat/completions.""" + """Handle POST /v1/chat/completions and /chat/completions.""" body = await request.json() messages = body.get("messages", []) stream = body.get("stream", False) - response_text = match_response(messages) - completion_id = f"mock-{uuid.uuid4().hex[:8]}" + has_tools = bool(body.get("tools")) + cid = f"mock-{uuid.uuid4().hex[:8]}" + # Tool result in messages -> text summary + tr = _find_tool_result(messages) + if tr: + text = f"The {tr['name']} tool returned: {tr['content']}" + if not stream: + return _text_response(cid, text) + return await _stream_text(request, cid, text) + + # Tool-call pattern match + tc = match_tool_call(messages, has_tools) + if tc: + if not stream: + return _tool_call_response(cid, tc) + return await _stream_tool_call(request, cid, tc) + + # Default text response + text = match_response(messages) if not stream: - return web.json_response({ - "id": completion_id, - "object": "chat.completion", - "created": int(time.time()), - "model": "mock-model", - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": response_text}, - "finish_reason": "stop", - }], - "usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15}, - }) + return _text_response(cid, text) + return await _stream_text(request, cid, text) - # Streaming response: split into word-boundary chunks - resp = web.StreamResponse( - status=200, - headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, - ) - await resp.prepare(request) - # First chunk: role - chunk = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": int(time.time()), +def _text_response(cid: str, text: str) -> web.Response: + return web.json_response({ + "id": cid, "object": "chat.completion", "created": int(time.time()), "model": "mock-model", - "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], - } - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15}, + }) - # Content chunks: split on spaces - words = response_text.split(" ") - for i, word in enumerate(words): - text = word if i == 0 else f" {word}" - chunk["choices"][0]["delta"] = {"content": text} - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) - # Final chunk: finish_reason +def _tool_call_response(cid: str, tc: dict) -> web.Response: + return web.json_response({ + "id": cid, "object": "chat.completion", "created": int(time.time()), + "model": "mock-model", + "choices": [{"index": 0, "message": { + "role": "assistant", "content": None, + "tool_calls": [{"id": f"call_{uuid.uuid4().hex[:8]}", "type": "function", + "function": {"name": tc["tool_name"], + "arguments": json.dumps(tc["arguments"])}}], + }, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }) + + +async def _stream_text(request: web.Request, cid: str, text: str) -> web.StreamResponse: + resp = web.StreamResponse(status=200, headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache"}) + await resp.prepare(request) + base = _make_base(cid) + chunk = {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, + "finish_reason": None}]} + await _send_sse(resp, chunk) + for i, word in enumerate(text.split(" ")): + chunk["choices"][0]["delta"] = {"content": word if i == 0 else f" {word}"} + await _send_sse(resp, chunk) chunk["choices"][0]["delta"] = {} chunk["choices"][0]["finish_reason"] = "stop" - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + await _send_sse(resp, chunk) await resp.write(b"data: [DONE]\n\n") - return resp +async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.StreamResponse: + resp = web.StreamResponse(status=200, headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache"}) + await resp.prepare(request) + call_id = f"call_{uuid.uuid4().hex[:8]}" + base = _make_base(cid) + # First chunk: role + tool call header with empty arguments + chunk = {**base, "choices": [{"index": 0, "delta": { + "role": "assistant", + "tool_calls": [{"index": 0, "id": call_id, "type": "function", + "function": {"name": tc["tool_name"], "arguments": ""}}], + }, "finish_reason": None}]} + await _send_sse(resp, chunk) + # Second chunk: arguments payload + chunk["choices"][0]["delta"] = { + "tool_calls": [{"index": 0, "function": {"arguments": json.dumps(tc["arguments"])}}]} + await _send_sse(resp, chunk) + # Final chunk: finish reason + chunk["choices"][0]["delta"] = {} + chunk["choices"][0]["finish_reason"] = "tool_calls" + await _send_sse(resp, chunk) + await resp.write(b"data: [DONE]\n\n") + return resp + + +async def oauth_exchange(request: web.Request) -> web.Response: + """Mock OAuth token exchange proxy for E2E tests. + + Accepts form params (code, redirect_uri, code_verifier) and returns + a fake token response. Called by ironclaw's exchange_via_proxy() when + IRONCLAW_OAUTH_EXCHANGE_URL is set. + """ + data = await request.post() + code = data.get("code", "") + return web.json_response({ + "access_token": f"mock-token-{code}", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + }) + + async def models(_request: web.Request) -> web.Response: - """Handle GET /v1/models.""" return web.json_response({ "object": "list", "data": [{"id": "mock-model", "object": "model", "owned_by": "test"}], @@ -102,23 +229,21 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=0) args = parser.parse_args() - app = web.Application() + # Register both /v1/ and non-/v1/ paths (rig-core omits the /v1/ prefix) app.router.add_post("/v1/chat/completions", chat_completions) + app.router.add_post("/chat/completions", chat_completions) app.router.add_get("/v1/models", models) - - # Use aiohttp's runner to get the actual bound port - import asyncio + app.router.add_get("/models", models) + app.router.add_post("/oauth/exchange", oauth_exchange) async def start(): runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", args.port) await site.start() - # Extract the actual port from the bound socket port = site._server.sockets[0].getsockname()[1] print(f"MOCK_LLM_PORT={port}", flush=True) - # Block forever await asyncio.Event().wait() asyncio.run(start()) diff --git a/tests/e2e/scenarios/test_extension_oauth.py b/tests/e2e/scenarios/test_extension_oauth.py new file mode 100644 index 00000000..b20d4275 --- /dev/null +++ b/tests/e2e/scenarios/test_extension_oauth.py @@ -0,0 +1,264 @@ +"""Extension OAuth round-trip e2e tests. + +Tests the full internal OAuth callback pipeline: install gmail → configure +(get auth_url) → simulate OAuth callback → verify token stored. Uses gateway +callback mode + mock token exchange (no real Google login). + +The conftest sets IRONCLAW_OAUTH_CALLBACK_URL (non-loopback, forces gateway +mode) and IRONCLAW_OAUTH_EXCHANGE_URL (points to mock_llm.py's /oauth/exchange). +""" + +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from helpers import api_get, api_post + +# Module-level state +_gmail_installed = False +_auth_url = None +_csrf_state = None + + +def _extract_state(auth_url: str) -> str: + """Extract the CSRF state parameter from an OAuth authorization URL.""" + parsed = urlparse(auth_url) + qs = parse_qs(parsed.query) + assert "state" in qs, f"auth_url should contain state param: {auth_url}" + state = qs["state"][0] + assert len(state) > 0 + return state + + +async def _get_extension(base_url, name): + """Get a specific extension from the extensions list, or None.""" + r = await api_get(base_url, "/api/extensions") + for ext in r.json().get("extensions", []): + if ext["name"] == name: + return ext + return None + + +async def _ensure_removed(base_url, name): + """Remove extension if already installed.""" + ext = await _get_extension(base_url, name) + if ext: + await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30) + + +# ── Section A: Install + OAuth Initiation ──────────────────────────────── + + +async def test_oauth_install_gmail(ironclaw_server): + """Install gmail from registry for OAuth testing.""" + global _gmail_installed + await _ensure_removed(ironclaw_server, "gmail") + + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": "gmail"}, + timeout=180, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Install failed: {data.get('message', '')}" + _gmail_installed = True + + +async def test_oauth_configure_returns_auth_url(ironclaw_server): + """Configure with empty secrets returns an OAuth auth_url.""" + global _auth_url, _csrf_state + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Configure failed: {data.get('message', '')}" + + _auth_url = data.get("auth_url") + assert _auth_url is not None, f"Expected auth_url in response: {data}" + assert "accounts.google.com" in _auth_url, ( + f"auth_url should point to Google: {_auth_url}" + ) + + _csrf_state = _extract_state(_auth_url) + + +async def test_oauth_activate_returns_auth_url(ironclaw_server): + """Activate on un-authenticated gmail returns auth_url.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, "/api/extensions/gmail/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + # Activation may fail with auth_url or succeed with auth_url + auth_url = data.get("auth_url") + assert auth_url is not None, f"Expected auth_url in activate response: {data}" + + +# ── Section B: Internal OAuth Round-Trip ───────────────────────────────── + + +async def test_oauth_callback_exchanges_token(ironclaw_server): + """Simulate OAuth callback with mock code — verifies token exchange.""" + global _csrf_state + if not _csrf_state: + pytest.skip("No CSRF state from configure step") + + # Re-configure to get a fresh pending flow (previous configure may have + # been consumed by the activate test above) + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + data = r.json() + auth_url = data.get("auth_url") + if auth_url: + _csrf_state = _extract_state(auth_url) + + # Hit the OAuth callback endpoint directly (public route, no auth header). + # The callback handler looks up the pending flow by state, calls + # exchange_via_proxy() which hits mock_llm.py's /oauth/exchange, and + # stores the returned fake token. + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": _csrf_state}, + timeout=30, + follow_redirects=True, + ) + + assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}" + body = r.text.lower() + # The landing page says " Connected" on success, "failed" on error + assert "connected" in body or "success" in body, ( + f"Callback HTML should indicate success: {r.text[:500]}" + ) + + +async def test_oauth_callback_replay_rejected(ironclaw_server): + """Replaying the same callback is rejected (flow consumed on first use).""" + if not _csrf_state: + pytest.skip("No CSRF state") + + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": _csrf_state}, + timeout=10, + follow_redirects=True, + ) + + # Should fail — the flow was already consumed + body = r.text.lower() + assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, ( + f"Replay should be rejected, got status={r.status_code}: {r.text[:500]}" + ) + + +async def test_oauth_callback_invalid_state(ironclaw_server): + """Callback with bogus state is rejected.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "x", "state": "totally-bogus-state-value"}, + timeout=10, + follow_redirects=True, + ) + + body = r.text.lower() + assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, ( + f"Invalid state should be rejected, got status={r.status_code}: {r.text[:500]}" + ) + + +async def test_oauth_extension_authenticated(ironclaw_server): + """After OAuth callback, gmail shows authenticated=True.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None, "gmail not in extensions list" + assert ext["authenticated"] is True, ( + f"gmail should be authenticated after OAuth callback: {ext}" + ) + + +async def test_oauth_tools_registered(ironclaw_server): + """After OAuth authentication, gmail tools appear in tools endpoint.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None + # Check the extension's tools array + tools = ext.get("tools", []) + assert len(tools) > 0, ( + f"gmail should have tools registered after auth: {ext}" + ) + + +async def test_remove_during_pending_oauth_invalidates_callback(ironclaw_server): + """Removing an extension while OAuth is pending invalidates the callback state.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + auth_url = data.get("auth_url") + assert auth_url is not None, f"Expected auth_url in response: {data}" + callback_state = _extract_state(auth_url) + + remove_r = await api_post( + ironclaw_server, "/api/extensions/gmail/remove", timeout=30 + ) + assert remove_r.status_code == 200 + assert remove_r.json().get("success") is True, ( + f"Removing gmail during pending OAuth should succeed: {remove_r.text[:300]}" + ) + + async with httpx.AsyncClient() as client: + callback_r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": callback_state}, + timeout=30, + follow_redirects=True, + ) + + assert callback_r.status_code == 200 + body = callback_r.text.lower() + assert "error" in body or "fail" in body or "expired" in body, ( + f"Callback after removal should fail: {callback_r.text[:500]}" + ) + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is None, "gmail should remain removed after invalidated callback" + + +# ── Section C: Cleanup ────────────────────────────────────────────────── + + +async def test_cleanup_gmail(ironclaw_server): + """Remove gmail (cleanup for other test files).""" + await _ensure_removed(ironclaw_server, "gmail") + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is None, "gmail should be removed" diff --git a/tests/e2e/scenarios/test_extensions.py b/tests/e2e/scenarios/test_extensions.py index 6cddacb4..f172d420 100644 --- a/tests/e2e/scenarios/test_extensions.py +++ b/tests/e2e/scenarios/test_extensions.py @@ -458,6 +458,37 @@ async def test_install_wasm_channel_triggers_configure(page): assert await modal.is_visible() +async def test_install_with_auth_url_opens_popup_and_shows_auth_prompt(page): + """Install responses with auth_url should surface the same auth prompt used elsewhere.""" + await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + + async def handle_install(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}), + ) + + await page.route("**/api/extensions/install", handle_install) + await go_to_extensions(page) + + install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + await install_btn.wait_for(state="visible", timeout=5000) + await install_btn.click() + + await page.wait_for_function( + "() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined", + timeout=5000, + ) + opened = await page.evaluate("window._lastOpenedUrl") + assert opened is not None, "window.open was not called" + assert "example.com" in opened + await page.locator(SEL["auth_card"] + '[data-extension-name="registry-tool"]').wait_for( + state="visible", timeout=5000 + ) + + # ─── Group F: Remove flow ───────────────────────────────────────────────────── async def test_remove_installed_extension_confirmed(page): @@ -612,7 +643,7 @@ async def test_configure_modal_save_success(page): async def test_configure_modal_save_oauth(page): - """Save response with auth_url opens a popup via window.open.""" + """Save response with auth_url opens a popup and shows the global auth prompt.""" await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") async def handle_setup(route): @@ -639,6 +670,9 @@ async def test_configure_modal_save_oauth(page): opened = await page.evaluate("window._lastOpenedUrl") assert opened is not None, "window.open was not called" assert "oauth" in opened or "example.com" in opened + await page.locator(SEL["auth_card"] + '[data-extension-name="test-ext"]').wait_for( + state="visible", timeout=5000 + ) async def test_configure_modal_save_failure(page): @@ -699,7 +733,7 @@ async def test_configure_modal_enter_key_submits(page): # ─── Group H: Auth card (SSE-triggered) ─────────────────────────────────────── async def _show_auth_card(page, **kwargs): - """Inject an auth card via JS and wait for it to appear.""" + """Inject the global auth prompt via JS and wait for it to appear.""" payload = json.dumps(kwargs) await page.evaluate(f"showAuthCard({payload})") await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000) @@ -812,12 +846,43 @@ async def test_auth_card_replaces_existing_same_extension(page): assert "Second" in await page.locator(SEL["auth_instructions"]).text_content() -async def test_auth_card_multiple_extensions_coexist(page): - """Auth cards for different extensions can coexist.""" +async def test_auth_card_for_different_extension_replaces_existing_prompt(page): + """A new auth prompt replaces the previous one to keep the UX modal and global.""" await page.evaluate('showAuthCard({extension_name: "ext-a", instructions: "Token A"})') await page.evaluate('showAuthCard({extension_name: "ext-b", instructions: "Token B"})') - await page.locator(SEL["auth_card"]).nth(1).wait_for(state="visible", timeout=3000) - assert await page.locator(SEL["auth_card"]).count() == 2 + await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=3000) + assert await page.locator(SEL["auth_card"]).count() == 1 + assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-a"]').count() == 0 + assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-b"]').count() == 1 + + +async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_names(page): + """Quoted extension names should not break auth/configure modal helpers.""" + result = await page.evaluate( + """({ name }) => { + showAuthCard({ extension_name: name, instructions: 'Paste token' }); + showAuthCardError(name, 'Bad token'); + const errorText = document.querySelector('.auth-error')?.textContent || ''; + removeAuthCard(name); + const authStillPresent = Array.from(document.querySelectorAll('.auth-card')) + .some((card) => card.getAttribute('data-extension-name') === name); + + const overlay = document.createElement('div'); + overlay.className = 'configure-overlay'; + overlay.setAttribute('data-extension-name', name); + document.body.appendChild(overlay); + closeConfigureModal(name); + const configureStillPresent = Array.from(document.querySelectorAll('.configure-overlay')) + .some((node) => node.getAttribute('data-extension-name') === name); + + return { errorText, authStillPresent, configureStillPresent }; + }""", + {"name": 'quoted "ext" name'}, + ) + + assert result["errorText"] == "Bad token" + assert result["authStillPresent"] is False + assert result["configureStillPresent"] is False async def test_auth_completed_sse_dismisses_card(page): @@ -826,13 +891,95 @@ async def test_auth_completed_sse_dismisses_card(page): # Simulate the auth_completed SSE event being fired await page.evaluate(""" - // Call the handler the same way the SSE listener does - removeAuthCard('myext'); + handleAuthCompleted({ + extension_name: 'myext', + success: true, + message: 'Authenticated!', + }); """) assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0 +async def test_auth_completed_for_other_extension_keeps_configure_modal_open(page): + """Auth completion should not close a different extension's configure modal.""" + async def handle_setup(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + + await page.evaluate(""" + handleAuthCompleted({ + extension_name: 'other-ext', + success: true, + message: 'Other extension connected.', + }); + """) + + assert await page.locator(SEL["configure_overlay"]).is_visible(), ( + "Configure modal should remain open when another extension finishes auth" + ) + + +async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensions(page): + """Failed auth_completed handling should clear stale UI and refresh extensions.""" + reload_count = [] + + async def counting_handler(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + reload_count.append(1) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": []}), + ) + else: + await route.continue_() + + async def handle_tools(route): + await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') + + async def handle_registry(route): + await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + + await page.route("**/api/extensions*", counting_handler) + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + + await go_to_extensions(page) + count_before = len(reload_count) + + await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth") + assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1 + + await page.evaluate(""" + handleAuthCompleted({ + extension_name: 'gmail', + success: false, + message: 'OAuth flow expired. Please try again.', + }); + """) + + await wait_for_toast(page, "OAuth flow expired. Please try again.") + assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0 + assert ( + await page.locator( + SEL["toast_error"], has_text="OAuth flow expired. Please try again." + ).count() + >= 1 + ) + + await page.wait_for_timeout(600) + assert len(reload_count) > count_before, "Extensions list did not reload after auth failure" + + # ─── Group I: Activate flow ──────────────────────────────────────────────────── async def test_activate_mcp_server_success(page): @@ -902,8 +1049,8 @@ async def test_activate_failure_shows_error_toast(page): await wait_for_toast(page, "Config missing") -async def test_activate_with_auth_url_opens_popup(page): - """Activate response with auth_url calls window.open.""" +async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page): + """Activate response with auth_url calls window.open and shows the auth prompt.""" await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") await mock_ext_apis(page, installed=[_MCP_INACTIVE]) @@ -921,6 +1068,9 @@ async def test_activate_with_auth_url_opens_popup(page): opened = await page.evaluate("window._lastOpenedUrl") assert opened is not None, "window.open was not called" assert "example.com" in opened + await page.locator( + SEL["auth_card"] + '[data-extension-name="test-mcp-inactive"]' + ).wait_for(state="visible", timeout=5000) # ─── Group J: Tab reload behaviour ──────────────────────────────────────────── @@ -947,9 +1097,9 @@ async def test_extensions_tab_reloads_on_revisit(page): async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) - await page.route("**/api/extensions*", counting_handler) # First visit await go_to_extensions(page) @@ -990,19 +1140,20 @@ async def test_auth_completed_sse_triggers_extensions_reload(page): async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) - await page.route("**/api/extensions*", counting_handler) await go_to_extensions(page) count_before = len(reload_count) - # Simulate auth_completed by calling loadExtensions directly (as the SSE handler does) + # Simulate auth_completed via the shared handler. await page.evaluate(""" - // Simulate what the auth_completed SSE handler does when currentTab === 'extensions' - if (typeof loadExtensions === 'function') { - loadExtensions(); - } + handleAuthCompleted({ + extension_name: 'reload-ext', + success: true, + message: 'Reloaded.', + }); """) await page.wait_for_timeout(600) diff --git a/tests/e2e/scenarios/test_pairing.py b/tests/e2e/scenarios/test_pairing.py new file mode 100644 index 00000000..e3ff9144 --- /dev/null +++ b/tests/e2e/scenarios/test_pairing.py @@ -0,0 +1,79 @@ +"""DM pairing flow e2e tests. + +Tests the pairing security gate for WASM channels: listing pending requests, +approving codes, and error handling. +""" + +import httpx +from helpers import AUTH_TOKEN + + +def _headers(): + return {"Authorization": f"Bearer {AUTH_TOKEN}"} + + +async def test_pairing_list_returns_empty_for_unknown_channel(ironclaw_server): + """GET /api/pairing/{channel} returns empty list or 404 for non-existent channel.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/api/pairing/nonexistent-channel", + headers=_headers(), + timeout=10, + ) + # Either empty list or error is acceptable + if r.status_code == 200: + data = r.json() + assert isinstance(data, (dict, list)) + if isinstance(data, dict): + assert "requests" in data + assert isinstance(data["requests"], list) + assert data["requests"] == [] + else: + assert data == [] + else: + # 404 or similar is fine for non-existent channel + assert r.status_code in (404, 400) + + +async def test_approve_invalid_code_rejected(ironclaw_server): + """POST /api/pairing/{channel}/approve with bad code returns error.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": "INVALID0"}, + headers=_headers(), + timeout=10, + ) + # Should fail — no pending request with this code + if r.status_code == 200: + data = r.json() + assert data.get("success") is False or data.get("ok") is False or "error" in str(data).lower() + else: + assert r.status_code >= 400 + + +async def test_approve_empty_code_rejected(ironclaw_server): + """POST /api/pairing/{channel}/approve with empty code returns error.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": ""}, + headers=_headers(), + timeout=10, + ) + if r.status_code == 200: + data = r.json() + assert data.get("success") is False or data.get("ok") is False + else: + assert r.status_code >= 400 + + +async def test_pairing_approve_requires_auth(ironclaw_server): + """POST /api/pairing/{channel}/approve without auth token is rejected.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": "ABCD1234"}, + timeout=10, + ) + assert r.status_code == 401 or r.status_code == 403 diff --git a/tests/e2e/scenarios/test_tool_execution.py b/tests/e2e/scenarios/test_tool_execution.py new file mode 100644 index 00000000..89627ac3 --- /dev/null +++ b/tests/e2e/scenarios/test_tool_execution.py @@ -0,0 +1,94 @@ +"""Tool execution e2e tests. + +Tests the agent loop: user message -> mock LLM returns tool_calls -> tool +executes -> result displayed in chat. Requires the enhanced mock_llm.py +with TOOL_CALL_PATTERNS support. +""" + +from helpers import SEL + + +async def _send_and_get_response( + page, + message: str, + *, + expected_fragment: str, + timeout: int = 30000, +) -> str: + """Send a message and return the text of the newest assistant response. + + Counts existing assistant messages before sending, then waits for a new + one to appear and contain the expected final text fragment. This avoids + reading partial streamed content before the assistant response is complete. + """ + chat_input = page.locator(SEL["chat_input"]) + await chat_input.wait_for(state="visible", timeout=5000) + + # Count existing assistant messages before sending + assistant_sel = SEL["message_assistant"] + before_count = await page.locator(assistant_sel).count() + + await chat_input.fill(message) + await chat_input.press("Enter") + + # Wait for the final assistant message to exist and include the expected + # text fragment rather than returning on the first streamed chunk. + expected = before_count + 1 + await page.wait_for_function( + """({ assistantSelector, expectedCount, expectedFragment }) => { + const messages = document.querySelectorAll(assistantSelector); + if (messages.length < expectedCount) return false; + const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase(); + return text.includes(expectedFragment.toLowerCase()); + }""", + arg={ + "assistantSelector": assistant_sel, + "expectedCount": expected, + "expectedFragment": expected_fragment, + }, + timeout=timeout, + ) + + return await page.locator(assistant_sel).last.inner_text() + + +async def test_builtin_echo_tool(page): + """Send a message that triggers the echo tool via mock LLM function calling.""" + text = await _send_and_get_response( + page, + "echo hello world", + expected_fragment="hello world", + ) + + # The mock LLM returns "The echo tool returned: " + assert "echo" in text.lower() or "hello world" in text.lower(), ( + f"Expected echo result in response, got: {text}" + ) + + +async def test_builtin_time_tool(page): + """Send a message that triggers the time tool via mock LLM function calling.""" + text = await _send_and_get_response( + page, + "what time is it", + expected_fragment="time", + ) + + # The mock LLM returns "The time tool returned: " + assert "time" in text.lower(), ( + f"Expected time result in response, got: {text}" + ) + + +async def test_non_tool_message_still_works(page): + """Messages that don't match tool patterns still get text responses.""" + text = await _send_and_get_response( + page, + "What is 2+2?", + expected_fragment="4", + timeout=15000, + ) + + assert "4" in text, ( + f"Expected '4' in response, got: {text}" + ) diff --git a/tests/e2e/scenarios/test_wasm_lifecycle.py b/tests/e2e/scenarios/test_wasm_lifecycle.py new file mode 100644 index 00000000..961e7ad0 --- /dev/null +++ b/tests/e2e/scenarios/test_wasm_lifecycle.py @@ -0,0 +1,517 @@ +"""Comprehensive WASM extension lifecycle e2e tests. + +Tests the full extension pipeline: registry → install → fields → configure → +activate → tools → remove → reinstall. Validates response fields, not just +status codes, to catch production bugs like missing capabilities, wrong +activation state, and stale registry flags. + +Lifecycle stages are expressed as scoped fixtures so each test requests the +state it needs explicitly rather than relying on module-global flags. +""" + +from pathlib import Path + +import pytest + +from helpers import SEL, api_get, api_post + +async def _get_extension(base_url, name): + """Get a specific extension from the extensions list, or None.""" + r = await api_get(base_url, "/api/extensions") + for ext in r.json().get("extensions", []): + if ext["name"] == name: + return ext + return None + + +async def _ensure_removed(base_url, name): + """Remove extension if already installed (idempotent cleanup).""" + ext = await _get_extension(base_url, name) + if ext: + await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30) + + +async def _install_extension(base_url, name): + """Install an extension and assert success.""" + r = await api_post( + base_url, + "/api/extensions/install", + json={"name": name}, + timeout=180, + ) + assert r.status_code == 200, f"Install HTTP error: {r.status_code} {r.text[:300]}" + data = r.json() + assert data.get("success") is True, f"Install failed: {data.get('message', '')}" + return data + + +@pytest.fixture(scope="module", autouse=True) +async def extension_lifecycle_cleanup(ironclaw_server): + """Start and end the module with a clean extension set.""" + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + yield + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + + +@pytest.fixture(scope="module") +async def web_search_installed(ironclaw_server, extension_lifecycle_cleanup): + """Install web-search once for tests that require the pre-configure state.""" + data = await _install_extension(ironclaw_server, "web-search") + return {"name": "web-search", "install": data} + + +@pytest.fixture(scope="module") +async def web_search_configured(ironclaw_server, web_search_installed): + """Configure web-search once for tests that require the active state.""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"brave_api_key": "test-key-123"}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Configure failed: {data.get('message', '')}" + assert data.get("activated") is True, "Should auto-activate after configure" + return {"name": "web-search", "configure": data} + + +@pytest.fixture(scope="module") +async def gmail_installed(ironclaw_server, extension_lifecycle_cleanup): + """Install gmail once for multi-extension and OAuth setup assertions.""" + data = await _install_extension(ironclaw_server, "gmail") + return {"name": "gmail", "install": data} + + +@pytest.fixture(scope="module") +async def web_search_removed(ironclaw_server, web_search_configured): + """Remove web-search once for post-uninstall assertions.""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/remove", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Remove failed: {data.get('message', '')}" + return {"name": "web-search", "remove": data} + + +@pytest.fixture(scope="module") +async def web_search_reinstalled(ironclaw_server, web_search_removed): + """Reinstall web-search after removal to verify saved-secret recovery.""" + await _ensure_removed(ironclaw_server, "web-search") + data = await _install_extension(ironclaw_server, "web-search") + return {"name": "web-search", "install": data} + + +# ── Section A: Registry Validation ────────────────────────────────────── + + +async def test_registry_lists_extensions(ironclaw_server): + """Registry endpoint returns entries from the embedded catalog.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + assert r.status_code == 200 + data = r.json() + assert "entries" in data + names = [e["name"] for e in data["entries"]] + assert "web-search" in names + assert "gmail" in names + + +async def test_registry_entry_fields(ironclaw_server): + """Every registry entry has all required fields with correct types.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + assert len(entries) > 0, "Registry should have entries" + for entry in entries: + assert "name" in entry and isinstance(entry["name"], str) and entry["name"] + assert "display_name" in entry and isinstance(entry["display_name"], str) + assert "kind" in entry and isinstance(entry["kind"], str) + assert "description" in entry and isinstance(entry["description"], str) + assert "installed" in entry and isinstance(entry["installed"], bool) + assert "keywords" in entry and isinstance(entry["keywords"], list) + + +async def test_registry_installed_flag_false_initially(ironclaw_server): + """Before any install, all registry entries have installed=False.""" + # Clean up in case previous test run left extensions installed + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + for entry in entries: + if entry["name"] in ("web-search", "gmail"): + assert entry["installed"] is False, ( + f"{entry['name']} should not be installed yet" + ) + + +async def test_registry_search_filters(ironclaw_server): + """Search query filters registry results.""" + r = await api_get( + ironclaw_server, "/api/extensions/registry", params={"query": "search"} + ) + assert r.status_code == 200 + entries = r.json()["entries"] + names = [e["name"] for e in entries] + assert "web-search" in names + + +async def test_registry_search_no_match(ironclaw_server): + """Nonsense query returns empty results.""" + r = await api_get( + ironclaw_server, + "/api/extensions/registry", + params={"query": "xyznonexistent999"}, + ) + assert r.status_code == 200 + assert len(r.json()["entries"]) == 0 + + +# ── Section B: Install Lifecycle (web-search) ─────────────────────────── + + +async def test_install_web_search(web_search_installed): + """Install web-search from registry. Asserts success — failure here means + the registry/download/build pipeline is broken.""" + assert "message" in web_search_installed["install"] + + +async def test_installed_extension_fields(ironclaw_server, web_search_installed): + """After install, extension list shows correct fields.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None, "web-search not in extensions list after install" + assert ext["kind"] == "wasm_tool" + assert ext["needs_setup"] is True, "Should need setup (has brave_api_key secret)" + assert ext["authenticated"] is False, "Should not be authenticated before configure" + + +async def test_installed_in_registry(ironclaw_server, web_search_installed): + """Registry marks installed extension with installed=True.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + ws_entry = next((e for e in entries if e["name"] == "web-search"), None) + assert ws_entry is not None + assert ws_entry["installed"] is True, "Registry should show installed=True" + + +async def test_setup_schema_has_secrets(ironclaw_server, web_search_installed): + """Setup schema returns brave_api_key with correct field info.""" + r = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + assert r.status_code == 200 + data = r.json() + assert "secrets" in data + secrets = {s["name"]: s for s in data["secrets"]} + assert "brave_api_key" in secrets, ( + f"brave_api_key not in setup schema secrets: {list(secrets.keys())}" + ) + key_info = secrets["brave_api_key"] + assert key_info["provided"] is False, "Should not be provided yet" + + +async def test_extension_not_authenticated_before_configure( + ironclaw_server, web_search_installed +): + """Installed but not configured extension is not authenticated.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None + # Before configuring secrets, extension shouldn't be fully authenticated + assert ext["needs_setup"] is True, "Should still need setup before configure" + + +async def test_activate_before_configure_rejected(ironclaw_server, web_search_installed): + """Activating a tool that needs setup secrets is rejected.""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, ( + f"Activate should fail before configure: {data}" + ) + msg = data.get("message", "").lower() + assert "requires configuration" in msg or "setup" in msg, ( + f"Error should mention configuration: {data.get('message')}" + ) + + +# ── Section C: Configure + Activate (web-search) ──────────────────────── + + +async def test_configure_rejects_unknown_secret(ironclaw_server, web_search_installed): + """Submitting an unknown secret name is rejected.""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"fake_unknown_key": "value"}}, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, f"Should reject unknown secret: {data}" + assert "unknown" in data.get("message", "").lower() or "not found" in data.get( + "message", "" + ).lower(), f"Error should mention unknown secret: {data.get('message')}" + + +async def test_configure_with_valid_secret(web_search_configured): + """Configure with valid brave_api_key succeeds and auto-activates.""" + assert web_search_configured["configure"].get("activated") is True + + +async def test_extension_active_after_configure(ironclaw_server, web_search_configured): + """After configure, extension shows authenticated=True and active=True.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None + assert ext["authenticated"] is True, "Should be authenticated after configure" + assert ext["active"] is True, "Should be active after auto-activation" + assert len(ext.get("tools", [])) > 0, "Should have tools registered" + + +async def test_setup_shows_provided(ironclaw_server, web_search_configured): + """After configure, setup schema shows secret as provided.""" + r = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + assert r.status_code == 200 + secrets = {s["name"]: s for s in r.json()["secrets"]} + assert "brave_api_key" in secrets + assert secrets["brave_api_key"]["provided"] is True + + +async def test_tools_registered_after_activate( + ironclaw_server, web_search_configured +): + """After activation, extension tools appear in the tools endpoint.""" + r = await api_get(ironclaw_server, "/api/extensions/tools") + assert r.status_code == 200 + tool_names = [t["name"] for t in r.json()["tools"]] + assert "web-search" in tool_names, ( + f"web-search tool not found in tools list: {tool_names}" + ) + + +async def test_activate_already_active_idempotent( + ironclaw_server, web_search_configured +): + """Activating an already-active extension succeeds (idempotent).""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, ( + f"Re-activation should succeed: {data.get('message', '')}" + ) + + +async def test_configure_empty_secret_skipped(ironclaw_server, web_search_configured): + """Submitting an empty string for a secret skips it (doesn't overwrite).""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"brave_api_key": ""}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True + + # Verify the secret is still provided (not cleared) + r2 = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + secrets = {s["name"]: s for s in r2.json()["secrets"]} + assert secrets["brave_api_key"]["provided"] is True, ( + "Empty value should not clear existing secret" + ) + + +# ── Section D: Install gmail (multi-extension) ────────────────────────── + + +async def test_install_gmail(gmail_installed): + """Install gmail from registry (second extension, tests isolation).""" + assert "message" in gmail_installed["install"] + + +async def test_gmail_fields(ironclaw_server, gmail_installed): + """Gmail extension has correct field values (OAuth-based auth).""" + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None, "gmail not in extensions list" + assert ext["kind"] == "wasm_tool" + assert ext["has_auth"] is True, "Gmail should have OAuth auth" + + +async def test_both_extensions_listed( + ironclaw_server, web_search_configured, gmail_installed +): + """Both web-search and gmail appear in extensions list (no clobbering).""" + r = await api_get(ironclaw_server, "/api/extensions") + names = [e["name"] for e in r.json()["extensions"]] + assert "web-search" in names, f"web-search missing from: {names}" + assert "gmail" in names, f"gmail missing from: {names}" + + +async def test_gmail_setup_schema_auto_resolves(ironclaw_server, gmail_installed): + """Gmail setup schema returns empty secrets (builtin creds auto-resolve).""" + r = await api_get(ironclaw_server, "/api/extensions/gmail/setup") + assert r.status_code == 200 + data = r.json() + secrets = data.get("secrets", []) + # Builtin Google credentials auto-resolve client_id/client_secret via + # is_auto_resolved_oauth_field(), so the setup schema should have no + # user-facing secrets (or only auto-generated ones). + user_facing = [s for s in secrets if not s.get("auto_generate", False)] + assert len(user_facing) == 0, ( + f"Gmail should have no user-facing secrets (auto-resolved), got: " + f"{[s['name'] for s in user_facing]}" + ) + + +# ── Section E: Remove + Cleanup ───────────────────────────────────────── + + +async def test_remove_web_search(web_search_removed): + """Remove web-search succeeds.""" + assert web_search_removed["remove"].get("success") is True + + +async def test_removed_not_in_extensions(ironclaw_server, web_search_removed): + """Removed extension no longer appears in extensions list.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is None, "web-search should not be in extensions list after removal" + + +async def test_removed_extension_not_listed(ironclaw_server, web_search_removed): + """Removed extension should not appear in the extension tools list.""" + r = await api_get(ironclaw_server, "/api/extensions/tools") + assert r.status_code == 200 + tool_names = [t["name"] for t in r.json()["tools"]] + assert "web-search" not in tool_names, ( + f"Removed web-search tool should not remain registered: {tool_names}" + ) + + +async def test_removed_not_in_registry_installed(ironclaw_server, web_search_removed): + """Registry shows removed extension as installed=False.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + ws_entry = next( + (e for e in r.json()["entries"] if e["name"] == "web-search"), None + ) + assert ws_entry is not None + assert ws_entry["installed"] is False, "Registry should show installed=False" + + +async def test_activate_after_remove_uses_replacement_bytes_not_cached_module( + ironclaw_server, wasm_tools_dir, web_search_removed +): + """After removal, activation must use the replacement bytes rather than a stale cache.""" + wasm_path = Path(wasm_tools_dir) / "web-search.wasm" + wasm_path.write_bytes(b"not-a-valid-wasm-component") + + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, ( + f"Activation should fail against replacement bytes, got: {data}" + ) + + +async def test_reinstall_after_remove(ironclaw_server, web_search_reinstalled): + """Extension can be reinstalled after removal without stale activation errors.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None, "web-search not found after reinstall" + assert ext["active"] is True, "Reinstalled tool should auto-activate via saved secrets" + assert ext["authenticated"] is True, "Saved secret should still authenticate on reinstall" + # Verify no stale activation error from previous install + assert ext.get("activation_error") is None or ext.get("activation_error") == "", ( + f"Reinstalled extension should have no stale activation error: {ext}" + ) + + +# ── Section F: Error Paths ────────────────────────────────────────────── + + +async def test_install_nonexistent(ironclaw_server): + """Installing a nonexistent extension returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": "nonexistent-tool-xyz-999"}, + timeout=30, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_install_empty_name(ironclaw_server): + """Installing with empty name returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": ""}, + timeout=10, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_remove_noninstalled(ironclaw_server): + """Removing a non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, "/api/extensions/nonexistent-xyz/remove", timeout=10 + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_activate_noninstalled(ironclaw_server): + """Activating a non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, "/api/extensions/nonexistent-xyz/activate", timeout=10 + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_setup_noninstalled(ironclaw_server): + """Setup for non-installed extension returns an error.""" + r = await api_get(ironclaw_server, "/api/extensions/nonexistent-xyz/setup") + # May return 500 or a JSON error + assert r.status_code >= 400 or r.json().get("success") is False + + +async def test_configure_noninstalled(ironclaw_server): + """Configure for non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/nonexistent-xyz/setup", + json={"secrets": {}}, + timeout=10, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +# ── Section G: Browser UI ────────────────────────────────────────────── + + +async def test_extensions_tab_shows_registry(page): + """Extensions tab loads and shows available extensions from registry.""" + tab_btn = page.locator(SEL["tab_button"].format(tab="extensions")) + await tab_btn.click() + panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + await panel.wait_for(state="visible", timeout=5000) + + available_section = page.locator(SEL["available_wasm_list"]) + await available_section.wait_for(state="visible", timeout=10000)