From 28a22f2a59239df9ea6efd7430a9491b960471c5 Mon Sep 17 00:00:00 2001 From: Gabe Hamilton Date: Wed, 11 Mar 2026 16:55:35 -0600 Subject: [PATCH 01/67] fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS (#510) * fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS The previous sanitizeRenderedHtml() used regex patterns to strip dangerous HTML tags and event handler attributes before assigning to innerHTML. Regex- based HTML sanitization is notoriously bypassable via: - SVG/MathML elements not in the blocklist () - Newline-split event handlers () - Mutation XSS (browser parsing quirks that reconstruct dangerous DOM) - Encoded attribute values and alternative quote styles - Nested/recursive tag patterns that defeat linear regex This is exploitable through prompt injection: if an LLM tool output contains crafted HTML, it flows through marked.parse() -> sanitizeRenderedHtml() -> innerHTML, allowing script execution in the user's browser session. Replace the regex sanitizer with DOMPurify 3.2.3, the industry-standard DOM-based HTML sanitizer. DOMPurify parses HTML into a real DOM tree and walks it node-by-node, which eliminates all known bypass vectors. It is used by Mozilla, Google, and most major web applications. CDN: cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js SRI: sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl Audited all 60+ innerHTML assignments in app.js: - 5 use renderMarkdown() -> now protected by DOMPurify - Remainder use escapeHtml(), static literals, or empty strings Co-Authored-By: Claude Sonnet 4.6 * fix(security): guard sanitizeRenderedHtml against DOMPurify CDN unavailability [skip-regression-check] Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/channels/web/static/app.js | 32 ++++++++++++------------------ src/channels/web/static/index.html | 5 +++++ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 7ca9a25b..de1f83b6 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -676,26 +676,20 @@ function renderMarkdown(text) { return escapeHtml(text); } -// Strip dangerous HTML elements and attributes from rendered markdown. -// This prevents XSS from tool output or prompt injection in LLM responses. +// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output +// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer +// that handles all known bypass vectors (SVG onload, newline-split event +// handlers, mutation XSS, etc.) unlike the regex approach it replaces. function sanitizeRenderedHtml(html) { - html = html.replace(/)<[^<]*)*<\/script>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/iframe>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/object>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/form>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/style>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - // Remove event handler attributes (onclick, onerror, onload, etc.) - html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, ''); - html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, ''); - html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, ''); - // Remove javascript: and data: URLs in href/src attributes - html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="'); - html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="'); - return html; + if (typeof DOMPurify !== 'undefined') { + return DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + FORBID_TAGS: ['style', 'script'], + FORBID_ATTR: ['style', 'onerror', 'onload'] + }); + } + // DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML + return ''; } function copyCodeBlock(btn) { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 6f21b428..b6dd9d3a 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -15,6 +15,11 @@ + - + and and end of content.'), + 'Here is some content: and ' + ' and end of content.'), ] DEFAULT_RESPONSE = "I understand your request." +TOOL_CALL_PATTERNS = [ + (re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}), + (re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}), +] -def match_response(messages: list[dict]) -> str: - """Find canned response for the last user message.""" + +def _last_user_content(messages: list[dict]) -> str: for msg in reversed(messages): if msg.get("role") == "user": content = msg.get("content", "") - # Handle content that may be a list (multi-modal) if isinstance(content, list): content = " ".join( - part.get("text", "") for part in content if part.get("type") == "text" + p.get("text", "") for p in content if p.get("type") == "text" ) - for pattern, response in CANNED_RESPONSES: - if pattern.search(content): - return response - return DEFAULT_RESPONSE + return content + return "" + + +def match_response(messages: list[dict]) -> str: + content = _last_user_content(messages) + for pattern, response in CANNED_RESPONSES: + if pattern.search(content): + return response return DEFAULT_RESPONSE +def match_tool_call(messages: list[dict], has_tools: bool) -> dict | None: + if not has_tools: + return None + content = _last_user_content(messages) + for pattern, tool_name, args_fn in TOOL_CALL_PATTERNS: + m = pattern.search(content) + if m: + return {"tool_name": tool_name, "arguments": args_fn(m)} + return None + + +def _extract_tool_name(msg: dict) -> str: + """Extract tool name from a message, checking both 'name' field and XML content.""" + name = msg.get("name") + if name: + return name + # ironclaw wraps tool output as + content = msg.get("content", "") + m = re.search(r' dict | None: + """Find a pending tool result that appears after the last user message. + + Only returns a tool result if it's a fresh result the agent is waiting + for the LLM to summarize (i.e., it follows the most recent user message). + This prevents stale tool results from earlier conversation turns from + being re-processed. + """ + # Find the position of the last user message + last_user_idx = -1 + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + last_user_idx = i + break + + # Only look for tool results after the last user message + for i in range(len(messages) - 1, last_user_idx, -1): + if messages[i].get("role") == "tool": + return {"name": _extract_tool_name(messages[i]), + "content": messages[i].get("content", "")} + return None + + +def _make_base(completion_id: str) -> dict: + return {"id": completion_id, "object": "chat.completion.chunk", + "created": int(time.time()), "model": "mock-model"} + + +async def _send_sse(resp: web.StreamResponse, data: dict): + await resp.write(f"data: {json.dumps(data)}\n\n".encode()) + + async def chat_completions(request: web.Request) -> web.StreamResponse: - """Handle POST /v1/chat/completions.""" + """Handle POST /v1/chat/completions and /chat/completions.""" body = await request.json() messages = body.get("messages", []) stream = body.get("stream", False) - response_text = match_response(messages) - completion_id = f"mock-{uuid.uuid4().hex[:8]}" + has_tools = bool(body.get("tools")) + cid = f"mock-{uuid.uuid4().hex[:8]}" + # Tool result in messages -> text summary + tr = _find_tool_result(messages) + if tr: + text = f"The {tr['name']} tool returned: {tr['content']}" + if not stream: + return _text_response(cid, text) + return await _stream_text(request, cid, text) + + # Tool-call pattern match + tc = match_tool_call(messages, has_tools) + if tc: + if not stream: + return _tool_call_response(cid, tc) + return await _stream_tool_call(request, cid, tc) + + # Default text response + text = match_response(messages) if not stream: - return web.json_response({ - "id": completion_id, - "object": "chat.completion", - "created": int(time.time()), - "model": "mock-model", - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": response_text}, - "finish_reason": "stop", - }], - "usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15}, - }) + return _text_response(cid, text) + return await _stream_text(request, cid, text) - # Streaming response: split into word-boundary chunks - resp = web.StreamResponse( - status=200, - headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, - ) - await resp.prepare(request) - # First chunk: role - chunk = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": int(time.time()), +def _text_response(cid: str, text: str) -> web.Response: + return web.json_response({ + "id": cid, "object": "chat.completion", "created": int(time.time()), "model": "mock-model", - "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], - } - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15}, + }) - # Content chunks: split on spaces - words = response_text.split(" ") - for i, word in enumerate(words): - text = word if i == 0 else f" {word}" - chunk["choices"][0]["delta"] = {"content": text} - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) - # Final chunk: finish_reason +def _tool_call_response(cid: str, tc: dict) -> web.Response: + return web.json_response({ + "id": cid, "object": "chat.completion", "created": int(time.time()), + "model": "mock-model", + "choices": [{"index": 0, "message": { + "role": "assistant", "content": None, + "tool_calls": [{"id": f"call_{uuid.uuid4().hex[:8]}", "type": "function", + "function": {"name": tc["tool_name"], + "arguments": json.dumps(tc["arguments"])}}], + }, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }) + + +async def _stream_text(request: web.Request, cid: str, text: str) -> web.StreamResponse: + resp = web.StreamResponse(status=200, headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache"}) + await resp.prepare(request) + base = _make_base(cid) + chunk = {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, + "finish_reason": None}]} + await _send_sse(resp, chunk) + for i, word in enumerate(text.split(" ")): + chunk["choices"][0]["delta"] = {"content": word if i == 0 else f" {word}"} + await _send_sse(resp, chunk) chunk["choices"][0]["delta"] = {} chunk["choices"][0]["finish_reason"] = "stop" - await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + await _send_sse(resp, chunk) await resp.write(b"data: [DONE]\n\n") - return resp +async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.StreamResponse: + resp = web.StreamResponse(status=200, headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache"}) + await resp.prepare(request) + call_id = f"call_{uuid.uuid4().hex[:8]}" + base = _make_base(cid) + # First chunk: role + tool call header with empty arguments + chunk = {**base, "choices": [{"index": 0, "delta": { + "role": "assistant", + "tool_calls": [{"index": 0, "id": call_id, "type": "function", + "function": {"name": tc["tool_name"], "arguments": ""}}], + }, "finish_reason": None}]} + await _send_sse(resp, chunk) + # Second chunk: arguments payload + chunk["choices"][0]["delta"] = { + "tool_calls": [{"index": 0, "function": {"arguments": json.dumps(tc["arguments"])}}]} + await _send_sse(resp, chunk) + # Final chunk: finish reason + chunk["choices"][0]["delta"] = {} + chunk["choices"][0]["finish_reason"] = "tool_calls" + await _send_sse(resp, chunk) + await resp.write(b"data: [DONE]\n\n") + return resp + + +async def oauth_exchange(request: web.Request) -> web.Response: + """Mock OAuth token exchange proxy for E2E tests. + + Accepts form params (code, redirect_uri, code_verifier) and returns + a fake token response. Called by ironclaw's exchange_via_proxy() when + IRONCLAW_OAUTH_EXCHANGE_URL is set. + """ + data = await request.post() + code = data.get("code", "") + return web.json_response({ + "access_token": f"mock-token-{code}", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + }) + + async def models(_request: web.Request) -> web.Response: - """Handle GET /v1/models.""" return web.json_response({ "object": "list", "data": [{"id": "mock-model", "object": "model", "owned_by": "test"}], @@ -102,23 +229,21 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=0) args = parser.parse_args() - app = web.Application() + # Register both /v1/ and non-/v1/ paths (rig-core omits the /v1/ prefix) app.router.add_post("/v1/chat/completions", chat_completions) + app.router.add_post("/chat/completions", chat_completions) app.router.add_get("/v1/models", models) - - # Use aiohttp's runner to get the actual bound port - import asyncio + app.router.add_get("/models", models) + app.router.add_post("/oauth/exchange", oauth_exchange) async def start(): runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", args.port) await site.start() - # Extract the actual port from the bound socket port = site._server.sockets[0].getsockname()[1] print(f"MOCK_LLM_PORT={port}", flush=True) - # Block forever await asyncio.Event().wait() asyncio.run(start()) diff --git a/tests/e2e/scenarios/test_extension_oauth.py b/tests/e2e/scenarios/test_extension_oauth.py new file mode 100644 index 00000000..b20d4275 --- /dev/null +++ b/tests/e2e/scenarios/test_extension_oauth.py @@ -0,0 +1,264 @@ +"""Extension OAuth round-trip e2e tests. + +Tests the full internal OAuth callback pipeline: install gmail → configure +(get auth_url) → simulate OAuth callback → verify token stored. Uses gateway +callback mode + mock token exchange (no real Google login). + +The conftest sets IRONCLAW_OAUTH_CALLBACK_URL (non-loopback, forces gateway +mode) and IRONCLAW_OAUTH_EXCHANGE_URL (points to mock_llm.py's /oauth/exchange). +""" + +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from helpers import api_get, api_post + +# Module-level state +_gmail_installed = False +_auth_url = None +_csrf_state = None + + +def _extract_state(auth_url: str) -> str: + """Extract the CSRF state parameter from an OAuth authorization URL.""" + parsed = urlparse(auth_url) + qs = parse_qs(parsed.query) + assert "state" in qs, f"auth_url should contain state param: {auth_url}" + state = qs["state"][0] + assert len(state) > 0 + return state + + +async def _get_extension(base_url, name): + """Get a specific extension from the extensions list, or None.""" + r = await api_get(base_url, "/api/extensions") + for ext in r.json().get("extensions", []): + if ext["name"] == name: + return ext + return None + + +async def _ensure_removed(base_url, name): + """Remove extension if already installed.""" + ext = await _get_extension(base_url, name) + if ext: + await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30) + + +# ── Section A: Install + OAuth Initiation ──────────────────────────────── + + +async def test_oauth_install_gmail(ironclaw_server): + """Install gmail from registry for OAuth testing.""" + global _gmail_installed + await _ensure_removed(ironclaw_server, "gmail") + + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": "gmail"}, + timeout=180, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Install failed: {data.get('message', '')}" + _gmail_installed = True + + +async def test_oauth_configure_returns_auth_url(ironclaw_server): + """Configure with empty secrets returns an OAuth auth_url.""" + global _auth_url, _csrf_state + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Configure failed: {data.get('message', '')}" + + _auth_url = data.get("auth_url") + assert _auth_url is not None, f"Expected auth_url in response: {data}" + assert "accounts.google.com" in _auth_url, ( + f"auth_url should point to Google: {_auth_url}" + ) + + _csrf_state = _extract_state(_auth_url) + + +async def test_oauth_activate_returns_auth_url(ironclaw_server): + """Activate on un-authenticated gmail returns auth_url.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, "/api/extensions/gmail/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + # Activation may fail with auth_url or succeed with auth_url + auth_url = data.get("auth_url") + assert auth_url is not None, f"Expected auth_url in activate response: {data}" + + +# ── Section B: Internal OAuth Round-Trip ───────────────────────────────── + + +async def test_oauth_callback_exchanges_token(ironclaw_server): + """Simulate OAuth callback with mock code — verifies token exchange.""" + global _csrf_state + if not _csrf_state: + pytest.skip("No CSRF state from configure step") + + # Re-configure to get a fresh pending flow (previous configure may have + # been consumed by the activate test above) + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + data = r.json() + auth_url = data.get("auth_url") + if auth_url: + _csrf_state = _extract_state(auth_url) + + # Hit the OAuth callback endpoint directly (public route, no auth header). + # The callback handler looks up the pending flow by state, calls + # exchange_via_proxy() which hits mock_llm.py's /oauth/exchange, and + # stores the returned fake token. + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": _csrf_state}, + timeout=30, + follow_redirects=True, + ) + + assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}" + body = r.text.lower() + # The landing page says " Connected" on success, "failed" on error + assert "connected" in body or "success" in body, ( + f"Callback HTML should indicate success: {r.text[:500]}" + ) + + +async def test_oauth_callback_replay_rejected(ironclaw_server): + """Replaying the same callback is rejected (flow consumed on first use).""" + if not _csrf_state: + pytest.skip("No CSRF state") + + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": _csrf_state}, + timeout=10, + follow_redirects=True, + ) + + # Should fail — the flow was already consumed + body = r.text.lower() + assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, ( + f"Replay should be rejected, got status={r.status_code}: {r.text[:500]}" + ) + + +async def test_oauth_callback_invalid_state(ironclaw_server): + """Callback with bogus state is rejected.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "x", "state": "totally-bogus-state-value"}, + timeout=10, + follow_redirects=True, + ) + + body = r.text.lower() + assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, ( + f"Invalid state should be rejected, got status={r.status_code}: {r.text[:500]}" + ) + + +async def test_oauth_extension_authenticated(ironclaw_server): + """After OAuth callback, gmail shows authenticated=True.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None, "gmail not in extensions list" + assert ext["authenticated"] is True, ( + f"gmail should be authenticated after OAuth callback: {ext}" + ) + + +async def test_oauth_tools_registered(ironclaw_server): + """After OAuth authentication, gmail tools appear in tools endpoint.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None + # Check the extension's tools array + tools = ext.get("tools", []) + assert len(tools) > 0, ( + f"gmail should have tools registered after auth: {ext}" + ) + + +async def test_remove_during_pending_oauth_invalidates_callback(ironclaw_server): + """Removing an extension while OAuth is pending invalidates the callback state.""" + if not _gmail_installed: + pytest.skip("gmail not installed") + + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + auth_url = data.get("auth_url") + assert auth_url is not None, f"Expected auth_url in response: {data}" + callback_state = _extract_state(auth_url) + + remove_r = await api_post( + ironclaw_server, "/api/extensions/gmail/remove", timeout=30 + ) + assert remove_r.status_code == 200 + assert remove_r.json().get("success") is True, ( + f"Removing gmail during pending OAuth should succeed: {remove_r.text[:300]}" + ) + + async with httpx.AsyncClient() as client: + callback_r = await client.get( + f"{ironclaw_server}/oauth/callback", + params={"code": "mock_auth_code", "state": callback_state}, + timeout=30, + follow_redirects=True, + ) + + assert callback_r.status_code == 200 + body = callback_r.text.lower() + assert "error" in body or "fail" in body or "expired" in body, ( + f"Callback after removal should fail: {callback_r.text[:500]}" + ) + + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is None, "gmail should remain removed after invalidated callback" + + +# ── Section C: Cleanup ────────────────────────────────────────────────── + + +async def test_cleanup_gmail(ironclaw_server): + """Remove gmail (cleanup for other test files).""" + await _ensure_removed(ironclaw_server, "gmail") + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is None, "gmail should be removed" diff --git a/tests/e2e/scenarios/test_extensions.py b/tests/e2e/scenarios/test_extensions.py index 6cddacb4..f172d420 100644 --- a/tests/e2e/scenarios/test_extensions.py +++ b/tests/e2e/scenarios/test_extensions.py @@ -458,6 +458,37 @@ async def test_install_wasm_channel_triggers_configure(page): assert await modal.is_visible() +async def test_install_with_auth_url_opens_popup_and_shows_auth_prompt(page): + """Install responses with auth_url should surface the same auth prompt used elsewhere.""" + await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + + async def handle_install(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}), + ) + + await page.route("**/api/extensions/install", handle_install) + await go_to_extensions(page) + + install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + await install_btn.wait_for(state="visible", timeout=5000) + await install_btn.click() + + await page.wait_for_function( + "() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined", + timeout=5000, + ) + opened = await page.evaluate("window._lastOpenedUrl") + assert opened is not None, "window.open was not called" + assert "example.com" in opened + await page.locator(SEL["auth_card"] + '[data-extension-name="registry-tool"]').wait_for( + state="visible", timeout=5000 + ) + + # ─── Group F: Remove flow ───────────────────────────────────────────────────── async def test_remove_installed_extension_confirmed(page): @@ -612,7 +643,7 @@ async def test_configure_modal_save_success(page): async def test_configure_modal_save_oauth(page): - """Save response with auth_url opens a popup via window.open.""" + """Save response with auth_url opens a popup and shows the global auth prompt.""" await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") async def handle_setup(route): @@ -639,6 +670,9 @@ async def test_configure_modal_save_oauth(page): opened = await page.evaluate("window._lastOpenedUrl") assert opened is not None, "window.open was not called" assert "oauth" in opened or "example.com" in opened + await page.locator(SEL["auth_card"] + '[data-extension-name="test-ext"]').wait_for( + state="visible", timeout=5000 + ) async def test_configure_modal_save_failure(page): @@ -699,7 +733,7 @@ async def test_configure_modal_enter_key_submits(page): # ─── Group H: Auth card (SSE-triggered) ─────────────────────────────────────── async def _show_auth_card(page, **kwargs): - """Inject an auth card via JS and wait for it to appear.""" + """Inject the global auth prompt via JS and wait for it to appear.""" payload = json.dumps(kwargs) await page.evaluate(f"showAuthCard({payload})") await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000) @@ -812,12 +846,43 @@ async def test_auth_card_replaces_existing_same_extension(page): assert "Second" in await page.locator(SEL["auth_instructions"]).text_content() -async def test_auth_card_multiple_extensions_coexist(page): - """Auth cards for different extensions can coexist.""" +async def test_auth_card_for_different_extension_replaces_existing_prompt(page): + """A new auth prompt replaces the previous one to keep the UX modal and global.""" await page.evaluate('showAuthCard({extension_name: "ext-a", instructions: "Token A"})') await page.evaluate('showAuthCard({extension_name: "ext-b", instructions: "Token B"})') - await page.locator(SEL["auth_card"]).nth(1).wait_for(state="visible", timeout=3000) - assert await page.locator(SEL["auth_card"]).count() == 2 + await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=3000) + assert await page.locator(SEL["auth_card"]).count() == 1 + assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-a"]').count() == 0 + assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-b"]').count() == 1 + + +async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_names(page): + """Quoted extension names should not break auth/configure modal helpers.""" + result = await page.evaluate( + """({ name }) => { + showAuthCard({ extension_name: name, instructions: 'Paste token' }); + showAuthCardError(name, 'Bad token'); + const errorText = document.querySelector('.auth-error')?.textContent || ''; + removeAuthCard(name); + const authStillPresent = Array.from(document.querySelectorAll('.auth-card')) + .some((card) => card.getAttribute('data-extension-name') === name); + + const overlay = document.createElement('div'); + overlay.className = 'configure-overlay'; + overlay.setAttribute('data-extension-name', name); + document.body.appendChild(overlay); + closeConfigureModal(name); + const configureStillPresent = Array.from(document.querySelectorAll('.configure-overlay')) + .some((node) => node.getAttribute('data-extension-name') === name); + + return { errorText, authStillPresent, configureStillPresent }; + }""", + {"name": 'quoted "ext" name'}, + ) + + assert result["errorText"] == "Bad token" + assert result["authStillPresent"] is False + assert result["configureStillPresent"] is False async def test_auth_completed_sse_dismisses_card(page): @@ -826,13 +891,95 @@ async def test_auth_completed_sse_dismisses_card(page): # Simulate the auth_completed SSE event being fired await page.evaluate(""" - // Call the handler the same way the SSE listener does - removeAuthCard('myext'); + handleAuthCompleted({ + extension_name: 'myext', + success: true, + message: 'Authenticated!', + }); """) assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0 +async def test_auth_completed_for_other_extension_keeps_configure_modal_open(page): + """Auth completion should not close a different extension's configure modal.""" + async def handle_setup(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + + await page.evaluate(""" + handleAuthCompleted({ + extension_name: 'other-ext', + success: true, + message: 'Other extension connected.', + }); + """) + + assert await page.locator(SEL["configure_overlay"]).is_visible(), ( + "Configure modal should remain open when another extension finishes auth" + ) + + +async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensions(page): + """Failed auth_completed handling should clear stale UI and refresh extensions.""" + reload_count = [] + + async def counting_handler(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + reload_count.append(1) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": []}), + ) + else: + await route.continue_() + + async def handle_tools(route): + await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') + + async def handle_registry(route): + await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + + await page.route("**/api/extensions*", counting_handler) + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + + await go_to_extensions(page) + count_before = len(reload_count) + + await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth") + assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1 + + await page.evaluate(""" + handleAuthCompleted({ + extension_name: 'gmail', + success: false, + message: 'OAuth flow expired. Please try again.', + }); + """) + + await wait_for_toast(page, "OAuth flow expired. Please try again.") + assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0 + assert ( + await page.locator( + SEL["toast_error"], has_text="OAuth flow expired. Please try again." + ).count() + >= 1 + ) + + await page.wait_for_timeout(600) + assert len(reload_count) > count_before, "Extensions list did not reload after auth failure" + + # ─── Group I: Activate flow ──────────────────────────────────────────────────── async def test_activate_mcp_server_success(page): @@ -902,8 +1049,8 @@ async def test_activate_failure_shows_error_toast(page): await wait_for_toast(page, "Config missing") -async def test_activate_with_auth_url_opens_popup(page): - """Activate response with auth_url calls window.open.""" +async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page): + """Activate response with auth_url calls window.open and shows the auth prompt.""" await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") await mock_ext_apis(page, installed=[_MCP_INACTIVE]) @@ -921,6 +1068,9 @@ async def test_activate_with_auth_url_opens_popup(page): opened = await page.evaluate("window._lastOpenedUrl") assert opened is not None, "window.open was not called" assert "example.com" in opened + await page.locator( + SEL["auth_card"] + '[data-extension-name="test-mcp-inactive"]' + ).wait_for(state="visible", timeout=5000) # ─── Group J: Tab reload behaviour ──────────────────────────────────────────── @@ -947,9 +1097,9 @@ async def test_extensions_tab_reloads_on_revisit(page): async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) - await page.route("**/api/extensions*", counting_handler) # First visit await go_to_extensions(page) @@ -990,19 +1140,20 @@ async def test_auth_completed_sse_triggers_extensions_reload(page): async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) - await page.route("**/api/extensions*", counting_handler) await go_to_extensions(page) count_before = len(reload_count) - # Simulate auth_completed by calling loadExtensions directly (as the SSE handler does) + # Simulate auth_completed via the shared handler. await page.evaluate(""" - // Simulate what the auth_completed SSE handler does when currentTab === 'extensions' - if (typeof loadExtensions === 'function') { - loadExtensions(); - } + handleAuthCompleted({ + extension_name: 'reload-ext', + success: true, + message: 'Reloaded.', + }); """) await page.wait_for_timeout(600) diff --git a/tests/e2e/scenarios/test_pairing.py b/tests/e2e/scenarios/test_pairing.py new file mode 100644 index 00000000..e3ff9144 --- /dev/null +++ b/tests/e2e/scenarios/test_pairing.py @@ -0,0 +1,79 @@ +"""DM pairing flow e2e tests. + +Tests the pairing security gate for WASM channels: listing pending requests, +approving codes, and error handling. +""" + +import httpx +from helpers import AUTH_TOKEN + + +def _headers(): + return {"Authorization": f"Bearer {AUTH_TOKEN}"} + + +async def test_pairing_list_returns_empty_for_unknown_channel(ironclaw_server): + """GET /api/pairing/{channel} returns empty list or 404 for non-existent channel.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{ironclaw_server}/api/pairing/nonexistent-channel", + headers=_headers(), + timeout=10, + ) + # Either empty list or error is acceptable + if r.status_code == 200: + data = r.json() + assert isinstance(data, (dict, list)) + if isinstance(data, dict): + assert "requests" in data + assert isinstance(data["requests"], list) + assert data["requests"] == [] + else: + assert data == [] + else: + # 404 or similar is fine for non-existent channel + assert r.status_code in (404, 400) + + +async def test_approve_invalid_code_rejected(ironclaw_server): + """POST /api/pairing/{channel}/approve with bad code returns error.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": "INVALID0"}, + headers=_headers(), + timeout=10, + ) + # Should fail — no pending request with this code + if r.status_code == 200: + data = r.json() + assert data.get("success") is False or data.get("ok") is False or "error" in str(data).lower() + else: + assert r.status_code >= 400 + + +async def test_approve_empty_code_rejected(ironclaw_server): + """POST /api/pairing/{channel}/approve with empty code returns error.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": ""}, + headers=_headers(), + timeout=10, + ) + if r.status_code == 200: + data = r.json() + assert data.get("success") is False or data.get("ok") is False + else: + assert r.status_code >= 400 + + +async def test_pairing_approve_requires_auth(ironclaw_server): + """POST /api/pairing/{channel}/approve without auth token is rejected.""" + async with httpx.AsyncClient() as client: + r = await client.post( + f"{ironclaw_server}/api/pairing/test-channel/approve", + json={"code": "ABCD1234"}, + timeout=10, + ) + assert r.status_code == 401 or r.status_code == 403 diff --git a/tests/e2e/scenarios/test_tool_execution.py b/tests/e2e/scenarios/test_tool_execution.py new file mode 100644 index 00000000..89627ac3 --- /dev/null +++ b/tests/e2e/scenarios/test_tool_execution.py @@ -0,0 +1,94 @@ +"""Tool execution e2e tests. + +Tests the agent loop: user message -> mock LLM returns tool_calls -> tool +executes -> result displayed in chat. Requires the enhanced mock_llm.py +with TOOL_CALL_PATTERNS support. +""" + +from helpers import SEL + + +async def _send_and_get_response( + page, + message: str, + *, + expected_fragment: str, + timeout: int = 30000, +) -> str: + """Send a message and return the text of the newest assistant response. + + Counts existing assistant messages before sending, then waits for a new + one to appear and contain the expected final text fragment. This avoids + reading partial streamed content before the assistant response is complete. + """ + chat_input = page.locator(SEL["chat_input"]) + await chat_input.wait_for(state="visible", timeout=5000) + + # Count existing assistant messages before sending + assistant_sel = SEL["message_assistant"] + before_count = await page.locator(assistant_sel).count() + + await chat_input.fill(message) + await chat_input.press("Enter") + + # Wait for the final assistant message to exist and include the expected + # text fragment rather than returning on the first streamed chunk. + expected = before_count + 1 + await page.wait_for_function( + """({ assistantSelector, expectedCount, expectedFragment }) => { + const messages = document.querySelectorAll(assistantSelector); + if (messages.length < expectedCount) return false; + const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase(); + return text.includes(expectedFragment.toLowerCase()); + }""", + arg={ + "assistantSelector": assistant_sel, + "expectedCount": expected, + "expectedFragment": expected_fragment, + }, + timeout=timeout, + ) + + return await page.locator(assistant_sel).last.inner_text() + + +async def test_builtin_echo_tool(page): + """Send a message that triggers the echo tool via mock LLM function calling.""" + text = await _send_and_get_response( + page, + "echo hello world", + expected_fragment="hello world", + ) + + # The mock LLM returns "The echo tool returned: " + assert "echo" in text.lower() or "hello world" in text.lower(), ( + f"Expected echo result in response, got: {text}" + ) + + +async def test_builtin_time_tool(page): + """Send a message that triggers the time tool via mock LLM function calling.""" + text = await _send_and_get_response( + page, + "what time is it", + expected_fragment="time", + ) + + # The mock LLM returns "The time tool returned: " + assert "time" in text.lower(), ( + f"Expected time result in response, got: {text}" + ) + + +async def test_non_tool_message_still_works(page): + """Messages that don't match tool patterns still get text responses.""" + text = await _send_and_get_response( + page, + "What is 2+2?", + expected_fragment="4", + timeout=15000, + ) + + assert "4" in text, ( + f"Expected '4' in response, got: {text}" + ) diff --git a/tests/e2e/scenarios/test_wasm_lifecycle.py b/tests/e2e/scenarios/test_wasm_lifecycle.py new file mode 100644 index 00000000..961e7ad0 --- /dev/null +++ b/tests/e2e/scenarios/test_wasm_lifecycle.py @@ -0,0 +1,517 @@ +"""Comprehensive WASM extension lifecycle e2e tests. + +Tests the full extension pipeline: registry → install → fields → configure → +activate → tools → remove → reinstall. Validates response fields, not just +status codes, to catch production bugs like missing capabilities, wrong +activation state, and stale registry flags. + +Lifecycle stages are expressed as scoped fixtures so each test requests the +state it needs explicitly rather than relying on module-global flags. +""" + +from pathlib import Path + +import pytest + +from helpers import SEL, api_get, api_post + +async def _get_extension(base_url, name): + """Get a specific extension from the extensions list, or None.""" + r = await api_get(base_url, "/api/extensions") + for ext in r.json().get("extensions", []): + if ext["name"] == name: + return ext + return None + + +async def _ensure_removed(base_url, name): + """Remove extension if already installed (idempotent cleanup).""" + ext = await _get_extension(base_url, name) + if ext: + await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30) + + +async def _install_extension(base_url, name): + """Install an extension and assert success.""" + r = await api_post( + base_url, + "/api/extensions/install", + json={"name": name}, + timeout=180, + ) + assert r.status_code == 200, f"Install HTTP error: {r.status_code} {r.text[:300]}" + data = r.json() + assert data.get("success") is True, f"Install failed: {data.get('message', '')}" + return data + + +@pytest.fixture(scope="module", autouse=True) +async def extension_lifecycle_cleanup(ironclaw_server): + """Start and end the module with a clean extension set.""" + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + yield + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + + +@pytest.fixture(scope="module") +async def web_search_installed(ironclaw_server, extension_lifecycle_cleanup): + """Install web-search once for tests that require the pre-configure state.""" + data = await _install_extension(ironclaw_server, "web-search") + return {"name": "web-search", "install": data} + + +@pytest.fixture(scope="module") +async def web_search_configured(ironclaw_server, web_search_installed): + """Configure web-search once for tests that require the active state.""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"brave_api_key": "test-key-123"}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Configure failed: {data.get('message', '')}" + assert data.get("activated") is True, "Should auto-activate after configure" + return {"name": "web-search", "configure": data} + + +@pytest.fixture(scope="module") +async def gmail_installed(ironclaw_server, extension_lifecycle_cleanup): + """Install gmail once for multi-extension and OAuth setup assertions.""" + data = await _install_extension(ironclaw_server, "gmail") + return {"name": "gmail", "install": data} + + +@pytest.fixture(scope="module") +async def web_search_removed(ironclaw_server, web_search_configured): + """Remove web-search once for post-uninstall assertions.""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/remove", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Remove failed: {data.get('message', '')}" + return {"name": "web-search", "remove": data} + + +@pytest.fixture(scope="module") +async def web_search_reinstalled(ironclaw_server, web_search_removed): + """Reinstall web-search after removal to verify saved-secret recovery.""" + await _ensure_removed(ironclaw_server, "web-search") + data = await _install_extension(ironclaw_server, "web-search") + return {"name": "web-search", "install": data} + + +# ── Section A: Registry Validation ────────────────────────────────────── + + +async def test_registry_lists_extensions(ironclaw_server): + """Registry endpoint returns entries from the embedded catalog.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + assert r.status_code == 200 + data = r.json() + assert "entries" in data + names = [e["name"] for e in data["entries"]] + assert "web-search" in names + assert "gmail" in names + + +async def test_registry_entry_fields(ironclaw_server): + """Every registry entry has all required fields with correct types.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + assert len(entries) > 0, "Registry should have entries" + for entry in entries: + assert "name" in entry and isinstance(entry["name"], str) and entry["name"] + assert "display_name" in entry and isinstance(entry["display_name"], str) + assert "kind" in entry and isinstance(entry["kind"], str) + assert "description" in entry and isinstance(entry["description"], str) + assert "installed" in entry and isinstance(entry["installed"], bool) + assert "keywords" in entry and isinstance(entry["keywords"], list) + + +async def test_registry_installed_flag_false_initially(ironclaw_server): + """Before any install, all registry entries have installed=False.""" + # Clean up in case previous test run left extensions installed + await _ensure_removed(ironclaw_server, "web-search") + await _ensure_removed(ironclaw_server, "gmail") + + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + for entry in entries: + if entry["name"] in ("web-search", "gmail"): + assert entry["installed"] is False, ( + f"{entry['name']} should not be installed yet" + ) + + +async def test_registry_search_filters(ironclaw_server): + """Search query filters registry results.""" + r = await api_get( + ironclaw_server, "/api/extensions/registry", params={"query": "search"} + ) + assert r.status_code == 200 + entries = r.json()["entries"] + names = [e["name"] for e in entries] + assert "web-search" in names + + +async def test_registry_search_no_match(ironclaw_server): + """Nonsense query returns empty results.""" + r = await api_get( + ironclaw_server, + "/api/extensions/registry", + params={"query": "xyznonexistent999"}, + ) + assert r.status_code == 200 + assert len(r.json()["entries"]) == 0 + + +# ── Section B: Install Lifecycle (web-search) ─────────────────────────── + + +async def test_install_web_search(web_search_installed): + """Install web-search from registry. Asserts success — failure here means + the registry/download/build pipeline is broken.""" + assert "message" in web_search_installed["install"] + + +async def test_installed_extension_fields(ironclaw_server, web_search_installed): + """After install, extension list shows correct fields.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None, "web-search not in extensions list after install" + assert ext["kind"] == "wasm_tool" + assert ext["needs_setup"] is True, "Should need setup (has brave_api_key secret)" + assert ext["authenticated"] is False, "Should not be authenticated before configure" + + +async def test_installed_in_registry(ironclaw_server, web_search_installed): + """Registry marks installed extension with installed=True.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + entries = r.json()["entries"] + ws_entry = next((e for e in entries if e["name"] == "web-search"), None) + assert ws_entry is not None + assert ws_entry["installed"] is True, "Registry should show installed=True" + + +async def test_setup_schema_has_secrets(ironclaw_server, web_search_installed): + """Setup schema returns brave_api_key with correct field info.""" + r = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + assert r.status_code == 200 + data = r.json() + assert "secrets" in data + secrets = {s["name"]: s for s in data["secrets"]} + assert "brave_api_key" in secrets, ( + f"brave_api_key not in setup schema secrets: {list(secrets.keys())}" + ) + key_info = secrets["brave_api_key"] + assert key_info["provided"] is False, "Should not be provided yet" + + +async def test_extension_not_authenticated_before_configure( + ironclaw_server, web_search_installed +): + """Installed but not configured extension is not authenticated.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None + # Before configuring secrets, extension shouldn't be fully authenticated + assert ext["needs_setup"] is True, "Should still need setup before configure" + + +async def test_activate_before_configure_rejected(ironclaw_server, web_search_installed): + """Activating a tool that needs setup secrets is rejected.""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, ( + f"Activate should fail before configure: {data}" + ) + msg = data.get("message", "").lower() + assert "requires configuration" in msg or "setup" in msg, ( + f"Error should mention configuration: {data.get('message')}" + ) + + +# ── Section C: Configure + Activate (web-search) ──────────────────────── + + +async def test_configure_rejects_unknown_secret(ironclaw_server, web_search_installed): + """Submitting an unknown secret name is rejected.""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"fake_unknown_key": "value"}}, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, f"Should reject unknown secret: {data}" + assert "unknown" in data.get("message", "").lower() or "not found" in data.get( + "message", "" + ).lower(), f"Error should mention unknown secret: {data.get('message')}" + + +async def test_configure_with_valid_secret(web_search_configured): + """Configure with valid brave_api_key succeeds and auto-activates.""" + assert web_search_configured["configure"].get("activated") is True + + +async def test_extension_active_after_configure(ironclaw_server, web_search_configured): + """After configure, extension shows authenticated=True and active=True.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None + assert ext["authenticated"] is True, "Should be authenticated after configure" + assert ext["active"] is True, "Should be active after auto-activation" + assert len(ext.get("tools", [])) > 0, "Should have tools registered" + + +async def test_setup_shows_provided(ironclaw_server, web_search_configured): + """After configure, setup schema shows secret as provided.""" + r = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + assert r.status_code == 200 + secrets = {s["name"]: s for s in r.json()["secrets"]} + assert "brave_api_key" in secrets + assert secrets["brave_api_key"]["provided"] is True + + +async def test_tools_registered_after_activate( + ironclaw_server, web_search_configured +): + """After activation, extension tools appear in the tools endpoint.""" + r = await api_get(ironclaw_server, "/api/extensions/tools") + assert r.status_code == 200 + tool_names = [t["name"] for t in r.json()["tools"]] + assert "web-search" in tool_names, ( + f"web-search tool not found in tools list: {tool_names}" + ) + + +async def test_activate_already_active_idempotent( + ironclaw_server, web_search_configured +): + """Activating an already-active extension succeeds (idempotent).""" + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, ( + f"Re-activation should succeed: {data.get('message', '')}" + ) + + +async def test_configure_empty_secret_skipped(ironclaw_server, web_search_configured): + """Submitting an empty string for a secret skips it (doesn't overwrite).""" + r = await api_post( + ironclaw_server, + "/api/extensions/web-search/setup", + json={"secrets": {"brave_api_key": ""}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True + + # Verify the secret is still provided (not cleared) + r2 = await api_get(ironclaw_server, "/api/extensions/web-search/setup") + secrets = {s["name"]: s for s in r2.json()["secrets"]} + assert secrets["brave_api_key"]["provided"] is True, ( + "Empty value should not clear existing secret" + ) + + +# ── Section D: Install gmail (multi-extension) ────────────────────────── + + +async def test_install_gmail(gmail_installed): + """Install gmail from registry (second extension, tests isolation).""" + assert "message" in gmail_installed["install"] + + +async def test_gmail_fields(ironclaw_server, gmail_installed): + """Gmail extension has correct field values (OAuth-based auth).""" + ext = await _get_extension(ironclaw_server, "gmail") + assert ext is not None, "gmail not in extensions list" + assert ext["kind"] == "wasm_tool" + assert ext["has_auth"] is True, "Gmail should have OAuth auth" + + +async def test_both_extensions_listed( + ironclaw_server, web_search_configured, gmail_installed +): + """Both web-search and gmail appear in extensions list (no clobbering).""" + r = await api_get(ironclaw_server, "/api/extensions") + names = [e["name"] for e in r.json()["extensions"]] + assert "web-search" in names, f"web-search missing from: {names}" + assert "gmail" in names, f"gmail missing from: {names}" + + +async def test_gmail_setup_schema_auto_resolves(ironclaw_server, gmail_installed): + """Gmail setup schema returns empty secrets (builtin creds auto-resolve).""" + r = await api_get(ironclaw_server, "/api/extensions/gmail/setup") + assert r.status_code == 200 + data = r.json() + secrets = data.get("secrets", []) + # Builtin Google credentials auto-resolve client_id/client_secret via + # is_auto_resolved_oauth_field(), so the setup schema should have no + # user-facing secrets (or only auto-generated ones). + user_facing = [s for s in secrets if not s.get("auto_generate", False)] + assert len(user_facing) == 0, ( + f"Gmail should have no user-facing secrets (auto-resolved), got: " + f"{[s['name'] for s in user_facing]}" + ) + + +# ── Section E: Remove + Cleanup ───────────────────────────────────────── + + +async def test_remove_web_search(web_search_removed): + """Remove web-search succeeds.""" + assert web_search_removed["remove"].get("success") is True + + +async def test_removed_not_in_extensions(ironclaw_server, web_search_removed): + """Removed extension no longer appears in extensions list.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is None, "web-search should not be in extensions list after removal" + + +async def test_removed_extension_not_listed(ironclaw_server, web_search_removed): + """Removed extension should not appear in the extension tools list.""" + r = await api_get(ironclaw_server, "/api/extensions/tools") + assert r.status_code == 200 + tool_names = [t["name"] for t in r.json()["tools"]] + assert "web-search" not in tool_names, ( + f"Removed web-search tool should not remain registered: {tool_names}" + ) + + +async def test_removed_not_in_registry_installed(ironclaw_server, web_search_removed): + """Registry shows removed extension as installed=False.""" + r = await api_get(ironclaw_server, "/api/extensions/registry") + ws_entry = next( + (e for e in r.json()["entries"] if e["name"] == "web-search"), None + ) + assert ws_entry is not None + assert ws_entry["installed"] is False, "Registry should show installed=False" + + +async def test_activate_after_remove_uses_replacement_bytes_not_cached_module( + ironclaw_server, wasm_tools_dir, web_search_removed +): + """After removal, activation must use the replacement bytes rather than a stale cache.""" + wasm_path = Path(wasm_tools_dir) / "web-search.wasm" + wasm_path.write_bytes(b"not-a-valid-wasm-component") + + r = await api_post( + ironclaw_server, "/api/extensions/web-search/activate", timeout=30 + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is False, ( + f"Activation should fail against replacement bytes, got: {data}" + ) + + +async def test_reinstall_after_remove(ironclaw_server, web_search_reinstalled): + """Extension can be reinstalled after removal without stale activation errors.""" + ext = await _get_extension(ironclaw_server, "web-search") + assert ext is not None, "web-search not found after reinstall" + assert ext["active"] is True, "Reinstalled tool should auto-activate via saved secrets" + assert ext["authenticated"] is True, "Saved secret should still authenticate on reinstall" + # Verify no stale activation error from previous install + assert ext.get("activation_error") is None or ext.get("activation_error") == "", ( + f"Reinstalled extension should have no stale activation error: {ext}" + ) + + +# ── Section F: Error Paths ────────────────────────────────────────────── + + +async def test_install_nonexistent(ironclaw_server): + """Installing a nonexistent extension returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": "nonexistent-tool-xyz-999"}, + timeout=30, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_install_empty_name(ironclaw_server): + """Installing with empty name returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": ""}, + timeout=10, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_remove_noninstalled(ironclaw_server): + """Removing a non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, "/api/extensions/nonexistent-xyz/remove", timeout=10 + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_activate_noninstalled(ironclaw_server): + """Activating a non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, "/api/extensions/nonexistent-xyz/activate", timeout=10 + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +async def test_setup_noninstalled(ironclaw_server): + """Setup for non-installed extension returns an error.""" + r = await api_get(ironclaw_server, "/api/extensions/nonexistent-xyz/setup") + # May return 500 or a JSON error + assert r.status_code >= 400 or r.json().get("success") is False + + +async def test_configure_noninstalled(ironclaw_server): + """Configure for non-installed extension returns an error.""" + r = await api_post( + ironclaw_server, + "/api/extensions/nonexistent-xyz/setup", + json={"secrets": {}}, + timeout=10, + ) + if r.status_code == 200: + assert r.json().get("success") is False + else: + assert r.status_code >= 400 + + +# ── Section G: Browser UI ────────────────────────────────────────────── + + +async def test_extensions_tab_shows_registry(page): + """Extensions tab loads and shows available extensions from registry.""" + tab_btn = page.locator(SEL["tab_button"].format(tab="extensions")) + await tab_btn.click() + panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + await panel.wait_for(state="visible", timeout=5000) + + available_section = page.locator(SEL["available_wasm_list"]) + await available_section.wait_for(state="visible", timeout=10000) From cd1245afc099277d6f3f20a454cd0ca4e9edf3eb Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 16:58:43 -0700 Subject: [PATCH 60/67] fix(ci): repair staging-ci workflow parsing (#1090) --- .github/workflows/staging-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index ba0b8f91..4229f108 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -194,8 +194,7 @@ jobs: COMMIT_COUNT=$(echo "$COMMIT_LIST" | wc -l | tr -d ' ') if [ "$COMMIT_COUNT" -gt "$MAX_COMMITS" ]; then COMMIT_MD=$(echo "$COMMIT_LIST" | head -n "$MAX_COMMITS" | sed 's/^/- /') - COMMIT_MD="${COMMIT_MD} -- ... and $((COMMIT_COUNT - MAX_COMMITS)) more (see compare view)" + COMMIT_MD+=$'\n'"- ... and $((COMMIT_COUNT - MAX_COMMITS)) more (see compare view)" else COMMIT_MD=$(echo "$COMMIT_LIST" | sed 's/^/- /') fi From 15c5d3e2e2f4a3ddeb0ee7a35bcdba2605e0b1a4 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Thu, 12 Mar 2026 17:48:05 -0700 Subject: [PATCH 61/67] fix(wasm): address #1086 review followups -- description hint and coercion safety (#1092) Two fixes from the review of #1086 (tool_info schema discovery): 1. Replace fragile description string mutation (append_schema_hint_if_permissive / strip_schema_hint) with composition at display time. The raw description stays clean; the tool_info hint is composed in the Tool::schema() override only when the advertised schema is permissive. This also includes the tool name and `include_schema: true` in the hint for better LLM guidance. 2. Make effective_for_coercion use the load-time extracted schema from PreparedModule instead of re-calling the WASM schema() export on the already-running instance mid-execution. This avoids potential state contamination from calling schema() after linear memory is initialized for execution. Co-authored-by: Claude Opus 4.6 --- src/tools/wasm/wrapper.rs | 114 ++++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 43 deletions(-) diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index 52805afa..479acfa1 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -464,6 +464,7 @@ pub struct WasmToolWrapper { /// Capabilities to grant to this tool. capabilities: Capabilities, /// Cached description (from PreparedModule or override). + /// Stored without any tool_info hints — hints are composed at display time. description: String, /// Compact and discovery schemas for this tool. schemas: WasmToolSchemas, @@ -533,20 +534,25 @@ impl WasmToolSchemas { self.discovery.clone() } - fn effective_for_coercion( - &self, - tool_iface: &wit_tool::Guest, - store: &mut Store, - ) -> serde_json::Value { + /// Return the best schema available for type coercion. + /// + /// Prefers the discovery schema when it has typed properties. Falls back + /// to the `PreparedModule` schema extracted at load time rather than + /// re-calling the WASM `schema()` export mid-execution, which could + /// interact with mutable linear memory state. + fn effective_for_coercion(&self, prepared_schema: &serde_json::Value) -> serde_json::Value { if !Self::is_permissive_schema(&self.discovery) { return self.discovery.clone(); } - tool_iface - .call_schema(store) - .ok() - .and_then(|schema_str| serde_json::from_str::(&schema_str).ok()) - .unwrap_or_else(|| self.discovery.clone()) + // Fall back to the load-time extracted schema from PreparedModule. + // This avoids calling schema() on the already-running WASM instance + // where mutable state could produce inconsistent results. + if !Self::is_permissive_schema(prepared_schema) { + return prepared_schema.clone(); + } + + self.discovery.clone() } } @@ -557,7 +563,7 @@ impl WasmToolWrapper { prepared: Arc, capabilities: Capabilities, ) -> Self { - let mut wrapper = Self { + Self { description: prepared.description.clone(), schemas: WasmToolSchemas::new(prepared.schema.clone()), runtime, @@ -566,45 +572,21 @@ impl WasmToolWrapper { credentials: HashMap::new(), secrets_store: None, oauth_refresh: None, - }; - wrapper.append_schema_hint_if_permissive(); - wrapper + } } /// Override the tool description. pub fn with_description(mut self, description: impl Into) -> Self { self.description = description.into(); - self.append_schema_hint_if_permissive(); self } /// Override the parameter schema. pub fn with_schema(mut self, schema: serde_json::Value) -> Self { self.schemas = self.schemas.with_override(schema); - self.strip_schema_hint(); - self.append_schema_hint_if_permissive(); self } - /// Append a tool_info hint to the description when the schema is permissive - /// (no typed properties), so the LLM knows to call tool_info for the full schema. - fn append_schema_hint_if_permissive(&mut self) { - if self.schemas.is_advertised_permissive() && !self.description.contains("tool_info") { - self.description - .push_str(" (call tool_info for parameter schema)"); - } - } - - /// Remove the tool_info hint from the description (e.g. after with_schema adds real types). - fn strip_schema_hint(&mut self) { - if let Some(pos) = self - .description - .find(" (call tool_info for parameter schema)") - { - self.description.truncate(pos); - } - } - /// Set credentials for HTTP request placeholder injection. pub fn with_credentials(mut self, credentials: HashMap) -> Self { self.credentials = credentials; @@ -712,13 +694,14 @@ impl WasmToolWrapper { } })?; - // Get typed interface — used for execute and error hints. + // Get typed interface — used for execute. let tool_iface = instance.near_agent_tool(); // Determine effective schema for type coercion. - // Prefer the registration-time discovery schema when typed; otherwise - // try the WASM export transiently for this invocation only. - let effective_schema = self.schemas.effective_for_coercion(tool_iface, &mut store); + // Prefer the discovery schema when typed; fall back to the load-time + // extracted schema from PreparedModule rather than re-calling the WASM + // export on the already-running instance. + let effective_schema = self.schemas.effective_for_coercion(&self.prepared.schema); // Coerce string-encoded values to their schema-declared types. // LLMs frequently pass numeric values as strings (e.g. "5" instead of 5). @@ -832,6 +815,28 @@ impl Tool for WasmToolWrapper { self.schemas.discovery() } + /// Compose the tool schema for LLM function calling. + /// + /// When the advertised schema is permissive (no typed properties), appends + /// a hint to the description directing the LLM to call `tool_info` for the + /// full parameter schema. This keeps the raw description clean while still + /// guiding the LLM. + fn schema(&self) -> crate::tools::tool::ToolSchema { + let description = if self.schemas.is_advertised_permissive() { + format!( + "{} (call tool_info(name: \"{}\", include_schema: true) for parameter schema)", + self.description, self.prepared.name + ) + } else { + self.description.clone() + }; + crate::tools::tool::ToolSchema { + name: self.prepared.name.clone(), + description, + parameters: self.schemas.advertised(), + } + } + async fn execute( &self, params: serde_json::Value, @@ -1384,8 +1389,8 @@ mod tests { super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default()); wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone()); wrapper.description = "Search documents".to_string(); - wrapper.append_schema_hint_if_permissive(); + // Advertised schema stays permissive; discovery holds the typed schema assert_eq!( wrapper.parameters_schema(), serde_json::json!({ @@ -1395,8 +1400,24 @@ mod tests { }) ); assert_eq!(wrapper.discovery_schema(), discovery_schema); - assert!(wrapper.description().contains("tool_info")); + // Raw description is clean — no tool_info hint baked in + assert!(!wrapper.description().contains("tool_info")); + + // But schema() composes the hint at display time when advertised is permissive + let schema = wrapper.schema(); + assert!( + schema.description.contains("tool_info"), + "schema().description should contain tool_info hint: {}", + schema.description + ); + assert!( + schema.description.contains("include_schema: true"), + "hint should mention include_schema: true: {}", + schema.description + ); + + // After sidecar override, both schemas match and hint disappears let wrapper = wrapper.with_schema(serde_json::json!({ "type": "object", "properties": { @@ -1416,7 +1437,14 @@ mod tests { }) ); assert_eq!(wrapper.discovery_schema(), wrapper.parameters_schema()); - assert!(!wrapper.description().contains("tool_info")); + + // With typed schema, schema() should NOT include tool_info hint + let schema = wrapper.schema(); + assert!( + !schema.description.contains("tool_info"), + "schema().description should not contain tool_info hint when typed: {}", + schema.description + ); } #[test] From 3c619b627297d042d52fd87c915d31284e7df907 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 20:34:27 -0700 Subject: [PATCH 62/67] fix(ci): repair staging promotion workflow behavior (#1091) * fix(ci): repair staging-ci workflow parsing * fix(ci): chain staging promotion to latest open branch * feat(ci): carry staging batch summaries into release PRs * test(ci): add dry-run dispatch for promotion metadata workflows * fix(ci): fetch only release tags for batch summaries * fix(ci): address review feedback on batch summaries * fix(ci): harden metadata workflows and dedupe body helpers * fix(ci): pass repo explicitly to gh pr list --- .github/scripts/pr-body-utils.sh | 59 ++++++ .github/scripts/update-release-plz-body.sh | 101 +++++++++++ .../scripts/update-staging-promotion-body.sh | 53 ++++++ .../workflows/release-plz-batch-summary.yml | 44 +++++ .github/workflows/release-plz.yml | 8 +- .github/workflows/staging-ci.yml | 169 ++++++++++-------- .../workflows/staging-promotion-metadata.yml | 76 ++++++++ 7 files changed, 431 insertions(+), 79 deletions(-) create mode 100644 .github/scripts/pr-body-utils.sh create mode 100644 .github/scripts/update-release-plz-body.sh create mode 100644 .github/scripts/update-staging-promotion-body.sh create mode 100644 .github/workflows/release-plz-batch-summary.yml create mode 100644 .github/workflows/staging-promotion-metadata.yml diff --git a/.github/scripts/pr-body-utils.sh b/.github/scripts/pr-body-utils.sh new file mode 100644 index 00000000..f41f769f --- /dev/null +++ b/.github/scripts/pr-body-utils.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +load_commit_summary() { + local range="$1" + local max_commits="${2:-50}" + local commit_list overflow + + commit_list="$(git log --oneline --no-merges --reverse "${range}" 2>/dev/null || echo "")" + if [ -n "${commit_list}" ]; then + COMMIT_COUNT="$(printf '%s\n' "${commit_list}" | wc -l | tr -d ' ')" + if [ "${COMMIT_COUNT}" -gt "${max_commits}" ]; then + COMMIT_MD="$(printf '%s\n' "${commit_list}" | head -n "${max_commits}" | sed 's/^/- /')" + overflow=$((COMMIT_COUNT - max_commits)) + COMMIT_MD+=$'\n'"- ... and ${overflow} more (see compare view)" + else + COMMIT_MD="$(printf '%s\n' "${commit_list}" | sed 's/^/- /')" + fi + else + COMMIT_COUNT=0 + COMMIT_MD="- (no non-merge commits in range)" + fi +} + +replace_marked_section() { + local body_file="$1" + local section_file="$2" + local section_start="$3" + local section_end="$4" + local output_file="$5" + + if grep -qF "${section_start}" "${body_file}" && grep -qF "${section_end}" "${body_file}"; then + awk -v start="${section_start}" -v end="${section_end}" -v replacement_file="${section_file}" ' + BEGIN { + while ((getline line < replacement_file) > 0) { + replacement = replacement line ORS + } + in_block = 0 + } + $0 == start { + printf "%s", replacement + in_block = 1 + next + } + $0 == end { + in_block = 0 + next + } + !in_block { + print + } + ' "${body_file}" > "${output_file}" + else + cp "${body_file}" "${output_file}" + if [ -s "${output_file}" ]; then + printf '\n\n' >> "${output_file}" + fi + cat "${section_file}" >> "${output_file}" + fi +} diff --git a/.github/scripts/update-release-plz-body.sh b/.github/scripts/update-release-plz-body.sh new file mode 100644 index 00000000..3a7eef20 --- /dev/null +++ b/.github/scripts/update-release-plz-body.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${REPO:?REPO is required}" + +MAIN_BRANCH="${MAIN_BRANCH:-main}" +DRY_RUN="${DRY_RUN:-false}" +SECTION_START="" +SECTION_END="" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +# shellcheck source=.github/scripts/pr-body-utils.sh +source "$(dirname "$0")/pr-body-utils.sh" + +gh pr view "${PR_NUMBER}" --repo "${REPO}" --json body > "${TMP_DIR}/pr.json" +jq -r '.body // ""' < "${TMP_DIR}/pr.json" > "${TMP_DIR}/body.md" + +git fetch origin "${MAIN_BRANCH}" +git fetch origin "+refs/tags/v*:refs/tags/v*" + +LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 "origin/${MAIN_BRANCH}" 2>/dev/null || true)" +if [ -n "${LAST_TAG}" ]; then + RANGE="${LAST_TAG}..origin/${MAIN_BRANCH}" + HEADER="## Staging promotion batches since ${LAST_TAG}" + EMPTY_MESSAGE="_No structured staging promotion merges found since ${LAST_TAG}._" +else + RANGE="origin/${MAIN_BRANCH}" + HEADER="## Staging promotion batches on ${MAIN_BRANCH}" + EMPTY_MESSAGE="_No structured staging promotion merges found on ${MAIN_BRANCH}._" +fi + +{ + echo "${SECTION_START}" + echo "${HEADER}" + echo +} > "${TMP_DIR}/section.md" + +FOUND_SUMMARY=false +while IFS= read -r sha; do + [ -n "${sha}" ] || continue + BODY="$(git show -s --format=%b "${sha}")" + if ! printf '%s\n' "${BODY}" | grep -q '^staging-promotion-summary-v1$'; then + continue + fi + + FOUND_SUMMARY=true + SUBJECT="$(git show -s --format=%s "${sha}")" + PR_REF="$(printf '%s\n' "${BODY}" | sed -n 's/^promotion-pr: //p' | head -n 1)" + COMMIT_COUNT="$(printf '%s\n' "${BODY}" | sed -n 's/^current-commit-count: //p' | head -n 1)" + CURRENT_RANGE="$(printf '%s\n' "${BODY}" | sed -n 's/^current-range: //p' | head -n 1)" + COMMIT_BLOCK="$(printf '%s\n' "${BODY}" | awk 'capture { print } /^Current commits in this promotion \([0-9]+\):$/ { capture = 1 }')" + + { + echo "### ${SUBJECT}" + echo + if [ -n "${PR_REF}" ]; then + echo "**Promotion PR:** ${PR_REF}" + fi + if [ -n "${COMMIT_COUNT}" ]; then + echo "**Commit count:** ${COMMIT_COUNT}" + fi + if [ -n "${CURRENT_RANGE}" ]; then + echo "**Range:** \`${CURRENT_RANGE}\`" + fi + echo + if [ -n "${COMMIT_BLOCK}" ]; then + echo "${COMMIT_BLOCK}" + else + echo "- (no commit summary found)" + fi + echo + } >> "${TMP_DIR}/section.md" +done < <(git log --merges --reverse --format='%H' "${RANGE}") + +if [ "${FOUND_SUMMARY}" = false ]; then + { + echo "${EMPTY_MESSAGE}" + echo + } >> "${TMP_DIR}/section.md" +fi + +{ + echo "*Auto-updated from structured staging promotion merge bodies on ${MAIN_BRANCH}.*" + echo "${SECTION_END}" +} >> "${TMP_DIR}/section.md" + +replace_marked_section \ + "${TMP_DIR}/body.md" \ + "${TMP_DIR}/section.md" \ + "${SECTION_START}" \ + "${SECTION_END}" \ + "${TMP_DIR}/new-body.md" + +if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run enabled. Computed PR body for #${PR_NUMBER}:" + cat "${TMP_DIR}/new-body.md" +else + gh pr edit "${PR_NUMBER}" --repo "${REPO}" --body-file "${TMP_DIR}/new-body.md" +fi diff --git a/.github/scripts/update-staging-promotion-body.sh b/.github/scripts/update-staging-promotion-body.sh new file mode 100644 index 00000000..9686b58c --- /dev/null +++ b/.github/scripts/update-staging-promotion-body.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${REPO:?REPO is required}" + +MAX_COMMITS="${MAX_COMMITS:-50}" +DRY_RUN="${DRY_RUN:-false}" +SECTION_START="" +SECTION_END="" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +# shellcheck source=.github/scripts/pr-body-utils.sh +source "$(dirname "$0")/pr-body-utils.sh" + +gh pr view "${PR_NUMBER}" --repo "${REPO}" --json body,baseRefName,headRefName > "${TMP_DIR}/pr.json" +jq -r '.body // ""' < "${TMP_DIR}/pr.json" > "${TMP_DIR}/body.md" +BASE="$(jq -r '.baseRefName' < "${TMP_DIR}/pr.json")" +HEAD="$(jq -r '.headRefName' < "${TMP_DIR}/pr.json")" +RANGE="origin/${BASE}..origin/${HEAD}" + +git fetch origin "${BASE}" "${HEAD}" + +load_commit_summary "${RANGE}" "${MAX_COMMITS}" + +{ + echo "${SECTION_START}" + echo "### Current commits in this promotion (${COMMIT_COUNT})" + echo + echo "**Current base:** \`${BASE}\`" + echo "**Current head:** \`${HEAD}\`" + echo "**Current range:** \`${RANGE}\`" + echo + echo "${COMMIT_MD}" + echo + echo "*Auto-updated by staging promotion metadata workflow*" + echo "${SECTION_END}" +} > "${TMP_DIR}/section.md" + +replace_marked_section \ + "${TMP_DIR}/body.md" \ + "${TMP_DIR}/section.md" \ + "${SECTION_START}" \ + "${SECTION_END}" \ + "${TMP_DIR}/new-body.md" + +if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run enabled. Computed PR body for #${PR_NUMBER}:" + cat "${TMP_DIR}/new-body.md" +else + gh pr edit "${PR_NUMBER}" --repo "${REPO}" --body-file "${TMP_DIR}/new-body.md" +fi diff --git a/.github/workflows/release-plz-batch-summary.yml b/.github/workflows/release-plz-batch-summary.yml new file mode 100644 index 00000000..0e106736 --- /dev/null +++ b/.github/workflows/release-plz-batch-summary.yml @@ -0,0 +1,44 @@ +name: Release-plz Batch Summary + +on: + workflow_dispatch: + inputs: + pr_number: + description: "release-plz PR number to refresh" + required: true + type: string + dry_run: + description: "Compute the body update without editing the PR" + required: false + type: boolean + default: true + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + update-release-pr: + if: > + (github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release-plz-')) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }} + fetch-depth: 0 + fetch-tags: true + + - name: Update release-plz PR body with staging batch summary + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} + REPO: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + run: bash .github/scripts/update-release-plz-body.sh diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 142b2b20..d1be9004 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -58,10 +58,16 @@ jobs: - *checkout - *install-rust - uses: Swatinem/rust-cache@v2 + - name: Generate GitHub token + uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} + private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz uses: release-plz/action@v0.5 with: command: release-pr env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index 4229f108..2df7bf6f 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -25,9 +25,35 @@ concurrency: cancel-in-progress: false # Let running suites finish jobs: + # ── Resolve promotion base branch ─────────────────────────────── + resolve-promotion-base: + name: Resolve promotion base + runs-on: ubuntu-latest + outputs: + promotion_base: ${{ steps.resolve.outputs.promotion_base }} + steps: + - name: Resolve promotion base + id: resolve + env: + GH_TOKEN: ${{ github.token }} + FALLBACK_BRANCH: main + REPO: ${{ github.repository }} + run: | + LATEST=$(gh pr list --repo "${REPO}" --label staging-promotion --state open \ + --json headRefName,createdAt \ + --jq '[.[] | select(.headRefName | startswith("staging-promote/"))] | sort_by(.createdAt) | last | .headRefName // empty') + if [ -n "$LATEST" ]; then + echo "promotion_base=${LATEST}" >> "$GITHUB_OUTPUT" + echo "Using open promotion branch as base: ${LATEST}" + else + echo "promotion_base=${FALLBACK_BRANCH}" >> "$GITHUB_OUTPUT" + echo "No open promotion branch found. Using ${FALLBACK_BRANCH}." + fi + # ── Check for new commits ────────────────────────────────────── check-changes: name: Check for new commits + needs: resolve-promotion-base runs-on: ubuntu-latest outputs: has_changes: ${{ steps.check.outputs.has_changes }} @@ -44,7 +70,7 @@ jobs: id: check env: FORCE_RUN: ${{ inputs.force }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }} run: | CURRENT_HEAD=$(git rev-parse HEAD) echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT" @@ -66,9 +92,9 @@ jobs: echo "Found ${COMMIT_COUNT} new commit(s) since last tested" DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}" else - git fetch origin "${DEFAULT_BRANCH}" - MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD) - echo "First run -- reviewing from merge-base ${MERGE_BASE}" + git fetch origin "${PROMOTION_BASE}" + MERGE_BASE=$(git merge-base "origin/${PROMOTION_BASE}" HEAD) + echo "First run -- reviewing from merge-base ${MERGE_BASE} against ${PROMOTION_BASE}" DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}" fi fi @@ -102,13 +128,12 @@ jobs: # ── Create promotion PR (triggers claude-review.yml on the PR) ── create-promotion-pr: name: Create Promotion PR - needs: check-changes + needs: [resolve-promotion-base, check-changes] if: needs.check-changes.outputs.has_changes == 'true' runs-on: ubuntu-latest outputs: pr_number: ${{ steps.create-pr.outputs.pr_number }} promotion_branch: ${{ steps.branch.outputs.branch }} - commit_summary: ${{ steps.create-pr.outputs.commit_summary }} steps: - uses: actions/checkout@v6 with: @@ -135,15 +160,15 @@ jobs: id: ahead-check env: GH_TOKEN: ${{ steps.token.outputs.token }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }} run: | - git fetch origin "${DEFAULT_BRANCH}" - AHEAD=$(git rev-list --count "origin/${DEFAULT_BRANCH}..origin/staging") + git fetch origin "${PROMOTION_BASE}" + AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..origin/staging") echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT" if [ "$AHEAD" -eq 0 ]; then - echo "Staging is not ahead of ${DEFAULT_BRANCH}. Nothing to promote." + echo "Staging is not ahead of ${PROMOTION_BASE}. Nothing to promote." else - echo "Staging is ${AHEAD} commits ahead of ${DEFAULT_BRANCH}." + echo "Staging is ${AHEAD} commits ahead of ${PROMOTION_BASE}." fi - name: Create promotion branch @@ -157,51 +182,20 @@ jobs: echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" echo "Created promotion branch: ${BRANCH}" - - name: Find base branch - id: find-base - if: steps.ahead-check.outputs.commits_ahead != '0' - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - # Find the newest open promotion PR with a staging-promote/* head branch - LATEST=$(gh pr list --label staging-promotion --state open \ - --json headRefName,createdAt \ - --jq '[.[] | select(.headRefName | startswith("staging-promote/"))] | sort_by(.createdAt) | last | .headRefName // empty') - if [ -n "$LATEST" ]; then - echo "base=${LATEST}" >> "$GITHUB_OUTPUT" - echo "Chaining onto existing promotion branch: ${LATEST}" - else - echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT" - echo "No existing promotion PR — targeting ${DEFAULT_BRANCH}" - fi - - name: Create promotion PR id: create-pr if: steps.ahead-check.outputs.commits_ahead != '0' env: GH_TOKEN: ${{ steps.token.outputs.token }} run: | + source .github/scripts/pr-body-utils.sh RANGE="${{ needs.check-changes.outputs.diff_range }}" TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC") BRANCH="${{ steps.branch.outputs.branch }}" - BASE="${{ steps.find-base.outputs.base }}" + BASE="${{ needs.resolve-promotion-base.outputs.promotion_base }}" - # Enumerate commits in this batch (exclude merge commits, cap at 50) MAX_COMMITS=50 - COMMIT_LIST=$(git log --oneline --no-merges --reverse "${RANGE}" 2>/dev/null || echo "") - if [ -n "$COMMIT_LIST" ]; then - COMMIT_COUNT=$(echo "$COMMIT_LIST" | wc -l | tr -d ' ') - if [ "$COMMIT_COUNT" -gt "$MAX_COMMITS" ]; then - COMMIT_MD=$(echo "$COMMIT_LIST" | head -n "$MAX_COMMITS" | sed 's/^/- /') - COMMIT_MD+=$'\n'"- ... and $((COMMIT_COUNT - MAX_COMMITS)) more (see compare view)" - else - COMMIT_MD=$(echo "$COMMIT_LIST" | sed 's/^/- /') - fi - else - COMMIT_COUNT=0 - COMMIT_MD="- (no non-merge commits in range)" - fi + load_commit_summary "${RANGE}" "${MAX_COMMITS}" # Build PR body via concatenation to avoid heredoc shell expansion # (commit messages in COMMIT_MD may contain $, backticks, or backslashes) @@ -212,6 +206,17 @@ jobs: PR_BODY+=$'\n'"**Triggered by:** Staging CI batch at ${TIMESTAMP}" PR_BODY+=$'\n\n'"### Commits in this batch (${COMMIT_COUNT}):" PR_BODY+=$'\n'"${COMMIT_MD}" + PR_BODY+=$'\n\n'"" + PR_BODY+=$'\n'"### Current commits in this promotion (${COMMIT_COUNT})" + PR_BODY+=$'\n' + PR_BODY+=$'\n'"**Current base:** \`${BASE}\`" + PR_BODY+=$'\n'"**Current head:** \`${BRANCH}\`" + PR_BODY+=$'\n'"**Current range:** \`origin/${BASE}..origin/${BRANCH}\`" + PR_BODY+=$'\n' + PR_BODY+=$'\n'"${COMMIT_MD}" + PR_BODY+=$'\n' + PR_BODY+=$'\n'"*Auto-updated by staging promotion metadata workflow*" + PR_BODY+=$'\n'"" PR_BODY+=$'\n\n'"Waiting for gates:" PR_BODY+=$'\n'"- Tests: pending" PR_BODY+=$'\n'"- E2E: pending" @@ -230,15 +235,6 @@ jobs: echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT" echo "Created promotion PR #${PR_NUM}" - # Output commit summary for use in merge commit message - DELIM="COMMIT_SUMMARY_EOF_$(date +%s)" - { - echo "commit_summary<<${DELIM}" - echo "Commits in this batch (${COMMIT_COUNT}):" - echo "${COMMIT_MD}" - echo "${DELIM}" - } >> "$GITHUB_OUTPUT" - # ── Gate: wait for review, process findings, merge or block ───── gate: name: Staging Gate @@ -257,7 +253,8 @@ jobs: - uses: actions/checkout@v6 with: ref: staging - fetch-depth: 1 + # Need full history to recompute the final promoted range before merge. + fetch-depth: 0 - name: Generate GitHub App token id: app-token @@ -356,8 +353,10 @@ jobs: # Use process substitution so variables propagate to parent shell while read -r line; do TAG=$(echo "$line" | grep -oE '^\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]') - SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/') - CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/') + SEVERITY="${TAG#\[}" + SEVERITY="${SEVERITY%%:*}" + CONFIDENCE="${TAG##*:}" + CONFIDENCE="${CONFIDENCE%\]}" DESC=$(echo "$line" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -1) echo "Found: [${SEVERITY}:${CONFIDENCE}] ${DESC}" @@ -448,18 +447,30 @@ jobs: env: GH_TOKEN: ${{ steps.token.outputs.token }} PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }} - COMMIT_SUMMARY: ${{ needs.create-promotion-pr.outputs.commit_summary }} run: | + source .github/scripts/pr-body-utils.sh if [ -n "$PR_NUMBER" ]; then BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName') if [ "$BASE" = "main" ]; then echo "Merging promotion PR #${PR_NUMBER} (targets main)" TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title') - if [ -n "$COMMIT_SUMMARY" ]; then - gh pr merge "$PR_NUMBER" --merge --subject "#${PR_NUMBER} $TITLE" --body-file <(printf '%s' "$COMMIT_SUMMARY") - else - gh pr merge "$PR_NUMBER" --merge - fi + HEAD_BRANCH=$(gh pr view "$PR_NUMBER" --json headRefName --jq '.headRefName') + git fetch origin "${BASE}" "${HEAD_BRANCH}" + CURRENT_RANGE="origin/${BASE}..origin/${HEAD_BRANCH}" + MAX_COMMITS=50 + load_commit_summary "${CURRENT_RANGE}" "${MAX_COMMITS}" + { + echo "staging-promotion-summary-v1" + echo "promotion-pr: #${PR_NUMBER}" + echo "base: ${BASE}" + echo "head: ${HEAD_BRANCH}" + echo "current-range: ${CURRENT_RANGE}" + echo "current-commit-count: ${COMMIT_COUNT}" + echo "" + echo "Current commits in this promotion (${COMMIT_COUNT}):" + echo "${COMMIT_MD}" + } > /tmp/staging-promotion-merge-body.md + gh pr merge "$PR_NUMBER" --merge --subject "#${PR_NUMBER} $TITLE" --body-file /tmp/staging-promotion-merge-body.md echo "merged=true" >> "$GITHUB_OUTPUT" else echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution" @@ -499,18 +510,20 @@ jobs: steps: - name: Summary run: | - echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY" - echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY" - PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}" - if [ -n "$PR_NUM" ]; then - echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY" - fi + { + echo "## Staging CI Batch Results" + echo "" + echo "| Check | Result |" + echo "|-------|--------|" + echo "| Tests | ${{ needs.tests.result }} |" + echo "| E2E | ${{ needs.e2e.result }} |" + echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" + echo "| Gate | ${{ needs.gate.result }} |" + echo "| Tag Updated | ${{ needs.update-tag.result }} |" + echo "" + echo "Range: ${{ needs.check-changes.outputs.diff_range }}" + PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}" + if [ -n "$PR_NUM" ]; then + echo "Promotion PR: #${PR_NUM}" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/staging-promotion-metadata.yml b/.github/workflows/staging-promotion-metadata.yml new file mode 100644 index 00000000..63591d83 --- /dev/null +++ b/.github/workflows/staging-promotion-metadata.yml @@ -0,0 +1,76 @@ +name: Staging Promotion Metadata + +on: + workflow_dispatch: + inputs: + pr_number: + description: "Staging promotion PR number to refresh" + required: true + type: string + dry_run: + description: "Compute the body update without editing the PR" + required: false + type: boolean + default: true + pull_request_target: + types: [opened, synchronize, reopened] + push: + branches: + - main + +permissions: + contents: read + pull-requests: write + +jobs: + refresh-single-pr: + if: > + (github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'staging-promote/')) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }} + fetch-depth: 0 + fetch-tags: true + + - name: Refresh staging promotion PR body + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} + REPO: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + run: bash .github/scripts/update-staging-promotion-body.sh + + refresh-open-prs-after-main-push: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Refresh all open staging promotion PR bodies + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + # ubuntu-latest uses bash 5.x, so mapfile is available here. + mapfile -t prs < <(gh pr list --repo "${REPO}" --label staging-promotion --state open \ + --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("staging-promote/")) | .number') + if [ "${#prs[@]}" -eq 0 ]; then + echo "No open staging promotion PRs to refresh." + exit 0 + fi + for pr in "${prs[@]}"; do + echo "Refreshing staging promotion PR #${pr}" + PR_NUMBER="${pr}" bash .github/scripts/update-staging-promotion-body.sh + done From a89cf379938b1fdc58a6ecb11233f5ae90e786eb Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Thu, 12 Mar 2026 21:02:00 -0700 Subject: [PATCH 63/67] fix(registry): bump telegram channel version for capabilities change (#1064) The validation_endpoint addition to telegram.capabilities.json requires a version bump to pass the CI version-check gate on staging promotion. Co-authored-by: Claude Opus 4.6 --- registry/channels/telegram.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index 74336e41..9a4d8918 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -2,7 +2,7 @@ "name": "telegram", "display_name": "Telegram Channel", "kind": "channel", - "version": "0.2.2", + "version": "0.2.3", "wit_version": "0.3.0", "description": "Talk to your agent through a Telegram bot", "keywords": [ @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.2-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.3-wasm32-wasip2.tar.gz", "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" } }, From c47237b9c7c89d2570b4b788dba7e3c5ee70a59b Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 21:13:36 -0700 Subject: [PATCH 64/67] fix(ci): add missing attachments field and crates/ dir to Dockerfiles (#1100) The discord channel's poll_channel_mentions emit_message call was missing the required `attachments: vec![]` field, causing WASM compilation failure. Both Dockerfiles were also missing `COPY crates/ crates/` needed for the extracted ironclaw_safety crate. [skip-regression-check] Co-authored-by: Claude Opus 4.6 --- Dockerfile | 1 + Dockerfile.test | 1 + channels-src/discord/Cargo.lock | 2 +- channels-src/discord/src/lib.rs | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0375e509..08a0b721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ WORKDIR /app # Copy manifests first for layer caching COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ # Copy source, build script, tests, and supporting directories COPY build.rs build.rs diff --git a/Dockerfile.test b/Dockerfile.test index 202bd04d..6ec502ba 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -20,6 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ COPY build.rs build.rs COPY src/ src/ COPY tests/ tests/ diff --git a/channels-src/discord/Cargo.lock b/channels-src/discord/Cargo.lock index 9fee443c..f25ce551 100644 --- a/channels-src/discord/Cargo.lock +++ b/channels-src/discord/Cargo.lock @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "discord-channel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ed25519-dalek", "hex", diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index acb0bb41..cdb6c515 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -642,6 +642,7 @@ fn poll_channel_mentions(channel_id: &str, bot_id: &str) { }, thread_id: None, metadata_json, + attachments: vec![], }); remember_processed_id(&mut recent_ids, &msg.id); From 5e7758598fb858dc48bc08cdb57da674a31b4339 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 21:32:19 -0700 Subject: [PATCH 65/67] chore: periodic sync main into staging (resolved conflicts) (#1098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: promote staging to main (2026-03-10 15:19 UTC) (#865) * fix: Channel HTTP: server doesn't start after config change (no hot-r… (#779) * fix: Channel HTTP: server doesn't start after config change (no hot-reload) * review fixes * review fixes * fix linter * fix code style * fix: prevent session lock contention blocking message processing (#783) * fix: prevent session lock contention blocking message processing ## Problem After container restart, POST /api/chat/send returns 202 ACCEPTED but messages don't appear in conversation_messages and agent never responds. Messages get stuck in "stale state" after restart. Root cause: Session lock was held for entire duration of chat_threads_handler and chat_history_handler, including during slow database queries. This blocked the agent loop from acquiring the session lock to process incoming messages, causing them to hang indefinitely. ## Solution 1. **Release session lock early in chat_threads_handler**: Only acquire lock when reading active_thread at response time, not during DB queries for thread list. DB operations no longer block message processing. 2. **Release session lock early in chat_history_handler**: Only acquire lock when accessing in-memory thread state, not during paginated DB queries or thread ownership checks. DB operations no longer block message processing. 3. **Add comprehensive logging**: Track message flow from receipt through session resolution, thread hydration, and state transitions. Helps diagnose future issues: - Message queued to agent loop (chat_send_handler) - Processing message from channel (handle_message) - Hydrating thread from DB (maybe_hydrate_thread) - Resolving session and thread (resolve_thread) - Checking thread state (process_user_input) - Persisting user message (persist_user_message) ## Impact - Message processing no longer blocks on session lock contention - API response times for thread list/history queries unaffected (DB queries still happen, but lock is not held) - Better diagnostics for future debugging ## Testing - All 2756 tests pass - Code compiles with zero clippy warnings - No changes to user-facing API or behavior, only lock timing Co-Authored-By: Claude Haiku 4.5 * security: redact PII from info-level logs Downgrade user_id and channel logging to debug level to prevent exposing Personally Identifiable Information (PII) in production logs. The user_id field can contain sensitive information such as phone numbers (e.g., for Signal messages). Logging PII in cleartext at the info level creates a security and privacy risk, as these logs may be stored in persistent storage, indexed by log management systems, or accessible to unauthorized personnel. Changes: - Info level: logs only message_id (UUID) for tracking - Debug level: logs user_id, channel, thread_id for troubleshooting This maintains debugging capability for developers while protecting user privacy in production logs. Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude Haiku 4.5 * chore: sync main into staging (#855) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809) Add declarative `unsupported_params` field to provider definitions in providers.json. Parameters listed are stripped from requests before sending, preventing 400 errors from providers that reject them (e.g. gpt-5 family and kimi-k2.5 rejecting custom temperature values). - Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig - Propagate from registry through config resolution - Generic strip helpers handle temperature, max_tokens, stop_sequences - Apply filtering in RigAdapter and AnthropicOAuthProvider - Mark openai and tinfoil providers as unsupporting temperature - Update openai default model to gpt-5-mini Co-authored-by: Claude Opus 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Illia Polosukhin * fix: Chat input is hidden in mobile browser mode (#877) * fix: stop XML-escaping tool output content (#598) (#874) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809) Add declarative `unsupported_params` field to provider definitions in providers.json. Parameters listed are stripped from requests before sending, preventing 400 errors from providers that reject them (e.g. gpt-5 family and kimi-k2.5 rejecting custom temperature values). - Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig - Propagate from registry through config resolution - Generic strip helpers handle temperature, max_tokens, stop_sequences - Apply filtering in RigAdapter and AnthropicOAuthProvider - Mark openai and tinfoil providers as unsupporting temperature - Update openai default model to gpt-5-mini Co-authored-by: Claude Opus 4.6 * fix: stop XML-escaping tool output content in wrap_for_llm (#598) Remove content escaping that corrupted JSON in tool output. The structural boundary is preserved but content now passes through raw, fixing downstream parse failures. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 * fix(safety): allow empty string tool params (#848) * fix(safety): allow empty string tool params * fix(safety): preserve heuristic checks and add path context to tool validation This follow-up refactor addresses PR review feedback by restoring heuristic checks (whitespace ratio, character repetition) for tool parameter validation and improving error reporting. Changes: - Restored heuristic warnings in validate_non_empty_input so they apply to both user input and tool parameters (when non-empty). - Refactored check_strings to recursively build and pass JSON paths (e.g., "metadata.tags[1]"). - Updated validation errors to use the specific JSON path as the field name instead of the generic "input". - Added regression tests for whitespace/repetition warnings and JSON path reporting in tool parameters. This ensures the safety layer remains semantically neutral about empty strings (fixing the memory_tree path: "" issue) while maintaining rigorous protection and providing better developer ergonomics. * style: run cargo fmt * perf: optimize release and dist build profiles (#843) * perf: optimize release and dist build profiles Add [profile.release] with strip=true and panic="abort" for smaller, faster release binaries. Upgrade [profile.dist] from lto="thin" to lto="fat" with codegen-units=1 for maximum optimization in CI releases. Co-Authored-By: Claude Opus 4.6 * fix: remove panic=abort from release profile Reviewers (zmanian, Copilot, Gemini) correctly flagged that panic=abort in the release profile would kill the entire process on any tokio task panic, breaking fault isolation for the long-running server. Removed from release profile entirely. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat: add PR template with risk assessment (#837) * feat: add PR template with risk assessment and review tracks Add a pull request template that includes summary, change type, validation checklist, security/database impact sections, blast radius, and rollback plan. Update CONTRIBUTING.md with review track definitions (A/B/C) based on change risk level. Co-Authored-By: Claude Opus 4.6 * fix: expand CONTRIBUTING.md with setup, workflow, and guidelines Add getting started, development workflow, code style summary, database change guidance, and dependency management sections. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat: add fuzzing targets for untrusted input parsers (#835) * feat: add fuzzing targets for untrusted input parsers Add cargo-fuzz infrastructure with 5 fuzz targets exercising security-critical code paths: - fuzz_safety_sanitizer: Aho-Corasick + regex injection detection - fuzz_safety_validator: Input validation (length, encoding, patterns) - fuzz_leak_detector: Secret leak scanning (API keys, tokens) - fuzz_tool_params: Tool parameter JSON validation - fuzz_config_env: TOML/JSON config parsing Each target exercises real IronClaw business logic with invariant assertions. Includes corpus directories and setup documentation. Co-Authored-By: Claude Opus 4.6 * fix: improve fuzz targets to exercise real IronClaw code paths - fuzz_config_env: exercise SafetyLayer end-to-end (sanitize, validate, policy check) instead of generic TOML/JSON parsing - fuzz_tool_params: add validate_tool_schema coverage alongside validate_tool_params - Add "fuzz" to workspace exclude in root Cargo.toml - Update README descriptions to match actual target behavior [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: replace redundant detect() call with meaningful invariant assertion Replace the double sanitize()+detect() call with an assertion that critical severity warnings always trigger content modification. Co-Authored-By: Claude Opus 4.6 * fix: rewrite fuzz_config_env to exercise IronClaw safety code directly Replace SafetyLayer wrapper usage with direct Sanitizer, Validator, and LeakDetector instantiation and invocation. Adds meaningful consistency assertions (non-empty output, valid-means-no-errors, scan/clean agreement). Removes the config construction that was only exercising struct instantiation. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix(wasm): run leak scan before credential injection in tools wrapper (#791) * fix(wasm): run leak scan before credential injection in tools wrapper The tools WASM wrapper runs the LeakDetector on HTTP request headers AFTER inject_host_credentials() has already substituted real secrets (e.g., xoxb- Slack bot tokens). This causes the leak detector to flag the tool's own legitimate outbound API calls as secret exfiltration. Move the scan to run on raw_headers before any credential injection, matching the fix already applied to the channels wrapper in #421. Fixes the same class of bug as #421 (which only fixed channels/wasm/wrapper.rs). Co-Authored-By: Claude Opus 4.6 * perf: inline leak scan to avoid Vec allocation on every HTTP request Address review feedback: instead of cloning all header keys/values into a Vec to pass to scan_http_request(), iterate over raw_headers directly using scan_and_clean(). This also provides more specific error messages (URL vs header vs body). Co-Authored-By: Claude Opus 4.6 * style: fix cargo fmt formatting in leak scan loop Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix(setup): drain residual terminal events before secret input (#747) (#849) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809) Add declarative `unsupported_params` field to provider definitions in providers.json. Parameters listed are stripped from requests before sending, preventing 400 errors from providers that reject them (e.g. gpt-5 family and kimi-k2.5 rejecting custom temperature values). - Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig - Propagate from registry through config resolution - Generic strip helpers handle temperature, max_tokens, stop_sequences - Apply filtering in RigAdapter and AnthropicOAuthProvider - Mark openai and tinfoil providers as unsupporting temperature - Update openai default model to gpt-5-mini Co-authored-by: Claude Opus 4.6 * fix: skip the regression check [skip-regression-check] --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Illia Polosukhin * feat(agent): add context size logging before LLM prompt (#810) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * feat(agent): add context size logging before LLM prompt --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Illia Polosukhin * fix: preserve text before tool-call XML in forced-text responses (#852) * fix: preserve text before tool-call XML in forced-text responses (#789) Local models (Qwen3, DeepSeek, GLM) emit XML even when no tools are available (force_text mode). The existing strip_xml_tag() discards everything from an unclosed opening tag onward, producing an empty string that triggers the "I'm not sure how to respond" fallback. Add truncate_at_tool_tags() — a code-region-aware pre-processing step that truncates at the first tool-call XML tag BEFORE clean_response() runs, preserving all useful text before the tag. Protect all 7 clean_response() call sites. Case-insensitive matching handles models that emit or variants. Secondary fix: add has_native_thinking() model detection to skip / system prompt injection for models with built-in reasoning (Qwen3, QwQ, DeepSeek-R1, GLM-Z1, etc.), preventing thinking-only responses that clean to empty. Wire with_model_name(active_model_name()) at all 9 production sites that construct Reasoning, so the runtime model name (not static config) drives system prompt generation. 126 new/updated tests covering truncation edge cases, code-block awareness, Unicode, case-insensitivity, StubLlm integration for complete/plan/evaluate_success/respond_with_tools paths, model detection, and conditional system prompt generation. Closes #789 Co-Authored-By: Claude Opus 4.6 * fix: address Copilot review — unclosed-only truncation, ASCII case folding - truncate_at_tool_tags() now only truncates at UNCLOSED tool tags; properly closed tags (e.g. ...) are left intact for clean_response() to strip normally, preserving any text after them - Switch from to_lowercase() to to_ascii_lowercase() to prevent byte offset misalignment with non-ASCII characters whose lowercase form has different byte length (e.g. Kelvin sign U+212A) - Add closing_tag_for() helper to derive closing tags from open patterns - Fix doc comment: "fenced markdown code blocks or inline code spans" (not "indented", which find_code_regions() doesn't detect) - Add regression tests: closed vs unclosed for each tag variant, Unicode + case-insensitive offset safety, and mixed closed/unclosed Co-Authored-By: Claude Opus 4.6 * fix: minor review items — consistent ascii_lowercase, closing_tag_for tests - Switch has_native_thinking() from to_lowercase() to to_ascii_lowercase() for consistency with truncate_at_tool_tags() approach - Add unit tests for closing_tag_for(): standard tags, space-suffixed patterns, pipe-delimited tags, and exhaustive coverage of all TOOL_TAG_PATTERNS entries - Add test for mixed closed+unclosed tags of different types Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Feat/docker shell edition (#804) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 * fix(mcp): strip top-level null params before forwarding to MCP servers (#795) * feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809) Add declarative `unsupported_params` field to provider definitions in providers.json. Parameters listed are stripped from requests before sending, preventing 400 errors from providers that reject them (e.g. gpt-5 family and kimi-k2.5 rejecting custom temperature values). - Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig - Propagate from registry through config resolution - Generic strip helpers handle temperature, max_tokens, stop_sequences - Apply filtering in RigAdapter and AnthropicOAuthProvider - Mark openai and tinfoil providers as unsupporting temperature - Update openai default model to gpt-5-mini Co-authored-by: Claude Opus 4.6 * fix(mcp): strip top-level null params before forwarding to MCP servers LLMs frequently emit `"field": null` for optional parameters in tool calls. Many MCP servers reject explicit nulls for fields that should simply be absent — e.g. Notion returns 400 for `"sort": null` in a search call, expecting the field to be omitted entirely. Strip top-level null keys from the params object before calling `call_tool()`. Only top-level keys are stripped; nested nulls are preserved since they may be semantically meaningful. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Illia Polosukhin Co-authored-by: Claude Opus 4.6 * Add event-triggered routines and workflow skill templates (#756) * Add event-triggered routines and workflow skill templates * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix: address PR review feedback for event_emit security and quality Security fixes: - Require approval (UnlessAutoApproved) for event_emit, matching routine_fire - Enable sanitization on event_emit payload (external JSON reaches LLM) - Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id Correctness fixes: - Rename source → event_source in event_emit for consistency with routine_create - Use json_value_as_filter_string for filter parsing (handles numbers/booleans) - Case-insensitive matching for event source and event_type - Add debug logging for missing filter keys in payload - Fix skill_install_routine_webhook_sim test missing .with_skills() - Fix schema_validator test for event_emit payload properties Code quality: - Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout) - Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs - Add test section headers in e2e_routine_heartbeat.rs - Clarify event_emit description to specify system_event routines only Co-Authored-By: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix: make routine_system_event_emit test create routine before emitting - Add routine_create step to trace fixture so event_emit has a matching routine to fire - Assert fired_routines > 0, not just key presence (Copilot review) - Add .with_auto_approve_tools(true) since event_emit now requires approval Co-Authored-By: Claude Opus 4.6 * fix: renumber test headers after system_event test insertion Test 4 was duplicated (routine_cooldown and heartbeat_findings). Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: merge staging and add missing RoutineEngine args in test RoutineEngine::new on staging requires `tools` and `safety` params. Update system_event_trigger_matches_and_filters test to pass them. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address new Copilot review comments - Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim test so event_emit doesn't block on approval - Fix module-level doc comment for event_emit to specify system_event trigger [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: deduplicate json_value_as_string helper Remove private `json_value_as_string` from routine_engine.rs and use the identical public `json_value_as_filter_string` from routine.rs, eliminating divergence risk. (Copilot review) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 * fix: enable WASM credential injection in No-DB environments (#845) * fix(wasm): enable credential injection in no-DB environments via env var fallback When a secrets store is unavailable (e.g. no-DB mode), WASM channel credentials were silently not injected, causing channels to start without credentials. Fix by: - Changing `inject_channel_credentials_from_secrets` to accept `Option<&dyn SecretsStore>` — secrets store is tried first when present - Adding env var fallback (`inject_env_credentials`) for credentials not covered by the secrets store - Enforcing a channel-name prefix security check on env var names to prevent WASM channels from reading unrelated host credentials (e.g. `AWS_SECRET_ACCESS_KEY`) - Extracting pure `resolve_env_credentials` helper for testability - Adding case-insensitive prefix matching for secrets store lookup Co-Authored-By: Claude Sonnet 4.6 * fix(wasm): inject credentials at startup when no secrets store (setup.rs path) The startup path (setup_wasm_channels -> register_channel) was guarded by `if let Some(secrets) = secrets_store`, so in No-DB mode credentials were never injected and the channel started without them. Fix by: - Changing inject_channel_credentials to accept Option<&dyn SecretsStore> - Always calling it (removing the if-let guard) — env var fallback runs even when secrets_store is None - Adding channel-name prefix security check to the env var fallback path (e.g. TELEGRAM_ for channel "telegram"), consistent with manager.rs Co-Authored-By: Claude Sonnet 4.6 * fix(test): correct misleading comment on ICTEST1_UNRELATED_OTHER placeholder * fix(wasm): guard against empty channel name in credential injection An empty channel_name would produce prefix "_", allowing any env var starting with "_" to pass the security check and be injected. Add an early-return guard in resolve_env_credentials, inject_env_credentials, and inject_channel_credentials. Add a test to cover this path. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: lizican123 Co-authored-by: Claude Sonnet 4.6 * fix: promote to main (#878) * fix: replace unsafe env::set_var with thread-safe inject_single_var in SIGHUP handler Fixes race condition where SIGHUP handler modifies global environment variables while other threads may be reading them via Config::from_env(). Changes: - Replace unsafe { std::env::set_var() } with ironclaw::config::inject_single_var() - Uses INJECTED_VARS mutex instead of unsafe global state modification - All reads via optional_env() check the thread-safe overlay first - Prevents data races between SIGHUP reload and concurrent config reads Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * fix: spawn webhook restart as background task to avoid blocking I/O across lock Prevents holding Mutex lock during async I/O operations (TcpListener::bind, task shutdown). The SIGHUP handler no longer blocks webhook processing during listener restart. Changes: - Read old_addr and drop lock immediately - Spawn restart_with_addr() as background task via tokio::spawn - Lock is only held during the actual restart operation, not the signal handler Benefits: - SIGHUP handler returns immediately without blocking - Webhook requests not delayed by listener restart I/O - Lock contention significantly reduced Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * fix: add graceful shutdown mechanism for SIGHUP handler background task Prevents unbounded loop without cancellation token. The SIGHUP handler now listens for a shutdown signal and exits cleanly during graceful termination. Changes: - Create broadcast channel for shutdown signaling - SIGHUP handler uses tokio::select! to wait for shutdown or SIGHUP - Send shutdown signal to all background tasks after agent.run() completes - Ensures clean task lifecycle and no orphaned background tasks Benefits: - Proper task cancellation during graceful shutdown - Follows Tokio best practices for background task management - No background tasks orphaned when runtime shuts down Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * refactor: replace stringly-typed parameter filtering with typed enum and single helper Fixes DRY violation where unsupported parameter filtering was duplicated across rig_adapter.rs and anthropic_oauth.rs using string contains checks. Changes: - Add UnsupportedParam typed enum in provider.rs (Temperature, MaxTokens, StopSequences) - Create strip_unsupported_completion_params() helper function - Create strip_unsupported_tool_params() helper function - Update rig_adapter.rs to use shared helpers - Update anthropic_oauth.rs to use shared helpers - Replace 60+ lines of duplicate stringly-typed logic Benefits: - Type safety: parameter names checked at compile time - Single source of truth: adding a new param updates one place - Reduced maintenance burden: no duplicate logic to keep in sync - Better code clarity: named enum variant is self-documenting Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * docs: clarify intentional parameter asymmetry between completion and tool requests Add documentation explaining why strip_unsupported_tool_params does not handle StopSequences: the field doesn't exist in ToolCompletionRequest. Changes: - Add clarifying comments to strip_unsupported_tool_params() - Explain why StopSequences is only in CompletionRequest - Note that ToolCompletionRequest only supports Temperature and MaxTokens - Inline comment confirms no action needed for StopSequences This addresses the appearance of incomplete implementation without changing logic, as the asymmetry is intentional and correct (ToolCompletionRequest lacks the field). Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * perf: isolate webhook_secret to reduce lock contention on hot path Move webhook_secret from shared HttpChannelState RwLock into its own Arc>. This eliminates contention between secret validation and other state operations. Changes: - Change webhook_secret field type from RwLock> to Arc>> - Update initialization in HttpChannel::new() - Update comments to explain isolation rationale Benefits: - Reduce lock contention on webhook request hot path (secret validation) - Rarely-changing field (SIGHUP only) isolated from frequent state accesses - Other state operations (tx, pending_responses) no longer wait behind secret reads - Minimal code change: only field declaration and initialization The Arc wrapper allows cloning the RwLock handle to separate concerns. With this change, every webhook request acquires its own isolated lock for secret validation, not the shared HttpChannelState lock. This scales better under high request volume. Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * fix: prevent partial state corruption on SIGHUP restart failure Ensure atomicity of configuration reload: if webhook listener restart fails, secret update is skipped to prevent inconsistent state. Changes: - Wait for restart_with_addr() to complete (don't spawn background task) - Track restart result with restart_failed flag - Only update secret if restart succeeded or wasn't needed - Ensure listener and secret stay synchronized Problem addressed: - Before: restart spawned as background task, secret updated immediately - If restart failed, secret was changed but listener still on old address - This left system in inconsistent state (partial corruption) Solution: - Make restart blocking (SIGHUP handler can wait, it's not on request hot path) - Atomically update secret only after successful restart - Flag prevents race between restart and secret update Benefits: - Configuration changes are atomic (both succeed or both fail together) - No partial state corruption on restart failure - Failed restarts don't silently leave inconsistent state - Secret and listener address stay in sync Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * refactor: generalize hot-secret-swapping with ChannelSecretUpdater trait Decouple SIGHUP handler from HTTP channel internals by introducing a trait for channels that support zero-downtime secret updates. Changes: - Add ChannelSecretUpdater trait in channels/channel.rs - Implement ChannelSecretUpdater for HttpChannelState - Export trait from channels module - Update SIGHUP handler to use trait-based secret updater collection - Replace explicit HTTP channel knowledge with generic updater loop Benefits: - SIGHUP handler no longer depends on HttpChannelState details - Tight coupling removed: main.rs doesn't need HTTP channel imports - Extensible: new channels can opt-in by implementing the trait - Scalable: multiple channels supported without main.rs changes - Maintainable: adding channels requires only trait implementation, not SIGHUP handler edits Pattern: - ChannelSecretUpdater trait defines the interface for all updaters - Channels that support hot-secret-swapping implement the trait - SIGHUP handler loops through all registered updaters generically Verification: - All 2,787 tests pass - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 * feat: validate parameter names at deserialization time, not just tests Add custom serde deserializer for unsupported_params that validates parameter names at runtime when loading providers.json (or user overrides). Changes: - Add unsupported_params_de module with custom deserializer - Only allows: "temperature", "max_tokens", "stop_sequences" - Invalid parameter names cause immediate deserialization error - Update ProviderDefinition to use custom deserializer - Enhanced test with explicit parameter name validation - Add new test that verifies invalid parameters are rejected Problem solved: - Before: Invalid param names (e.g., "temperrature") silently ignored - Now: Rejected at deserialization time with clear error message - Prevents runtime failures caused by typos in configuration Example error: unsupported parameter name 'temperrature': must be one of: temperature, max_tokens, stop_sequences Benefits: - Fail-fast: errors caught when loading config, not at runtime - Clear feedback: error message lists valid parameter names - Type safety: validators run during deserialization - Configuration errors detected immediately, not silently ignored Verification: - All 2,788 tests pass (including new validation test) - Zero clippy warnings - Code compiles successfully Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude Haiku 4.5 * merge: resolve conflicts for PR #800 and #822 into staging (#881) * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * refactor: unify three agentic loops into single AgenticLoop engine (#654) Replace three independent copy-pasted agentic loops (dispatcher, worker, container runtime) with a single shared engine in `agentic_loop.rs` that all consumers customize via the `LoopDelegate` trait. Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines): - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points - Tool intent nudge logic consolidated (was duplicated in 3 files) - Iteration limit + force-text behavior preserved Phase 2 — Three delegate implementations: - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost guard, context compaction, skill attenuation, interruption - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair - `ContainerDelegate` (worker/container.rs): sequential tool exec, HTTP-proxied LLM, container-safe tools, credential injection Phase 3 — File moves and cleanup: - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs` - Rename `src/worker/runtime.rs` → `src/worker/container.rs` - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs` - Update `scheduler.rs` imports to new worker location Shared helpers (`src/tools/execute.rs`): - `execute_tool_with_safety()` replaces 4 copies of validate → timeout → execute → serialize - `process_tool_result()` replaces 3 copies of sanitize → wrap → ChatMessage (also used by thread_ops.rs approval resume paths) Net result: -2,408 lines, zero duplicated loop logic, single code path for tool intent nudge and completion detection. Closes #654 Co-Authored-By: Claude Opus 4.6 * fix: address review feedback from Copilot 1. scheduler.rs: Replace `unwrap_or` fallback with proper error propagation when parsing tool output JSON — surfaces bugs instead of silently changing the output type. 2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in `check_signals()` to avoid holding a lock across an async I/O call (prevents `await_holding_lock` lint). 3. worker/job.rs: Restore consecutive rate-limit counter (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks the job stuck with "Persistent rate limiting" instead of silently burning through max_iterations. Co-Authored-By: Claude Opus 4.6 * fix: incorporate staging changes — token budget tracking + mark_failed Merge staging's changes into the refactored JobDelegate: - Add token budget tracking in call_llm (update_context/add_tokens) - mark_stuck → mark_failed for iteration cap and rate-limit exhaustion (aligns with staging's #788 fix) Co-Authored-By: Claude Opus 4.6 * fix: address zmanian's PR review — eliminate type erasure, clean up Address all 6 review points from zmanian on PR #800: 1. Replace LoopOutcome::Custom(Box) with typed LoopOutcome::NeedApproval(Box) — eliminates type erasure and downcast, resolves clippy large_enum_variant. 2. Remove dead max_tool_iterations field from ChatDelegate struct. 3. Add on_tool_intent_nudge() hook to LoopDelegate trait with implementations in Job and Container delegates for observability. 4. Fix SSE events in job worker to emit raw sanitized content instead of XML-wrapped tags. 5. Remove 4 duplicate completion tests from job.rs that were already covered by the shared util module. 6. Avoid logging full tool results — use result_size_bytes in debug logs (execute.rs, job.rs). Also updates path references in CLAUDE.md, COVERAGE_PLAN.md, and add-sse-event.md command. Co-Authored-By: Claude Opus 4.6 * feat(doctor): expand diagnostics from 7 to 16 health checks * test: add unit tests for agentic_loop and execute shared modules Add 16 tests covering the two new critical shared modules: agentic_loop.rs (10 tests): - Text response exits loop immediately - Tool call → text response continuation - LoopSignal::Stop exits before LLM call - LoopSignal::InjectMessage adds user message to context - Max iterations terminates with LoopOutcome::MaxIterations - Tool intent nudge fires twice then caps - before_llm_call early exit bypasses LLM - truncate_for_preview: short string, long string, multibyte safety execute.rs (6 tests): - execute_tool_with_safety success path - Missing tool returns ToolError::NotFound - Tool execution failure propagates - Per-tool timeout enforcement (50ms) - process_tool_result XML wrapping on success - process_tool_result error formatting All 2,777 unit tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 * style: cargo fmt Co-Authored-By: Claude Opus 4.6 * fix: address code review — 9 issues across agentic loop, job worker, container CRITICAL fixes: - Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of Ok(Text("")), stopping the loop immediately with no ghost iteration. Below-threshold retries still use Text("") with an explicit empty-string guard in handle_text_response to skip injection. - check_signals drains the entire message channel before returning, prioritizing Stop over UserMessage. Previously returned early on first UserMessage, silently dropping any queued Stop or additional messages. - check_signals now detects all non-progressing job states (Cancelled, Failed, Stuck, Completed, Submitted, Accepted) instead of only Cancelled and Failed. HIGH fixes: - Error path in process_tool_result_job applies truncate_for_preview to bound error strings in SSE/DB events (was unbounded). - Document Send+Sync lifetime constraint on LoopDelegate trait. - Test mock before_llm_call refactored from double-lock to single lock acquisition, eliminating deadlock risk on refactor. MEDIUM fixes: - CompletionReport includes actual iteration count via shared Arc> tracker (was hardcoded 0). - process_tool_result_job return type changed from Result to Result<()> — the bool was always false (dead API). - Deduplicate truncate in container.rs; now uses truncate_for_preview from agentic_loop. Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Illia Polosukhin Co-authored-by: Umesh Kumar Singh Co-authored-by: reidliu41 * Revert "Feat/docker shell edition" + fix fmt/clippy (#886) * Revert "Feat/docker shell edition (#804)" This reverts commit c566faf28fb77c2fa4df92c2947fb48f1a25df9b. * style: fix formatting issues from revert Run cargo fmt to fix formatting across 7 files after the revert of the docker shell edition feature. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * refactor: centralize test credential constants into testing::credentials (#829) * refactor: central… * feat(i18n): Add internationalization support with Chinese and English translations (#929) (#950) * feat(i18n): Add internationalization support with Chinese and English translations * fix(i18n): fix duplicate keys, broken placeholders, and dead overrides --------- Co-authored-by: jinxin <106428113+italic-jinxin@users.noreply.github.com> Co-authored-by: zwb1982 <133180666+zwb1982@users.noreply.github.com> * chore: release v0.18.0 (#885) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: update WASM artifact SHA256 checksums [skip ci] (#954) Co-authored-by: github-actions[bot] * feat(embeddings): add EMBEDDING_BASE_URL for OpenAI-compatible embedding providers (#1082) * feat(embeddings): add EMBEDDING_BASE_URL for OpenAI-compatible embedding providers Allow configuring a custom base URL for OpenAI-compatible embedding endpoints (e.g., Azure OpenAI, local proxies, vLLM) via the EMBEDDING_BASE_URL environment variable. When unset, defaults to https://api.openai.com. Changes: - Extract hardcoded OpenAI URL to OPENAI_API_BASE_URL constant - Add base_url field to OpenAiEmbeddings with builder method with_base_url() - Auto-prepend https:// for schemeless URLs, strip trailing slashes - Add openai_base_url field to EmbeddingsConfig, parsed from EMBEDDING_BASE_URL - Wire base URL through create_provider() with debug logging - Add EMBEDDING_BASE_URL to clear_embedding_env() in tests - Add unit tests for URL validation and env var parsing * refactor: address Gemini review — in-place trailing slash strip, simplify config logic - Use while/pop() instead of trim_end_matches().to_string() for zero extra allocation when stripping trailing slashes in with_base_url() - Remove double openai_base_url check in create_provider() — create provider first, then branch on base_url for logging + configuration --------- Co-authored-by: SMKRV --------- Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com> Co-authored-by: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 Co-authored-by: Illia Polosukhin Co-authored-by: Xing Ji <41811005+micsama@users.noreply.github.com> Co-authored-by: Nick Stebbings <47646783+nick-stebbings@users.noreply.github.com> Co-authored-by: Reid <61492567+reidliu41@users.noreply.github.com> Co-authored-by: Umesh Kumar Singh Co-authored-by: 智方云cubecloud-io Co-authored-by: lizican <44971766+xiaocan66@users.noreply.github.com> Co-authored-by: lizican123 Co-authored-by: Zaki Manian Co-authored-by: reidliu41 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jinxin <106428113+italic-jinxin@users.noreply.github.com> Co-authored-by: zwb1982 <133180666+zwb1982@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: smkrv <17809065+smkrv@users.noreply.github.com> Co-authored-by: SMKRV --- registry/channels/discord.json | 2 +- registry/channels/slack.json | 2 +- registry/channels/telegram.json | 2 +- registry/channels/whatsapp.json | 2 +- registry/tools/github.json | 4 +- registry/tools/gmail.json | 2 +- registry/tools/google-calendar.json | 2 +- registry/tools/google-docs.json | 2 +- registry/tools/google-drive.json | 2 +- registry/tools/google-sheets.json | 2 +- registry/tools/google-slides.json | 2 +- registry/tools/slack.json | 4 +- registry/tools/telegram.json | 4 +- registry/tools/web-search.json | 2 +- src/config/embeddings.rs | 70 ++++++++++++++++++++++++++--- src/workspace/embeddings.rs | 68 +++++++++++++++++++++++++++- 16 files changed, 147 insertions(+), 25 deletions(-) diff --git a/registry/channels/discord.json b/registry/channels/discord.json index cf057245..6f5cd4e7 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz", "sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69" } }, diff --git a/registry/channels/slack.json b/registry/channels/slack.json index 64b28e3b..e6d36604 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.1-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz", "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48" } }, diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index 9a4d8918..36be1fc7 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.3-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz", "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" } }, diff --git a/registry/channels/whatsapp.json b/registry/channels/whatsapp.json index d1017276..be3faf0d 100644 --- a/registry/channels/whatsapp.json +++ b/registry/channels/whatsapp.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/whatsapp-0.2.0-wasm32-wasip2.tar.gz", "sha256": "feb9194719d9bed796b070ab4dc30348dbfb5d3dec56f9f21e02d14137abab01" } }, diff --git a/registry/tools/github.json b/registry/tools/github.json index e2dd1168..e84f756d 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.1", + "version": "0.2.0", "wit_version": "0.3.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ @@ -19,7 +19,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/github-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz", "sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b" } }, diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index dc9e6c40..08913ce6 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/gmail-0.2.0-wasm32-wasip2.tar.gz", "sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d" } }, diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index 0b773f69..c43112d3 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-calendar-0.2.0-wasm32-wasip2.tar.gz", "sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d" } }, diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 66ddd407..9f1ab133 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-docs-0.2.0-wasm32-wasip2.tar.gz", "sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9" } }, diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index 6ee52089..9766e555 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-drive-0.2.0-wasm32-wasip2.tar.gz", "sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f" } }, diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index 1cf5c808..b63265e1 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-sheets-0.2.0-wasm32-wasip2.tar.gz", "sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a" } }, diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index 9c5684b8..54187531 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -17,7 +17,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-slides-0.2.0-wasm32-wasip2.tar.gz", "sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5" } }, diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 194f1ffe..11bd7fff 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -17,8 +17,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "8af3f884240de8413d272845fad2164a347d7d2a502a0d148aa38425b93f62ed" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48" } }, "auth_summary": { diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 0213126b..680d6fdb 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "2c66245913854be4294021fc6bb479e43f7d65830c5cec25cf6c60a71d1af468" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz", + "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" } }, "auth_summary": { diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 36cc6f6b..4da5744b 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -18,7 +18,7 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-0.2.0-wasm32-wasip2.tar.gz", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz", "sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc" } }, diff --git a/src/config/embeddings.rs b/src/config/embeddings.rs index c5a84c00..a1c3ecd7 100644 --- a/src/config/embeddings.rs +++ b/src/config/embeddings.rs @@ -23,6 +23,9 @@ pub struct EmbeddingsConfig { pub ollama_base_url: String, /// Embedding vector dimension. Inferred from the model name when not set explicitly. pub dimension: usize, + /// Custom base URL for OpenAI-compatible embedding providers. + /// When set, overrides the default `https://api.openai.com`. + pub openai_base_url: Option, } impl Default for EmbeddingsConfig { @@ -36,6 +39,7 @@ impl Default for EmbeddingsConfig { model, ollama_base_url: "http://localhost:11434".to_string(), dimension, + openai_base_url: None, } } } @@ -74,6 +78,8 @@ impl EmbeddingsConfig { let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?; + let openai_base_url = optional_env("EMBEDDING_BASE_URL")?; + Ok(Self { enabled, provider, @@ -81,6 +87,7 @@ impl EmbeddingsConfig { model, ollama_base_url, dimension, + openai_base_url, }) } @@ -130,16 +137,27 @@ impl EmbeddingsConfig { } _ => { if let Some(api_key) = self.openai_api_key() { - tracing::debug!( - "Embeddings enabled via OpenAI (model: {}, dim: {})", - self.model, - self.dimension, - ); - Some(Arc::new(crate::workspace::OpenAiEmbeddings::with_model( + let mut provider = crate::workspace::OpenAiEmbeddings::with_model( api_key, &self.model, self.dimension, - ))) + ); + if let Some(ref base_url) = self.openai_base_url { + tracing::debug!( + "Embeddings enabled via OpenAI (model: {}, base_url: {}, dim: {})", + self.model, + base_url, + self.dimension, + ); + provider = provider.with_base_url(base_url); + } else { + tracing::debug!( + "Embeddings enabled via OpenAI (model: {}, dim: {})", + self.model, + self.dimension, + ); + } + Some(Arc::new(provider)) } else { tracing::warn!("Embeddings configured but OPENAI_API_KEY not set"); None @@ -164,6 +182,7 @@ mod tests { std::env::remove_var("EMBEDDING_PROVIDER"); std::env::remove_var("EMBEDDING_MODEL"); std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("EMBEDDING_BASE_URL"); } } @@ -247,4 +266,41 @@ mod tests { std::env::remove_var("EMBEDDING_ENABLED"); } } + + #[test] + fn embedding_base_url_parsed_from_env() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_embedding_env(); + + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com"); + } + + let settings = Settings::default(); + let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); + assert_eq!( + config.openai_base_url.as_deref(), + Some("https://custom.example.com"), + "EMBEDDING_BASE_URL env var should be parsed into openai_base_url" + ); + + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("EMBEDDING_BASE_URL"); + } + } + + #[test] + fn embedding_base_url_defaults_to_none() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_embedding_env(); + + let settings = Settings::default(); + let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); + assert!( + config.openai_base_url.is_none(), + "openai_base_url should be None when EMBEDDING_BASE_URL is not set" + ); + } } diff --git a/src/workspace/embeddings.rs b/src/workspace/embeddings.rs index 42340fcb..e40337eb 100644 --- a/src/workspace/embeddings.rs +++ b/src/workspace/embeddings.rs @@ -60,12 +60,18 @@ pub trait EmbeddingProvider: Send + Sync { } } +/// Default base URL for the OpenAI API. +const OPENAI_API_BASE_URL: &str = "https://api.openai.com"; + /// OpenAI embedding provider using text-embedding-ada-002 or text-embedding-3-small. +/// +/// Supports any OpenAI-compatible embedding endpoint via [`with_base_url`](Self::with_base_url). pub struct OpenAiEmbeddings { client: reqwest::Client, api_key: String, model: String, dimension: usize, + base_url: String, } impl OpenAiEmbeddings { @@ -78,6 +84,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-3-small".to_string(), dimension: 1536, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -88,6 +95,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-ada-002".to_string(), dimension: 1536, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -98,6 +106,7 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: "text-embedding-3-large".to_string(), dimension: 3072, + base_url: OPENAI_API_BASE_URL.to_string(), } } @@ -112,8 +121,35 @@ impl OpenAiEmbeddings { api_key: api_key.into(), model: model.into(), dimension, + base_url: OPENAI_API_BASE_URL.to_string(), } } + + /// Set a custom base URL for OpenAI-compatible embedding providers. + /// + /// The URL must use `http://` or `https://` scheme. If no scheme is present, + /// `https://` is prepended automatically. Trailing slashes are stripped. + pub fn with_base_url(mut self, base_url: &str) -> Self { + let url = base_url.trim(); + + // Auto-prepend https:// if no scheme is present. + let mut url = if !url.starts_with("http://") && !url.starts_with("https://") { + tracing::debug!( + "No scheme in embedding base URL '{}', prepending https://", + url + ); + format!("https://{url}") + } else { + url.to_string() + }; + + while url.ends_with('/') { + url.pop(); + } + + self.base_url = url; + self + } } #[derive(Debug, Serialize)] @@ -173,9 +209,11 @@ impl EmbeddingProvider for OpenAiEmbeddings { input: texts, }; + let url = format!("{}/v1/embeddings", self.base_url); + let response = self .client - .post("https://api.openai.com/v1/embeddings") + .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .json(&request) .send() @@ -575,9 +613,37 @@ mod tests { let provider = OpenAiEmbeddings::new("test-key"); assert_eq!(provider.dimension(), 1536); assert_eq!(provider.model_name(), "text-embedding-3-small"); + assert_eq!(provider.base_url, OPENAI_API_BASE_URL); let provider = OpenAiEmbeddings::large("test-key"); assert_eq!(provider.dimension(), 3072); assert_eq!(provider.model_name(), "text-embedding-3-large"); + assert_eq!(provider.base_url, OPENAI_API_BASE_URL); + } + + #[test] + fn test_openai_with_base_url_valid() { + let provider = + OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com"); + assert_eq!(provider.base_url, "https://custom.example.com"); + } + + #[test] + fn test_openai_with_base_url_strips_trailing_slashes() { + let provider = + OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com///"); + assert_eq!(provider.base_url, "https://custom.example.com"); + } + + #[test] + fn test_openai_with_base_url_http_scheme() { + let provider = OpenAiEmbeddings::new("test-key").with_base_url("http://localhost:8080"); + assert_eq!(provider.base_url, "http://localhost:8080"); + } + + #[test] + fn test_openai_with_base_url_schemeless_prepends_https() { + let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1"); + assert_eq!(provider.base_url, "https://custom.example.com/v1"); } } From 1e00b1fed50ac88f78d128e4bd4e9243cecdae3e Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 21:32:28 -0700 Subject: [PATCH 66/67] fix(ci): checkout promotion PR head for metadata refresh (#1097) --- .github/workflows/staging-promotion-metadata.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/staging-promotion-metadata.yml b/.github/workflows/staging-promotion-metadata.yml index 63591d83..76b8326b 100644 --- a/.github/workflows/staging-promotion-metadata.yml +++ b/.github/workflows/staging-promotion-metadata.yml @@ -31,10 +31,12 @@ jobs: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - name: Checkout base branch + - name: Checkout workflow source uses: actions/checkout@v6 with: - ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }} + # For chained promotion PRs, the script lives on the trusted PR head, + # not necessarily on the older promotion branch used as the PR base. + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.head.sha }} fetch-depth: 0 fetch-tags: true From ca6d9f6ede87795de769bf7acaebd45b514cfc83 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 12 Mar 2026 23:01:31 -0700 Subject: [PATCH 67/67] fix(registry): bump versions for github, web-search, and discord extensions Co-Authored-By: Claude Opus 4.6 --- registry/channels/discord.json | 2 +- registry/tools/github.json | 2 +- registry/tools/web-search.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/registry/channels/discord.json b/registry/channels/discord.json index 6f5cd4e7..50ef85ee 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -2,7 +2,7 @@ "name": "discord", "display_name": "Discord Channel", "kind": "channel", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Talk to your agent in Discord", "keywords": [ diff --git a/registry/tools/github.json b/registry/tools/github.json index e84f756d..e775ac82 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 4da5744b..1722c391 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -2,7 +2,7 @@ "name": "web-search", "display_name": "Web Search", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Search the web using Brave Search API", "keywords": [