* 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]>
IronClaw E2E Tests
Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.
Prerequisites
- Python 3.11+
- Rust toolchain (for building ironclaw)
- Chromium (installed via Playwright)
Setup
cd tests/e2e
pip install -e .
playwright install chromium
Build ironclaw
The tests need the ironclaw binary built with libsql support:
cargo build --no-default-features --features libsql
Run tests
# From repo root
pytest tests/e2e/ -v
# Run a single scenario
pytest tests/e2e/scenarios/test_chat.py -v
# With visible browser (not headless)
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v
Architecture
Tests start two subprocesses:
- Mock LLM (
mock_llm.py) -- fake OpenAI-compat server with canned responses - IronClaw -- the real binary with gateway enabled, pointing to the mock LLM
Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.
Scenarios
| File | What it tests |
|---|---|
test_connection.py |
Auth, tab navigation, connection status |
test_chat.py |
Send message, SSE streaming, response rendering |
test_skills.py |
ClawHub search, skill install/remove |
test_tool_approval.py |
Tool approval overlay (approve, deny, always, params toggle) |
test_sse_reconnect.py |
SSE reconnection handling |
test_html_injection.py |
HTML injection security |
test_extensions.py |
Extensions tab: install, remove, configure, OAuth, auth card, activate |
Adding new scenarios
- Create
tests/e2e/scenarios/test_<name>.py - Use the
pagefixture for a fresh browser page - Use selectors from
helpers.py(updateSELdict if new elements are needed) - Keep tests deterministic -- use the mock LLM, not real providers
Mocking API responses with page.route()
For tabs that depend on external data (extensions, jobs, memory, routines), use
Playwright's page.route() to intercept the browser's HTTP requests to the
ironclaw gateway and return deterministic fixture JSON. This avoids needing
real installed binaries, live external services, or complex database setup.
Basic pattern
import json
async def test_something(page):
# 1. Set up route intercepts BEFORE navigation triggers the fetch
# Always use async def handlers — route.fulfill() is a coroutine and must be awaited.
async def handle_tools(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"tools": [{"name": "echo", "description": "Echo"}]}),
)
await page.route("**/api/extensions/tools", handle_tools)
# 2. Navigate / interact to trigger the fetch
await page.locator('.tab-bar button[data-tab="extensions"]').click()
# 3. Assert on the rendered DOM
rows = page.locator("#tools-tbody tr")
assert await rows.count() == 1
Matching only the exact path
**/api/extensions matches http://host/api/extensions but NOT sub-paths
like http://host/api/extensions/install. For the bare list endpoint, add
a check inside the handler:
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
await route.fulfill(json={"extensions": []})
else:
await route.continue_() # Let sub-paths through to the real server
await page.route("**/api/extensions*", handle_ext_list)
Mocking method-specific behaviour (GET vs POST)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(json={"secrets": [...]})
else: # POST
await route.fulfill(json={"success": True})
await page.route("**/api/extensions/my-ext/setup", handle_setup)
Counting calls (for reload tests)
calls = []
async def counting_handler(route):
calls.append(1)
await route.fulfill(json={"extensions": []})
await page.route("**/api/extensions", counting_handler)
# ... interact ...
assert len(calls) == 2 # called twice (initial + after some action)
Applying the pattern to other tabs
| Tab | Key API endpoints to mock |
|---|---|
| Jobs | /api/jobs, /api/jobs/{id}, /api/jobs/{id}/events |
| Memory | /api/memory/search, /api/memory/tree, /api/memory/read |
| Routines | /api/routines, /api/routines/{id}/runs |
Injecting state directly via page.evaluate()
For purely client-side UI (components rendered entirely in JS without API calls), call the JavaScript function directly to skip the network layer entirely:
# Show an approval card without needing a real tool execution
await page.evaluate("""
showApproval({
request_id: 'test-001',
thread_id: currentThreadId,
tool_name: 'shell',
description: 'Run something',
})
""")
This is the pattern used in test_tool_approval.py and parts of
test_extensions.py (auth card, configure modal).