mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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]>
254 lines
9.2 KiB
Python
254 lines
9.2 KiB
Python
"""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 = [
|
|
(re.compile(r"hello|hi|hey", re.IGNORECASE), "Hello! How can I help you today?"),
|
|
(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.'),
|
|
]
|
|
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 _last_user_content(messages: list[dict]) -> str:
|
|
for msg in reversed(messages):
|
|
if msg.get("role") == "user":
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
content = " ".join(
|
|
p.get("text", "") for p in content if p.get("type") == "text"
|
|
)
|
|
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 and /chat/completions."""
|
|
body = await request.json()
|
|
messages = body.get("messages", [])
|
|
stream = body.get("stream", False)
|
|
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 _text_response(cid, text)
|
|
return await _stream_text(request, cid, text)
|
|
|
|
|
|
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, "message": {"role": "assistant", "content": text},
|
|
"finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15},
|
|
})
|
|
|
|
|
|
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 _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:
|
|
return web.json_response({
|
|
"object": "list",
|
|
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
|
|
})
|
|
|
|
|
|
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)
|
|
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()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
print(f"MOCK_LLM_PORT={port}", flush=True)
|
|
await asyncio.Event().wait()
|
|
|
|
asyncio.run(start())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|