fix(extensions): fix lifecycle bugs + comprehensive E2E tests (#1070)

* 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) <[email protected]>

* 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) <[email protected]>

* 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) <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* fix(extensions): fix 5 extension lifecycle bugs found during E2E testing

Bug fixes in src/extensions/manager.rs:
- Add auth guard to activate_wasm_tool() blocking activation when secrets
  are missing (NeedsSetup), matching activate_wasm_channel() behavior
- Evict WasmToolRuntime module cache on remove() so reinstall uses fresh binary
- Clear activation_errors on remove() for both WasmTool and WasmChannel
- Clean up in-progress OAuth flows on remove() (abort TCP listener, purge
  pending flow entries)

Bug fix in src/channels/web/server.rs:
- Broadcast AuthCompleted SSE event on expired OAuth callback so web UI
  doesn't stay stuck showing "auth required"

E2E test coverage:
- test_wasm_lifecycle.py: 35 tests covering install/configure/activate/
  remove/reinstall lifecycle with regression tests for bugs 1 and 3
- test_extension_oauth.py: 9 tests covering OAuth round-trip flow
- test_tool_execution.py: 5 tests for tool invocation via chat
- test_pairing.py: 4 tests for pairing request lifecycle
- Enhanced conftest.py, helpers.py, mock_llm.py for OAuth mock support

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(web): unify extension auth UX and add lifecycle regressions

* test: fix pending oauth flow fixtures after rebase

* test(e2e): fix playwright route ordering for extensions reloads

* test: address e2e review follow-ups

* test: address remaining PR review comments

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-03-12 16:36:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8a60fa2d37
commit 9fbdd42988
13 changed files with 1848 additions and 190 deletions
+1 -1
View File
@@ -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
+120 -72
View File
@@ -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> {
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<dyn crate::secrets::SecretsStore + Send + Sync> =
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<dyn crate::secrets::SecretsStore + Send + Sync> =
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::<axum::http::Request<Body>>::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<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Arc<ExtensionManager> {
) -> (Arc<ExtensionManager>, 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);
+104 -36
View File
@@ -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();
}
+25 -1
View File
@@ -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);
+189 -6
View File
@@ -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<Arc<crate::tools::wasm::WasmToolRuntime>>,
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<dyn crate::secrets::SecretsStore + Send + Sync> =
@@ -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<Arc<crate::tools::wasm::WasmToolRuntime>>,
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
+77 -1
View File
@@ -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).
+29
View File
@@ -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,
)
+181 -56
View File
@@ -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: <script>alert("xss")</script> and <img src=x onerror="alert(1)"> and <iframe src="javascript:alert(2)"></iframe> end of content.'),
'Here is some content: <script>alert("xss")</script> and <img src=x onerror="alert(1)">'
' and <iframe src="javascript:alert(2)"></iframe> 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 <tool_output name="...">
content = msg.get("content", "")
m = re.search(r'<tool_output\s+name="([^"]+)"', content)
if m:
return m.group(1)
return "unknown"
def _find_tool_result(messages: list[dict]) -> 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())
+264
View File
@@ -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 "<name> 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"
+168 -17
View File
@@ -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)
+79
View File
@@ -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
@@ -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: <result>"
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: <json with iso/unix>"
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}"
)
+517
View File
@@ -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)