From d1a0b460e1c0981c72cc25004bce122e1ee29910 Mon Sep 17 00:00:00 2001 From: rookiestar28 Date: Thu, 26 Feb 2026 15:43:21 +0800 Subject: [PATCH] feat: implement R38/R96 streaming assist UX and docs full update --- README.md | 23 ++ api/assist.py | 398 ++++++++++++++++++++---- api/routes.py | 12 + docs/frontend_ux_walkthrough.md | 8 +- docs/release/api_contract.md | 48 ++- docs/release/config_secrets_contract.md | 13 +- docs/security_key_lifecycle_sop.md | 39 ++- services/capabilities.py | 1 + services/llm_client.py | 37 ++- services/planner.py | 17 +- services/providers/openai_compat.py | 151 ++++++++- services/refiner.py | 5 +- services/safe_io.py | 108 +++++++ tests/e2e/specs/r38_lite.spec.js | 46 +++ tests/test_api_assist.py | 92 ++++++ tests/test_r38_openai_stream_parser.py | 61 ++++ web/openclaw_api.js | 277 ++++++++++++++--- web/openclaw_fetch_wrappers.js | 123 ++++++++ web/tabs/planner_tab.js | 43 ++- web/tabs/refiner_tab.js | 44 ++- web/tests/e2e-harness.html | 30 ++ 21 files changed, 1435 insertions(+), 141 deletions(-) create mode 100644 tests/test_r38_openai_stream_parser.py create mode 100644 web/openclaw_fetch_wrappers.js diff --git a/README.md b/README.md index aaaa3c7..91ab59c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ This project is designed to make **ComfyUI a reliable automation target** with a - Adversarial verification is execution-gated (bounded fuzz + mutation smoke) in CI and local full-test/pre-push workflows with replayable artifacts - Wave E closeout hardening: deployment profile gates and critical flow parity are now enforced together with signed policy posture control, bounded anomaly telemetry, adversarial fuzz validation, and mutation-baseline regression sensitivity checks - Wave A/B/C closeout hardening: runtime/config/session stability contracts, strict outbound and supply-chain controls, and capability-aware operator guidance with bounded Parameter Lab/compare workflows +- Runtime guardrails are enforced as a runtime-only contract with diagnostics, clamping, and reject-on-persist behavior for safety-critical limits (timeouts/retries/queue bounds/provider safety defaults) +- Cryptographic lifecycle drills are automated with machine-readable evidence for rotation, revoke, key-loss recovery, and token-compromise fail-closed exercises +- Management query paths now use deterministic pagination normalization and bounded scans to reduce malformed-input abuse and unbounded admin/list query cost +- Compatibility matrix freshness/drift governance is operator-visible via Doctor checks and a repeatable refresh workflow with evidence output, reducing stale deployment assumptions before release Deployment profiles and hardening checklists: - [Security Deployment Guide](docs/security_deployment_guide.md) (local / LAN / public templates + self-check command) @@ -50,6 +54,19 @@ Deployment profiles and hardening checklists:
+Assist streaming UX and frontend fetch-wrapper safety hardening + +- Completed a focused assist UX + frontend transport reliability batch with full SOP verification: + - added optional streaming assist paths for Planner/Refiner with incremental preview updates and staged progress events + - added backend streaming endpoints for planner/refiner assist flows with capability-gated frontend enablement and safe fallback to the existing non-stream path + - added frontend live preview rendering for Planner/Refiner while preserving cancel/stale-response safety behavior + - added idempotent fetch-wrapper composition guards to prevent duplicate wrapper stacking during repeated frontend bootstrap/setup + - added backend/parser/frontend regression coverage for streaming assist behavior and fetch-wrapper idempotence, plus full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E) + +
+ +
+ Recent hardening and reliability improvements: runtime guardrails, crypto drills, compatibility governance, and safer management queries - Completed a focused reliability + operations hardening batch with full SOP verification: @@ -518,6 +535,7 @@ Use `/api/...` from browsers and extension JS. - `GET /openclaw/logs/tail?n=50` - log tail (supports `trace_id` / `prompt_id` filters) - `GET /openclaw/trace/{prompt_id}` -trace timeline (redacted) - `GET /openclaw/capabilities` -feature/capability probe for frontend compatibility + - includes feature flags such as `assist_planner`, `assist_refiner`, and optional `assist_streaming` (when incremental assist preview is available) - `GET /openclaw/jobs` -currently a stub (returns an empty list) Access control: @@ -532,10 +550,15 @@ Access control: - `POST /openclaw/llm/test` -test connectivity (admin boundary) - `POST /openclaw/llm/chat` -connector chat completion path (admin boundary) - `GET /openclaw/llm/models` -fetch model list for selected provider/base URL +- `POST /openclaw/assist/planner` -planner structured prompt generation (admin boundary) +- `POST /openclaw/assist/refiner` -prompt refinement with optional image context (admin boundary) +- `POST /openclaw/assist/planner/stream` -optional SSE-style planner streaming path (`text/event-stream`, admin boundary) +- `POST /openclaw/assist/refiner/stream` -optional SSE-style refiner streaming path (`text/event-stream`, admin boundary) Notes: - Queue submission uses `OPENCLAW_COMFYUI_URL` (default `http://127.0.0.1:8188`). +- Planner/Refiner UI uses capability-gated assist streaming when available and falls back to the non-stream endpoints automatically. - `PUT /openclaw/config` now returns apply metadata so callers can reason about what actually took effect: - `apply.ok`, `apply.requires_restart`, `apply.applied_keys` - `apply.effective_provider`, `apply.effective_model` diff --git a/api/assist.py b/api/assist.py index 9c784dd..9f058b4 100644 --- a/api/assist.py +++ b/api/assist.py @@ -1,4 +1,8 @@ +import asyncio +import contextlib +import json import logging +from typing import Any, Dict, Optional from aiohttp import web @@ -40,6 +44,9 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.assist") MAX_REQUIREMENTS_LEN = 8000 MAX_STYLE_LEN = 2000 MAX_IMAGE_B64_LEN = 5 * 1024 * 1024 # ~5MB base64 string length +MAX_STREAM_DELTA_CHARS = 256 +MAX_STREAM_PREVIEW_CHARS = 16_000 +STREAM_KEEPALIVE_SEC = 1.0 class AssistHandlers: @@ -48,6 +55,247 @@ class AssistHandlers: self.refiner = RefinerService() self.composer = AutomationComposerService() + async def _require_admin_and_rate_limit( + self, request: web.Request + ) -> Optional[web.Response]: + authorized, _err_msg = require_admin_token(request) + if not authorized: + return web.json_response({"error": "Unauthorized"}, status=401) + if not check_rate_limit(request, "admin"): + return web.json_response({"error": "Rate limit exceeded"}, status=429) + return None + + async def _parse_json_body( + self, request: web.Request + ) -> tuple[Optional[dict], Optional[web.Response]]: + try: + data = await request.json() + except Exception: + return None, web.json_response({"error": "Invalid JSON"}, status=400) + if not isinstance(data, dict): + return None, web.json_response( + {"error": "JSON object required"}, status=400 + ) + return data, None + + def _validate_planner_payload( + self, data: dict + ) -> tuple[Optional[dict], Optional[web.Response]]: + profile = data.get("profile", "SDXL-v1") + requirements = data.get("requirements", "") + style = data.get("style_directives", "") + seed = data.get("seed", 0) + + if not isinstance(profile, str): + return None, web.json_response( + {"error": "profile must be string"}, status=400 + ) + if not isinstance(requirements, str): + return None, web.json_response( + {"error": "requirements must be string"}, status=400 + ) + if not isinstance(style, str): + return None, web.json_response( + {"error": "style_directives must be string"}, status=400 + ) + if len(requirements) > MAX_REQUIREMENTS_LEN: + return None, web.json_response( + {"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"}, + status=400, + ) + if len(style) > MAX_STYLE_LEN: + return None, web.json_response( + {"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400 + ) + try: + seed = int(seed) + except Exception: + seed = 0 + return { + "profile": profile, + "requirements": requirements, + "style_directives": style, + "seed": seed, + }, None + + def _validate_refiner_payload( + self, data: dict + ) -> tuple[Optional[dict], Optional[web.Response]]: + image_b64 = data.get("image_b64", "") + orig_pos = data.get("orig_positive", "") + orig_neg = data.get("orig_negative", "") + issue = data.get("issue", "Fix issues") + params_json = data.get("params_json", "{}") + goal = data.get("goal", "Fix issues") + + if not isinstance(image_b64, str) or not image_b64: + return None, web.json_response({"error": "image_b64 required"}, status=400) + if len(image_b64) > MAX_IMAGE_B64_LEN: + return None, web.json_response( + {"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"}, + status=400, + ) + for key, value in ( + ("orig_positive", orig_pos), + ("orig_negative", orig_neg), + ("issue", issue), + ("params_json", params_json), + ("goal", goal), + ): + if not isinstance(value, str): + return None, web.json_response( + {"error": f"{key} must be string"}, status=400 + ) + if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN: + return None, web.json_response({"error": "Prompt too long"}, status=400) + + return { + "image_b64": image_b64, + "orig_positive": orig_pos, + "orig_negative": orig_neg, + "issue": issue, + "params_json": params_json, + "goal": goal, + }, None + + @staticmethod + def _sse_frame(event: str, payload: Dict[str, Any]) -> bytes: + return ( + f"event: {event}\n" + f"data: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n\n" + ).encode("utf-8") + + async def _write_sse_event( + self, response: web.StreamResponse, event: str, payload: Dict[str, Any] + ) -> bool: + try: + await response.write(self._sse_frame(event, payload)) + return True + except (ConnectionError, RuntimeError): + return False + + async def _assist_stream_session( + self, + request: web.Request, + *, + kind: str, + worker_fn, + worker_kwargs: Dict[str, Any], + ) -> web.StreamResponse: + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue() + preview_chars = 0 + + def emit(event: str, payload: Dict[str, Any]) -> None: + try: + loop.call_soon_threadsafe(queue.put_nowait, (event, payload)) + except RuntimeError: + pass + + def on_text_delta(delta: str) -> None: + nonlocal preview_chars + if not isinstance(delta, str) or not delta: + return + remaining = MAX_STREAM_PREVIEW_CHARS - preview_chars + if remaining <= 0: + return + clipped = delta[: min(remaining, MAX_STREAM_DELTA_CHARS)] + if not clipped: + return + preview_chars += len(clipped) + emit("delta", {"text": clipped, "preview_chars": preview_chars}) + + async def runner() -> None: + emit("ready", {"ok": True, "kind": kind, "mode": "sse"}) + emit( + "stage", {"phase": "dispatch", "message": "Dispatching assist request"} + ) + try: + call_kwargs = dict(worker_kwargs) + call_kwargs["on_text_delta"] = on_text_delta + result = await run_in_thread(worker_fn, **call_kwargs) + emit( + "stage", + {"phase": "finalize", "message": "Parsing and validating output"}, + ) + if kind == "planner": + pos, neg, params = result + final_payload = { + "positive": pos, + "negative": neg, + "params": params, + } + elif kind == "refiner": + new_pos, new_neg, patch, rationale = result + final_payload = { + "refined_positive": new_pos, + "refined_negative": new_neg, + "param_patch": patch, + "rationale": rationale, + } + else: + final_payload = {"result": result} + emit( + "final", + { + "ok": True, + "kind": kind, + "result": final_payload, + "streaming": { + "preview_chars": preview_chars, + "preview_truncated": preview_chars + >= MAX_STREAM_PREVIEW_CHARS, + }, + }, + ) + except Exception as e: + logger.exception("Assist streaming API failed (%s)", kind) + emit( + "error", + {"ok": False, "kind": kind, "error": "Internal server error"}, + ) + finally: + emit("__done__", {}) + + runner_task = asyncio.create_task(runner()) + + try: + while True: + try: + event, payload = await asyncio.wait_for( + queue.get(), timeout=STREAM_KEEPALIVE_SEC + ) + except asyncio.TimeoutError: + if runner_task.done(): + break + if not await self._write_sse_event( + response, "keepalive", {"ok": True} + ): + break + continue + + if event == "__done__": + break + if not await self._write_sse_event(response, event, payload): + break + finally: + if not runner_task.done(): + runner_task.cancel() + with contextlib.suppress(BaseException): + await runner_task + return response + @endpoint_metadata( auth=AuthTier.ADMIN, risk=RiskTier.MEDIUM, @@ -62,39 +310,26 @@ class AssistHandlers: JSON: { profile, requirements, style_directives, seed } """ # Security: Admin Token required - authorized, err_msg = require_admin_token(request) - if not authorized: - return web.json_response({"error": "Unauthorized"}, status=401) - - # Security: Rate Limit - if not check_rate_limit(request, "admin"): - return web.json_response({"error": "Rate limit exceeded"}, status=429) - - try: - data = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON"}, status=400) - - profile = data.get("profile", "SDXL-v1") - requirements = data.get("requirements", "") - style = data.get("style_directives", "") - seed = data.get("seed", 0) - - # Security: Payload size clamps - if len(requirements) > MAX_REQUIREMENTS_LEN: - return web.json_response( - {"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"}, - status=400, - ) - if len(style) > MAX_STYLE_LEN: - return web.json_response( - {"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400 - ) + auth_resp = await self._require_admin_and_rate_limit(request) + if auth_resp: + return auth_resp + data, error_resp = await self._parse_json_body(request) + if error_resp: + return error_resp + assert data is not None + payload, payload_err = self._validate_planner_payload(data) + if payload_err: + return payload_err + assert payload is not None try: # Run sync LLM call in thread pool to avoid blocking event loop pos, neg, params = await run_in_thread( - self.planner.plan_generation, profile, requirements, style, seed + self.planner.plan_generation, + payload["profile"], + payload["requirements"], + payload["style_directives"], + payload["seed"], ) return web.json_response( @@ -119,48 +354,23 @@ class AssistHandlers: JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal } """ # Security checks - authorized, err_msg = require_admin_token(request) - if not authorized: - return web.json_response({"error": "Unauthorized"}, status=401) - - # Security: Rate Limit - if not check_rate_limit(request, "admin"): - return web.json_response({"error": "Rate limit exceeded"}, status=429) - - try: - data = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON"}, status=400) - - # Extract payload (aligned with service signature) - image_b64 = data.get("image_b64", "") - orig_pos = data.get("orig_positive", "") - orig_neg = data.get("orig_negative", "") - issue = data.get("issue", "Fix issues") - params_json = data.get("params_json", "{}") - goal = data.get("goal", "Fix issues") - - # Validation & Size clamps - if not image_b64: - return web.json_response({"error": "image_b64 required"}, status=400) - if len(image_b64) > MAX_IMAGE_B64_LEN: - return web.json_response( - {"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"}, - status=400, - ) - if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN: - return web.json_response({"error": "Prompt too long"}, status=400) + auth_resp = await self._require_admin_and_rate_limit(request) + if auth_resp: + return auth_resp + data, error_resp = await self._parse_json_body(request) + if error_resp: + return error_resp + assert data is not None + payload, payload_err = self._validate_refiner_payload(data) + if payload_err: + return payload_err + assert payload is not None try: # Run sync LLM call in thread pool new_pos, new_neg, patch, rationale = await run_in_thread( self.refiner.refine_prompt, - image_b64=image_b64, - orig_positive=orig_pos, - orig_negative=orig_neg, - issue=issue, - params_json=params_json, - goal=goal, + **payload, ) return web.json_response( @@ -175,6 +385,62 @@ class AssistHandlers: logger.exception("Refiner API failed") return web.json_response({"error": "Internal server error"}, status=500) + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.MEDIUM, + summary="Run planner (streaming)", + description="Generate prompts from requirements via LLM with SSE-style incremental updates.", + audit="assist.planner.stream", + plane=RoutePlane.ADMIN, + ) + async def planner_stream_handler(self, request): + auth_resp = await self._require_admin_and_rate_limit(request) + if auth_resp: + return auth_resp + data, error_resp = await self._parse_json_body(request) + if error_resp: + return error_resp + assert data is not None + payload, payload_err = self._validate_planner_payload(data) + if payload_err: + return payload_err + assert payload is not None + + return await self._assist_stream_session( + request, + kind="planner", + worker_fn=self.planner.plan_generation, + worker_kwargs=payload, + ) + + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.MEDIUM, + summary="Run refiner (streaming)", + description="Refine prompt/parameters with SSE-style incremental updates.", + audit="assist.refiner.stream", + plane=RoutePlane.ADMIN, + ) + async def refiner_stream_handler(self, request): + auth_resp = await self._require_admin_and_rate_limit(request) + if auth_resp: + return auth_resp + data, error_resp = await self._parse_json_body(request) + if error_resp: + return error_resp + assert data is not None + payload, payload_err = self._validate_refiner_payload(data) + if payload_err: + return payload_err + assert payload is not None + + return await self._assist_stream_session( + request, + kind="refiner", + worker_fn=self.refiner.refine_prompt, + worker_kwargs=payload, + ) + @endpoint_metadata( auth=AuthTier.ADMIN, risk=RiskTier.MEDIUM, diff --git a/api/routes.py b/api/routes.py index 82a9bd0..d9da6e5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -833,9 +833,21 @@ def register_routes(server) -> None: register_dual_route( server, "POST", f"{prefix}/assist/planner", assist.planner_handler ) + register_dual_route( + server, + "POST", + f"{prefix}/assist/planner/stream", + assist.planner_stream_handler, + ) register_dual_route( server, "POST", f"{prefix}/assist/refiner", assist.refiner_handler ) + register_dual_route( + server, + "POST", + f"{prefix}/assist/refiner/stream", + assist.refiner_stream_handler, + ) register_dual_route( server, "POST", diff --git a/docs/frontend_ux_walkthrough.md b/docs/frontend_ux_walkthrough.md index e875045..dec577a 100644 --- a/docs/frontend_ux_walkthrough.md +++ b/docs/frontend_ux_walkthrough.md @@ -17,22 +17,26 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve - Frontend fetches capabilities during setup and conditionally registers tabs: - `assist_planner` → Planner - `assist_refiner` → Refiner + - `assist_streaming` → enable Planner/Refiner incremental live preview (fallback remains non-streaming) - `scheduler` → Variants (current gating) - `presets` → Library - `approvals` → Approvals If capabilities are unavailable, the full tab set is registered to surface actionable errors (instead of “missing tabs”). +If `assist_streaming` is unavailable or the stream transport degrades, Planner/Refiner automatically fall back to the existing non-stream request path. ## Quick Manual Checks 1. Open ComfyUI and confirm OpenClaw appears in the sidebar. 2. Switch between all visible tabs multiple times (and reopen the sidebar if possible) and ensure panes do not go blank. -3. Planner: click **Plan Generation** with minimal input and confirm either results render or a readable error appears. -4. Refiner: click **Refine Prompts** (with or without image) and confirm either results render or a readable error appears. +3. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears. +4. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears. 5. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes). +6. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state. ## E2E (Playwright) Checks - Run: `npm test` - Tests live in: `tests/e2e/specs/` - Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls) +- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks) diff --git a/docs/release/api_contract.md b/docs/release/api_contract.md index 11b676f..2e65928 100644 --- a/docs/release/api_contract.md +++ b/docs/release/api_contract.md @@ -1,8 +1,8 @@ # OpenClaw API Contract (v1) > **Status**: normative -> **Version**: 1.0.0 -> **Date**: 2026-02-09 +> **Version**: 1.0.1 +> **Date**: 2026-02-26 This document defines the public API contract for OpenClaw. It serves as the authoritative baseline for client compatibility and breaking change policies. @@ -17,9 +17,11 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d | Method | Path | Legacy Path | Auth | Description | | :--- | :--- | :--- | :--- | :--- | | `GET` | `/health` | `/moltbot/health` | None | System status, uptime, and dependencies. | -| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions. | +| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions (includes optional UX/runtime features such as assist streaming support). | | `GET` | `/logs/tail` | `/moltbot/logs/tail` | Observability | Tail recent log lines (rate-limited). | | `GET` | `/trace/{prompt_id}` | `/moltbot/trace/{id}` | Observability | Get execution trace by prompt ID. | +| `GET` | `/events` | `/moltbot/events` | Observability | List recent job lifecycle events (JSON polling fallback; includes pagination/scan diagnostics). | +| `GET` | `/events/stream` | `/moltbot/events/stream` | Observability | SSE stream of job lifecycle events with resume support. | | `GET` | `/config` | `/moltbot/config` | Observability | Read-only view of sanitized provider config. | | `PUT` | `/config` | `/moltbot/config` | Admin | Update system configuration. | | `GET` | `/jobs` | `/moltbot/jobs` | Observability | List recent jobs (Stub/Not Implemented). | @@ -35,9 +37,18 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d | `POST` | `/webhook/validate` | `/moltbot/webhook/validate` | Webhook Secret | Dry-run validation of webhook payload. | | `POST` | `/triggers/fire` | `/moltbot/triggers/fire` | Admin | Fire an ad-hoc workflow trigger from external system. | -### 1.3 LLM & Chat +### 1.3 Assist, LLM & Chat -**Base Path**: `/openclaw/llm/` +**Assist Base Path**: `/openclaw/assist/` + +| Method | Path | Legacy Path | Auth | Description | +| :--- | :--- | :--- | :--- | :--- | +| `POST` | `/assist/planner` | `/moltbot/assist/planner` | Admin/Local | Planner structured prompt generation. | +| `POST` | `/assist/refiner` | `/moltbot/assist/refiner` | Admin/Local | Prompt refinement with optional image context. | +| `POST` | `/assist/planner/stream` | `/moltbot/assist/planner/stream` | Admin/Local | Optional SSE-style planner streaming response (`text/event-stream`) with staged progress + final payload. | +| `POST` | `/assist/refiner/stream` | `/moltbot/assist/refiner/stream` | Admin/Local | Optional SSE-style refiner streaming response (`text/event-stream`) with staged progress + final payload. | + +**LLM Base Path**: `/openclaw/llm/` | Method | Path | Legacy Path | Auth | Description | | :--- | :--- | :--- | :--- | :--- | @@ -76,7 +87,7 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d | `DELETE` | `/schedules/{id}` | Delete a schedule. | | `POST` | `/schedules/{id}/run` | Manually trigger a schedule. | | `GET` | `/schedules/{id}/runs` | Get run history for a schedule. | -| `GET` | `/approvals` | List pending approvals. | +| `GET` | `/approvals` | List pending approvals (includes pagination/scan diagnostics; bounded serialization scan on malformed records). | | `POST` | `/approvals/{id}/approve` | Approve a pending request. | | `POST` | `/approvals/{id}/reject` | Reject a pending request. | @@ -128,6 +139,31 @@ All JSON responses (success or error) share a common structure: | `500` | Internal Error | Unhandled server exception. | | `503` | Unavailable | Feature disabled or service not wired. | +### 2.3 SSE Endpoint Notes (Contractual Behavior) + +- SSE endpoints return `Content-Type: text/event-stream`. +- Current SSE surfaces include: + - `/openclaw/events/stream` (job lifecycle events) + - optional `/openclaw/assist/planner/stream` and `/openclaw/assist/refiner/stream` (assist incremental preview path) +- Assist streaming emits event types from the following set: + - `ready` + - `stage` + - `delta` + - `final` + - `error` + - `keepalive` +- Clients MUST treat `final` as the source of truth for structured assist results. `delta` preview text is best-effort and may be truncated or differ from the final parsed payload. +- Clients SHOULD gracefully fall back to non-streaming assist endpoints when streaming capability is absent or streaming transport fails. + +### 2.4 Pagination & Scan Diagnostics (Management Query Contract) + +- `GET /openclaw/events` and `GET /openclaw/approvals` include deterministic pagination normalization. +- Responses may include `pagination` and `scan` diagnostic objects so clients/operators can detect: + - normalized limit/offset/cursor values + - stale/future cursor resets + - bounded scan truncation or malformed-record skips +- Backend/runtime errors outside pagination normalization are still surfaced explicitly (not silently swallowed). + --- ## 3. Limits & Budgets diff --git a/docs/release/config_secrets_contract.md b/docs/release/config_secrets_contract.md index 7250de3..1c4bda5 100644 --- a/docs/release/config_secrets_contract.md +++ b/docs/release/config_secrets_contract.md @@ -1,8 +1,8 @@ # OpenClaw Config & Secrets Contract (v1) > **Status**: normative -> **Version**: 1.0.1 -> **Date**: 2026-02-19 +> **Version**: 1.0.2 +> **Date**: 2026-02-26 This document defines the authoritative configuration contract for OpenClaw. It enumerates all supported environment variables, their precedence rules, and security classifications. @@ -101,6 +101,11 @@ Contractual limits to prevent resource exhaustion. | `OPENCLAW_DIAGNOSTICS` | Comma-separated list of subsystems to enable debug logging for (e.g. `webhook.*,templates`). Safe-redacted. | | `OPENCLAW_CONNECTOR_DEBUG` | Set `1` to enable verbose debug logging in Connector. | +Runtime guardrails contract (ENV-driven, runtime-only): +- `GET /openclaw/config` may include a `runtime_guardrails` diagnostics object describing effective runtime caps, sources, and degraded status. +- Runtime guardrails are evaluated at runtime (deployment/runtime profile aware) and are not part of the persisted user config contract. +- `PUT /openclaw/config` rejects attempts to persist `runtime_guardrails` / legacy guardrail payloads; callers must change the underlying environment variables instead. + --- ## 3. Secret Rotation & Migration @@ -126,3 +131,7 @@ If multiple keys are configured for the same purpose, the following order applie ### 3.3 Persistence Non-secret configuration (such as enabled/disabled flags, feature toggles) may be persisted in the `OPENCLAW_STATE_DIR/config.json` via the Settings API. However, **environment variables always override persisted settings**. + +Persistence guardrails: +- Runtime-only guardrail fields (for example `runtime_guardrails` and legacy guardrail aliases) are stripped/ignored when loading persisted config and rejected on `/config` write requests. +- This prevents runtime safety caps (timeouts/retries/provider safety clamps) from being silently converted into mutable persisted settings. diff --git a/docs/security_key_lifecycle_sop.md b/docs/security_key_lifecycle_sop.md index 77988a8..f6e5e50 100644 --- a/docs/security_key_lifecycle_sop.md +++ b/docs/security_key_lifecycle_sop.md @@ -156,7 +156,44 @@ PY 2. If integrity is uncertain, revoke all active tokens and re-issue per device. 3. Validate with `tests.test_s58_bridge_token_lifecycle` and `tests.test_s58_bridge_auth_integration`. -## 6. Validation Gate After Any Lifecycle Change +## 6. Drill Automation (Evidence-Generating, Optional but Recommended) + +In addition to the manual procedures above, OpenClaw provides a local/CI-safe drill runner that simulates lifecycle incidents and emits machine-readable evidence. + +Script: +- `scripts/run_crypto_lifecycle_drills.py` + +Supported scenarios: +- `planned_rotation` +- `emergency_revoke` +- `key_loss_recovery` +- `token_compromise` + +Example commands: + +```bash +python scripts/run_crypto_lifecycle_drills.py --pretty +python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output .planning/logs/crypto_drills.json --pretty +``` + +Evidence bundle contract (JSON): +- top-level fields include `schema_version`, `bundle`, `state_dir`, and `drills` +- each drill record includes: + - `operation` + - `scenario` + - `precheck` + - `result` + - `rollback_status` + - `artifacts` + - `decision_codes` + - `fail_closed_assertions` + +Operational notes: +- This drill runner is for verification/training/evidence collection and does not replace maintenance-window production rotation procedures. +- Use an isolated or temporary state directory unless you intentionally want artifacts written to a specific test state path. +- Store drill evidence alongside change tickets or implementation records when lifecycle readiness is part of acceptance criteria. + +## 7. Validation Gate After Any Lifecycle Change Run at minimum: diff --git a/services/capabilities.py b/services/capabilities.py index 4e399a1..13cfbf6 100644 --- a/services/capabilities.py +++ b/services/capabilities.py @@ -68,6 +68,7 @@ def get_capabilities() -> dict: "approvals": True, "assist_planner": True, "assist_refiner": True, + "assist_streaming": True, # R38 optional SSE-style assist streaming path "assist_automation_compose": True, "scheduler": True, "triggers": True, diff --git a/services/llm_client.py b/services/llm_client.py index 0f57387..e50dd4d 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -7,7 +7,7 @@ import logging import os import re import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple try: from ..config import setup_logger @@ -353,6 +353,8 @@ class LLMClient: max_tokens: int, tools: Optional[List[Dict[str, Any]]] = None, tool_choice: Optional[str] = None, + streaming: bool = False, + on_text_delta: Optional[Callable[[str], None]] = None, ) -> Dict[str, Any]: """Execute a single request attempt (factored out for failover).""" api_type = self._get_api_type() @@ -380,6 +382,8 @@ class LLMClient: max_tokens, tools=tools, tool_choice=tool_choice, + streaming=streaming, + on_text_delta=on_text_delta, ) def complete( @@ -395,6 +399,8 @@ class LLMClient: ] = None, # F25: Optional tool calling schemas tool_choice: Optional[str] = None, # F25: Optional tool_choice (OpenAI-compat) trace_id: Optional[str] = None, # R25: Trace context + streaming: bool = False, # R38: optional provider streaming path + on_text_delta: Optional[Callable[[str], None]] = None, # R38 callback ) -> Dict[str, Any]: """ Send a completion request to the configured provider. @@ -657,6 +663,8 @@ class LLMClient: max_tokens, tools=tools, tool_choice=tool_choice, + streaming=streaming, + on_text_delta=on_text_delta, ) # R37: Update health score on success @@ -787,6 +795,8 @@ class LLMClient: *, tools: Optional[List[Dict[str, Any]]] = None, tool_choice: Optional[str] = None, + streaming: bool = False, + on_text_delta: Optional[Callable[[str], None]] = None, ) -> Dict[str, Any]: """Complete using OpenAI-compatible API.""" egress_controls = self._get_egress_controls(self.provider, self.base_url) @@ -801,6 +811,31 @@ class LLMClient: else: messages.append({"role": "user", "content": user_message}) + # R38: Streaming is optional and currently only enabled for non-tool + # OpenAI-compatible text paths. Tool-call streaming deltas are not parsed yet. + if streaming and not tools and not tool_choice: + try: + return openai_compat.make_request_stream( + base_url=self.base_url, + api_key=self.api_key, + messages=messages, + model=self.model, + temperature=temperature, + max_tokens=max_tokens, + timeout=self.timeout, + allow_hosts=egress_controls.get("allow_hosts"), + allow_any_public_host=bool( + egress_controls.get("allow_any_public_host") + ), + allow_loopback_hosts=egress_controls.get("allow_loopback_hosts"), + on_text_delta=on_text_delta, + ) + except Exception as e: + logger.info( + "R38: Streaming request unavailable/failed; falling back to non-streaming request: %s", + e, + ) + return openai_compat.make_request( base_url=self.base_url, api_key=self.api_key, diff --git a/services/planner.py b/services/planner.py index c1fa455..442f913 100644 --- a/services/planner.py +++ b/services/planner.py @@ -1,7 +1,6 @@ -import json import logging import os -from typing import Any, Dict, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple from .llm_client import LLMClient from .llm_output import extract_json_object, sanitize_string @@ -55,7 +54,12 @@ class PlannerService: self.llm_client = LLMClient() def plan_generation( - self, profile_id: str, requirements: str, style_directives: str, seed: int = 0 + self, + profile_id: str, + requirements: str, + style_directives: str, + seed: int = 0, + on_text_delta: Optional[Callable[[str], None]] = None, ) -> Tuple[str, str, Dict[str, Any]]: """ Plan prompt and params via LLM. @@ -165,7 +169,12 @@ Style: {style_directives} else: # Traditional mode: Call LLM normally logger.info(f"Sending request to LLM for profile {profile_id}...") - response = self.llm_client.complete(system_prompt, user_message) + response = self.llm_client.complete( + system_prompt, + user_message, + streaming=on_text_delta is not None, + on_text_delta=on_text_delta, + ) # Traditional JSON extraction (fallback or default path) content = response.get("text", "") diff --git a/services/providers/openai_compat.py b/services/providers/openai_compat.py index 29b6fcc..c0cbf70 100644 --- a/services/providers/openai_compat.py +++ b/services/providers/openai_compat.py @@ -1,15 +1,25 @@ import json import logging -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional try: from ..provider_errors import ProviderHTTPError from ..retry_after import get_retry_after_seconds - from ..safe_io import STANDARD_OUTBOUND_POLICY, SSRFError, safe_request_json + from ..safe_io import ( + STANDARD_OUTBOUND_POLICY, + SSRFError, + safe_request_json, + safe_request_text_stream, + ) except ImportError: from services.provider_errors import ProviderHTTPError from services.retry_after import get_retry_after_seconds - from services.safe_io import STANDARD_OUTBOUND_POLICY, SSRFError, safe_request_json + from services.safe_io import ( + STANDARD_OUTBOUND_POLICY, + SSRFError, + safe_request_json, + safe_request_text_stream, + ) logger = logging.getLogger("ComfyUI-OpenClaw.services.providers.openai_compat") @@ -149,6 +159,141 @@ def make_request( raise RuntimeError(f"API request failed: {e}") +def make_request_stream( + base_url: str, + api_key: Optional[str], + messages: List[Dict[str, Any]], + model: str, + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: float = 120.0, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str] = None, + allow_hosts: Optional[set[str]] = None, + allow_any_public_host: bool = False, + allow_loopback_hosts: Optional[set[str]] = None, + on_text_delta: Optional[Callable[[str], None]] = None, + max_preview_chars: int = 16000, +) -> Dict[str, Any]: + """ + Best-effort streaming request to OpenAI-compatible /chat/completions endpoint. + + Parses SSE `data:` lines and emits incremental text deltas when present. + Falls back to final accumulated text result shape `{"text": str, "raw": dict}`. + """ + endpoint = f"{base_url.rstrip('/')}/chat/completions" + payload = build_chat_request( + messages, model, temperature, max_tokens, tools, tool_choice + ) + payload["stream"] = True + + headers = {"Content-Type": "application/json", "Accept": "text/event-stream"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + full_text_parts: List[str] = [] + chunk_count = 0 + saw_done = False + + def _emit_delta(delta: str) -> None: + if not delta: + return + nonlocal full_text_parts + current_len = sum(len(p) for p in full_text_parts) + if current_len >= max_preview_chars: + return + clipped = delta[: max_preview_chars - current_len] + if not clipped: + return + full_text_parts.append(clipped) + if on_text_delta: + try: + on_text_delta(clipped) + except Exception: + # Callback errors must not break provider parsing. + logger.debug("Ignoring on_text_delta callback error", exc_info=True) + + try: + for line in safe_request_text_stream( + method="POST", + url=endpoint, + json_body=payload, + headers=headers, + timeout_sec=int(timeout), + policy=STANDARD_OUTBOUND_POLICY, + allow_hosts=allow_hosts, + allow_any_public_host=allow_any_public_host, + allow_loopback_hosts=allow_loopback_hosts, + ): + line = line.rstrip("\r\n") + if not line or line.startswith(":"): + continue + if not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if not data_str: + continue + if data_str == "[DONE]": + saw_done = True + break + + try: + payload_obj = json.loads(data_str) + except json.JSONDecodeError: + continue + + chunk_count += 1 + choices = payload_obj.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta") or {} + content = delta.get("content") + + if isinstance(content, str): + _emit_delta(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text = part.get("text") + if isinstance(text, str): + _emit_delta(text) + + return { + "text": "".join(full_text_parts), + "raw": { + "stream": True, + "provider": "openai_compat", + "chunks": chunk_count, + "saw_done": saw_done, + }, + } + + except RuntimeError as e: + params = str(e) + status_code = 500 + import re + + m = re.search(r"HTTP error (\d+)", params) + if m: + status_code = int(m.group(1)) + + logger.error(f"OpenAI-compat streaming API error: {e}") + raise ProviderHTTPError( + status_code=status_code, + message=str(e), + provider="openai_compat", + model=model, + retry_after=0, + ) + except SSRFError as e: + logger.error(f"OpenAI-compat streaming SSRF blocked: {e}") + raise RuntimeError(f"Security policy blocked request: {e}") + except Exception as e: + logger.error(f"OpenAI-compat streaming unexpected error: {e}") + raise RuntimeError(f"API request failed: {e}") + + def build_vision_message( text_prompt: str, image_base64: str, diff --git a/services/refiner.py b/services/refiner.py index ef9d8c2..f12a02b 100644 --- a/services/refiner.py +++ b/services/refiner.py @@ -1,7 +1,7 @@ import json import logging import os -from typing import Any, Dict, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple from .llm_client import LLMClient from .llm_output import extract_json_object, filter_allowed_keys, sanitize_string @@ -52,6 +52,7 @@ class RefinerService: issue: str, params_json: str = "{}", goal: str = "Fix the issues", + on_text_delta: Optional[Callable[[str], None]] = None, ) -> Tuple[str, str, Dict[str, Any], str]: """ Refine prompt based on image + issue. @@ -178,6 +179,8 @@ Issue: {issue} system=system_prompt, user_message=user_message, image_base64=image_b64, + streaming=on_text_delta is not None, + on_text_delta=on_text_delta, ) content = response.get("text", "") diff --git a/services/safe_io.py b/services/safe_io.py index ed068ea..70933a1 100644 --- a/services/safe_io.py +++ b/services/safe_io.py @@ -641,3 +641,111 @@ def safe_request_json( if isinstance(e.reason, SSRFError): raise e.reason raise RuntimeError(f"Request failed: {e}") + + +def safe_request_text_stream( + method: str, + url: str, + json_body: Any = None, + *, + allow_hosts: Optional[Set[str]] = None, + allow_any_public_host: bool = False, + allow_loopback_hosts: Optional[Set[str]] = None, + headers: Optional[dict] = None, + timeout_sec: int = 10, + max_line_bytes: int = 64 * 1024, + max_redirects: int = 0, + policy: Optional[OutboundPolicy] = None, +): + """ + Perform a safe HTTP request and yield response lines as UTF-8 text. + + Intended for SSE/event-stream style provider responses. + """ + import json + import urllib.error + import urllib.parse + + current_url = url + current_method = method + current_body = json.dumps(json_body).encode("utf-8") if json_body else None + redirects_followed = 0 + + while True: + _scheme, _host, _port, pinned_ips = validate_outbound_url( + current_url, + allow_hosts=allow_hosts, + allow_any_public_host=allow_any_public_host, + allow_loopback_hosts=allow_loopback_hosts, + policy=policy, + ) + + request = urllib.request.Request( + current_url, data=current_body, method=current_method + ) + try: + from ..config import PACK_VERSION + except ImportError: # pragma: no cover + try: + from config import PACK_VERSION # type: ignore + except ImportError: + PACK_VERSION = "0.0.0" + + request.add_header("User-Agent", f"ComfyUI-OpenClaw/{PACK_VERSION}") + request.add_header("Content-Type", "application/json") + + ALLOWED_HEADER_PREFIXES = ("x-", "content-type", "authorization", "accept") + if headers: + for key, value in headers.items(): + key_lower = key.lower() + if any(key_lower.startswith(p) for p in ALLOWED_HEADER_PREFIXES): + request.add_header(key, value) + else: + logger.debug(f"Skipping disallowed header: {key}") + + opener = _build_pinned_opener(pinned_ips) + + try: + response = opener.open(request, timeout=timeout_sec) + code = response.getcode() + + if code in (301, 302, 303, 307, 308): + try: + response.close() + except Exception: + pass + if max_redirects > 0 and redirects_followed < max_redirects: + redirects_followed += 1 + new_loc = getattr(response, "headers", {}).get("Location") + if not new_loc: + raise RuntimeError(f"Redirect without Location: {code}") + current_url = urllib.parse.urljoin(current_url, new_loc) + if code in (301, 302, 303): + current_method = "GET" + current_body = None + continue + raise RuntimeError(f"Too many redirects: {max_redirects}") + + try: + while True: + line = response.readline(max_line_bytes + 1) + if not line: + break + if len(line) > max_line_bytes: + raise RuntimeError( + f"Stream line exceeds max_line_bytes ({max_line_bytes})" + ) + yield line.decode("utf-8", errors="replace") + finally: + try: + response.close() + except Exception: + pass + return + + except urllib.error.HTTPError as e: + raise RuntimeError(f"HTTP error {e.code}: {e.reason}") + except urllib.error.URLError as e: + if isinstance(e.reason, SSRFError): + raise e.reason + raise RuntimeError(f"Request failed: {e}") diff --git a/tests/e2e/specs/r38_lite.spec.js b/tests/e2e/specs/r38_lite.spec.js index 9564fc4..3e46a16 100644 --- a/tests/e2e/specs/r38_lite.spec.js +++ b/tests/e2e/specs/r38_lite.spec.js @@ -113,4 +113,50 @@ test.describe('R38 Lite UX lifecycle', () => { expect(pageErrors).toEqual([]); }); + + test('Planner streaming path renders live preview and final result', async ({ page }) => { + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await page.route('**/openclaw/assist/planner/stream', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: + 'event: ready\n' + + 'data: {"ok":true,"kind":"planner","mode":"sse"}\n\n' + + 'event: stage\n' + + 'data: {"phase":"dispatch","message":"Dispatching assist request"}\n\n' + + 'event: delta\n' + + 'data: {"text":"{\\"positive_prompt\\":\\"foggy ","preview_chars":28}\n\n' + + 'event: delta\n' + + 'data: {"text":"mountain\\"}","preview_chars":38}\n\n' + + 'event: final\n' + + 'data: {"ok":true,"kind":"planner","result":{"positive":"A foggy mountain valley","negative":"lowres, blurry","params":{"width":1024,"height":1024}},"streaming":{"preview_chars":38,"preview_truncated":false}}\n\n', + }); + }); + + // Force streaming capability in the shared API instance cache for this page. + await page.evaluate(async () => { + const mod = await import('/web/openclaw_api.js'); + mod.openclawApi._capabilitiesCache = { + ok: true, + data: { features: { assist_streaming: true } }, + }; + mod.openclawApi._capabilitiesCacheTs = Date.now(); + }); + + await clickTab(page, 'Planner'); + await page.locator('#planner-run-btn').click(); + + await expect(page.locator('#planner-loading')).toBeVisible(); + await expect(page.locator('#planner-stream-preview')).toHaveValue(/foggy/, { timeout: 2000 }); + await expect(page.locator('#planner-stage')).toHaveText( + /Dispatching assist request|Parsing and validating output\.\.\./, + ); + await expect(page.locator('#planner-out-pos')).toHaveValue('A foggy mountain valley'); + await expect(page.locator('#planner-out-neg')).toHaveValue('lowres, blurry'); + + expect(pageErrors).toEqual([]); + }); }); diff --git a/tests/test_api_assist.py b/tests/test_api_assist.py index b3c7055..8e375e9 100644 --- a/tests/test_api_assist.py +++ b/tests/test_api_assist.py @@ -113,6 +113,98 @@ class TestAssistAPI(unittest.IsolatedAsyncioTestCase): self.assertEqual(body["refined_positive"], "new_pos") self.assertEqual(body["rationale"], "Fixed hands") + async def test_planner_stream_success_emits_delta_and_final(self): + request = AsyncMock() + request.json = AsyncMock( + return_value={ + "profile": "SDXL-v1", + "requirements": "cat", + "style_directives": "cinematic", + "seed": 123, + } + ) + + class FakeStreamResponse: + def __init__(self, status=200, headers=None): + self.status = status + self.headers = headers or {} + self.writes = [] + + async def prepare(self, _request): + return self + + async def write(self, data): + self.writes.append(data) + return None + + async def fake_run_in_thread(func, *args, **kwargs): + cb = kwargs.get("on_text_delta") + if callable(cb): + cb("partial-json ") + cb("preview") + return ("pos", "neg", {"width": 1024, "seed": 123}) + + with ( + patch("api.assist.require_admin_token", return_value=(True, None)), + patch("api.assist.check_rate_limit", return_value=True), + patch("api.assist.web.StreamResponse", FakeStreamResponse), + patch("api.assist.run_in_thread", side_effect=fake_run_in_thread), + ): + resp = await self.handler.planner_stream_handler(request) + + self.assertEqual(resp.status, 200) + text = b"".join(resp.writes).decode("utf-8", errors="replace") + self.assertIn("event: ready", text) + self.assertIn("event: delta", text) + self.assertIn("event: final", text) + self.assertIn('"positive":"pos"', text) + self.assertIn('"preview_chars"', text) + + async def test_refiner_stream_unauthorized(self): + request = AsyncMock() + request.headers = {} + with patch("api.assist.require_admin_token", return_value=(False, "Denied")): + resp = await self.handler.refiner_stream_handler(request) + self.assertEqual(resp.status, 401) + + async def test_planner_stream_internal_error_emits_error_event(self): + request = AsyncMock() + request.json = AsyncMock( + return_value={ + "profile": "SDXL-v1", + "requirements": "cat", + "style_directives": "cinematic", + } + ) + + class FakeStreamResponse: + def __init__(self, status=200, headers=None): + self.status = status + self.headers = headers or {} + self.writes = [] + + async def prepare(self, _request): + return self + + async def write(self, data): + self.writes.append(data) + return None + + async def fake_run_in_thread(func, *args, **kwargs): + raise RuntimeError("boom") + + with ( + patch("api.assist.require_admin_token", return_value=(True, None)), + patch("api.assist.check_rate_limit", return_value=True), + patch("api.assist.web.StreamResponse", FakeStreamResponse), + patch("api.assist.run_in_thread", side_effect=fake_run_in_thread), + ): + resp = await self.handler.planner_stream_handler(request) + + text = b"".join(resp.writes).decode("utf-8", errors="replace") + self.assertIn("event: error", text) + self.assertIn("Internal server error", text) + async def test_compose_no_auth(self): """Test compose rejects unauthenticated requests.""" request = AsyncMock() diff --git a/tests/test_r38_openai_stream_parser.py b/tests/test_r38_openai_stream_parser.py new file mode 100644 index 0000000..e146a48 --- /dev/null +++ b/tests/test_r38_openai_stream_parser.py @@ -0,0 +1,61 @@ +""" +R38: OpenAI-compatible streaming parser contract tests. +""" + +import unittest +from unittest.mock import patch + +from services.providers import openai_compat + + +class TestR38OpenAICompatStreamParser(unittest.TestCase): + def test_make_request_stream_aggregates_deltas_and_emits_callback(self): + lines = [ + 'data: {"choices":[{"delta":{"content":"Hello "}}]}\n', + 'data: {"choices":[{"delta":{"content":"world"}}]}\n', + "data: [DONE]\n", + ] + seen = [] + + with patch( + "services.providers.openai_compat.safe_request_text_stream", + return_value=iter(lines), + ): + result = openai_compat.make_request_stream( + base_url="https://api.example.com/v1", + api_key="sk-test", + messages=[{"role": "user", "content": "hi"}], + model="test-model", + on_text_delta=seen.append, + allow_any_public_host=True, + ) + + self.assertEqual(result["text"], "Hello world") + self.assertEqual(seen, ["Hello ", "world"]) + self.assertTrue(result["raw"]["stream"]) + self.assertEqual(result["raw"]["chunks"], 2) + self.assertTrue(result["raw"]["saw_done"]) + + def test_make_request_stream_ignores_non_json_data_lines(self): + lines = [ + ": keepalive\n", + "data: not-json\n", + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n', + "data: [DONE]\n", + ] + with patch( + "services.providers.openai_compat.safe_request_text_stream", + return_value=iter(lines), + ): + result = openai_compat.make_request_stream( + base_url="https://api.example.com/v1", + api_key="sk-test", + messages=[{"role": "user", "content": "hi"}], + model="test-model", + allow_any_public_host=True, + ) + self.assertEqual(result["text"], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/openclaw_api.js b/web/openclaw_api.js index a8589c2..e11cb71 100644 --- a/web/openclaw_api.js +++ b/web/openclaw_api.js @@ -5,12 +5,33 @@ import { OpenClawSession } from "./openclaw_session.js"; import { fetchApi, apiURL, fileURL } from "./openclaw_comfy_api.js"; import { isAbortError, linkAbortSignal, parseJsonSafe } from "./openclaw_utils.js"; +import { + composeFetchWrappersOnce, + withAbortPassthrough, + withGetRetry, + withPreconnectHint, +} from "./openclaw_fetch_wrappers.js"; export class OpenClawAPI { constructor() { // baseUrl is handled by ComfyUI shim provided via fetchApi this.prefix = "/openclaw"; this.legacyPrefix = "/moltbot"; + this._capabilitiesCache = null; + this._capabilitiesCacheTs = 0; + + // R96: Compose fetch wrappers exactly once per fetch instance to avoid + // duplicate retry/preconnect/abort decoration on repeated bootstrap. + this._decoratedFetchApi = composeFetchWrappersOnce(fetchApi, [ + withAbortPassthrough(), + withPreconnectHint(), + withGetRetry({ retries: 1 }), + ]); + this._decoratedNativeFetch = composeFetchWrappersOnce(fetch.bind(window), [ + withAbortPassthrough(), + withPreconnectHint(), + withGetRetry({ retries: 1 }), + ]); } /** @@ -24,6 +45,43 @@ export class OpenClawAPI { return `${this.prefix}${suffix}`; } + _candidatePaths(url) { + const candidates = []; + if (typeof url === "string") { + candidates.push(url); + if (url.startsWith(this.prefix + "/")) { + candidates.push(url.replace(this.prefix, this.legacyPrefix)); + } else if (url.startsWith(this.legacyPrefix + "/")) { + candidates.push(url.replace(this.legacyPrefix, this.prefix)); + } + } else { + candidates.push(url); + } + return candidates; + } + + async _fetchWithCandidates(url, options = {}) { + let response = null; + const candidates = this._candidatePaths(url); + + for (const candidate of candidates) { + response = await this._decoratedFetchApi(candidate, options); + if (response.status !== 404) break; + } + + if (response && response.status === 404 && typeof url === "string") { + for (const candidate of candidates) { + try { + response = await this._decoratedNativeFetch(fileURL(candidate), options); + if (response.status !== 404) break; + } catch { + // ignore and continue fallback probes + } + } + } + return response; + } + _adminTokenHeaders(token) { const t = token || this._getAdminToken(); return { @@ -62,43 +120,10 @@ export class OpenClawAPI { try { // R26: Use ComfyUI shim (fetchApi) which handles base path automatically - let response = null; - - const candidates = []; - if (typeof url === "string") { - candidates.push(url); - if (url.startsWith(this.prefix + "/")) { - candidates.push(url.replace(this.prefix, this.legacyPrefix)); - } else if (url.startsWith(this.legacyPrefix + "/")) { - candidates.push(url.replace(this.legacyPrefix, this.prefix)); - } - } else { - candidates.push(url); - } - - // 1) Try fetchApi (preferred) - for (const candidate of candidates) { - response = await fetchApi(candidate, { - ...fetchOptions, - signal: controller.signal, - }); - if (response.status !== 404) break; - } - - // 2) Try direct non-/api route as a hardened fallback (legacy loader / routing order issues) - if (response && response.status === 404 && typeof url === "string") { - for (const candidate of candidates) { - try { - response = await fetch(fileURL(candidate), { - ...fetchOptions, - signal: controller.signal, - }); - if (response.status !== 404) break; - } catch { - // ignore and keep trying - } - } - } + const response = await this._fetchWithCandidates(url, { + ...fetchOptions, + signal: controller.signal, + }); clearTimeout(timeoutId); @@ -176,7 +201,21 @@ export class OpenClawAPI { // R19: Capabilities async getCapabilities() { - return this.fetch(this._path("/capabilities")); + const now = Date.now(); + if (this._capabilitiesCache && (now - this._capabilitiesCacheTs) < 5000) { + return this._capabilitiesCache; + } + const res = await this.fetch(this._path("/capabilities")); + if (res?.ok) { + this._capabilitiesCache = res; + this._capabilitiesCacheTs = now; + } + return res; + } + + async supportsAssistStreaming() { + const caps = await this.getCapabilities(); + return !!caps?.ok && !!caps?.data?.features?.assist_streaming; } // F17: ComfyUI History @@ -321,6 +360,152 @@ export class OpenClawAPI { // --- Assist Endpoints (F8/F21) --- + _parseSSEChunk(rawChunk) { + const lines = rawChunk.split(/\r?\n/); + let event = "message"; + const dataLines = []; + for (const line of lines) { + if (!line) continue; + if (line.startsWith("event:")) { + event = line.slice(6).trim() || "message"; + } else if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trim()); + } + } + if (!dataLines.length) return null; + const joined = dataLines.join("\n"); + let data = null; + try { + data = JSON.parse(joined); + } catch { + data = { raw: joined }; + } + return { event, data }; + } + + async streamSSEPost(url, payload, { signal = null, timeout = 60000, onEvent = null } = {}) { + const controller = new AbortController(); + let timedOut = false; + let cancelledByCaller = false; + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout); + + if (signal) { + if (signal.aborted) { + cancelledByCaller = true; + controller.abort(); + } else { + signal.addEventListener("abort", () => { + cancelledByCaller = true; + controller.abort(); + }, { once: true }); + } + } + + try { + const response = await this._fetchWithCandidates(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "text/event-stream", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response || !response.ok) { + clearTimeout(timeoutId); + let data = null; + try { + data = await response?.json?.(); + } catch { + try { data = await response?.text?.(); } catch { } + } + return { + ok: false, + status: response ? response.status : 0, + error: (data && data.error) || response?.statusText || "request_failed", + data, + }; + } + + const finalEnvelope = { value: null }; + const dispatchEvent = (evt) => { + if (!evt) return; + if (evt.event === "final") { + finalEnvelope.value = evt.data; + } + if (typeof onEvent === "function") onEvent(evt); + }; + + if (!response.body || typeof response.body.getReader !== "function") { + const text = await response.text(); + const chunks = text.split(/\r?\n\r?\n/); + for (const chunk of chunks) dispatchEvent(this._parseSSEChunk(chunk)); + } else { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const findBoundary = (text) => { + const idxCRLF = text.indexOf("\r\n\r\n"); + const idxLF = text.indexOf("\n\n"); + if (idxCRLF === -1) return { index: idxLF, len: 2 }; + if (idxLF === -1) return { index: idxCRLF, len: 4 }; + return idxCRLF < idxLF ? { index: idxCRLF, len: 4 } : { index: idxLF, len: 2 }; + }; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary = findBoundary(buffer); + while (boundary.index >= 0) { + const rawChunk = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.len); + dispatchEvent(this._parseSSEChunk(rawChunk)); + boundary = findBoundary(buffer); + } + } + buffer += decoder.decode(); + if (buffer.trim()) { + dispatchEvent(this._parseSSEChunk(buffer)); + } + } + + clearTimeout(timeoutId); + if (finalEnvelope.value?.ok) { + return { + ok: true, + status: 200, + data: finalEnvelope.value.result, + stream: finalEnvelope.value.streaming || {}, + envelope: finalEnvelope.value, + }; + } + if (finalEnvelope.value && finalEnvelope.value.ok === false) { + return { + ok: false, + status: 500, + error: finalEnvelope.value.error || "stream_failed", + data: finalEnvelope.value, + }; + } + return { ok: false, status: 0, error: "stream_incomplete" }; + } catch (err) { + clearTimeout(timeoutId); + const isAbort = err?.name === "AbortError"; + const abortKind = cancelledByCaller ? "cancelled" : (timedOut ? "timeout" : "cancelled"); + return { + ok: false, + status: 0, + error: isAbort ? abortKind : "network_error", + detail: err?.message, + }; + } + } + /** * Run Prompt Planner. * @param {object} params - { profile, requirements, style_directives, seed } @@ -339,6 +524,14 @@ export class OpenClawAPI { }); } + async runPlannerStream(params, { signal = null, onEvent = null } = {}) { + return this.streamSSEPost(this._path("/assist/planner/stream"), params, { + signal, + timeout: 60000, + onEvent, + }); + } + /** * Run Prompt Refiner. * @param {object} params - { image_b64, orig_positive, orig_negative, issue, params_json, goal } @@ -357,6 +550,14 @@ export class OpenClawAPI { }); } + async runRefinerStream(params, { signal = null, onEvent = null } = {}) { + return this.streamSSEPost(this._path("/assist/refiner/stream"), params, { + signal, + timeout: 60000, + onEvent, + }); + } + // --- F22: Presets --- async listPresets(category) { diff --git a/web/openclaw_fetch_wrappers.js b/web/openclaw_fetch_wrappers.js new file mode 100644 index 0000000..52c0bd5 --- /dev/null +++ b/web/openclaw_fetch_wrappers.js @@ -0,0 +1,123 @@ +/** + * R96: Fetch wrapper composition helpers with idempotence guard. + * + * Prevents duplicate wrapper stacking when bootstrap code runs multiple times. + */ + +const WRAP_META = Symbol.for("openclaw.fetch_wrapper_meta"); +const PRECONNECTED_ORIGINS = new Set(); + +function getDecoratorId(decorator, index) { + if (typeof decorator?.id === "string" && decorator.id) return decorator.id; + if (typeof decorator?.name === "string" && decorator.name) return decorator.name; + return `decorator_${index}`; +} + +function arraysEqual(a = [], b = []) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function getFetchWrapperMeta(fetchFn) { + return fetchFn?.[WRAP_META] || null; +} + +export function composeFetchWrappersOnce(fetchFn, decorators = []) { + if (typeof fetchFn !== "function") { + throw new TypeError("fetchFn must be a function"); + } + if (!Array.isArray(decorators)) { + throw new TypeError("decorators must be an array"); + } + + const chainIds = decorators.map(getDecoratorId); + const existingMeta = getFetchWrapperMeta(fetchFn); + if (existingMeta && arraysEqual(existingMeta.chainIds, chainIds)) { + return fetchFn; + } + + let wrapped = fetchFn; + decorators.forEach((decorator, index) => { + if (typeof decorator !== "function") { + throw new TypeError(`decorator at index ${index} must be a function`); + } + wrapped = decorator(wrapped); + }); + + const baseMeta = existingMeta || { baseFetch: fetchFn, appliedCount: 0, chainIds: [] }; + Object.defineProperty(wrapped, WRAP_META, { + value: { + baseFetch: baseMeta.baseFetch || fetchFn, + appliedCount: (baseMeta.appliedCount || 0) + 1, + chainIds, + }, + enumerable: false, + configurable: false, + writable: false, + }); + return wrapped; +} + +/** + * Decorator: ensures signal is passed through when options contain one. + * (Useful when other decorators clone/normalize init objects.) + */ +export function withAbortPassthrough() { + const decorator = async (next, input, init = {}) => next(input, { ...init }); + const wrapper = (next) => (input, init) => decorator(next, input, init); + wrapper.id = "abort_passthrough"; + return wrapper; +} + +/** + * Decorator: best-effort browser preconnect hint for HTTP(S) origins. + */ +export function withPreconnectHint() { + const wrapper = (next) => (input, init) => { + try { + const url = typeof input === "string" ? input : (input?.url || ""); + if (url && typeof document !== "undefined" && /^https?:\/\//i.test(url)) { + const origin = new URL(url, window.location.href).origin; + if (!PRECONNECTED_ORIGINS.has(origin)) { + PRECONNECTED_ORIGINS.add(origin); + const link = document.createElement("link"); + link.rel = "preconnect"; + link.href = origin; + document.head.appendChild(link); + } + } + } catch { + // best-effort only + } + return next(input, init); + }; + wrapper.id = "preconnect_hint"; + return wrapper; +} + +/** + * Decorator: single retry for idempotent GET requests on network failures. + */ +export function withGetRetry({ retries = 1 } = {}) { + const wrapper = (next) => async (input, init = {}) => { + const method = String(init?.method || "GET").toUpperCase(); + const shouldRetry = method === "GET" && retries > 0; + let lastErr; + const attempts = shouldRetry ? retries + 1 : 1; + for (let i = 0; i < attempts; i += 1) { + try { + return await next(input, init); + } catch (err) { + const isAbort = err?.name === "AbortError"; + if (isAbort || i >= attempts - 1) throw err; + lastErr = err; + } + } + throw lastErr || new Error("fetch_retry_exhausted"); + }; + wrapper.id = `retry_get_${retries}`; + return wrapper; +} diff --git a/web/tabs/planner_tab.js b/web/tabs/planner_tab.js index df2041e..8831795 100644 --- a/web/tabs/planner_tab.js +++ b/web/tabs/planner_tab.js @@ -43,6 +43,10 @@ export const PlannerTab = {
Elapsed: 0s
+
+
Live Preview (best effort)
+ +
@@ -90,9 +94,11 @@ export const PlannerTab = { const style = container.querySelector("#planner-style").value; const resDiv = container.querySelector("#planner-results"); + const previewEl = container.querySelector("#planner-stream-preview"); clearError(container); resDiv.style.display = "none"; + if (previewEl) previewEl.value = ""; const requestId = ++activeRequestId; const signal = lifecycle.begin("Preparing request..."); @@ -102,14 +108,35 @@ export const PlannerTab = { await new Promise((resolve) => requestAnimationFrame(resolve)); lifecycle.setStage("Waiting for provider response..."); - const res = await openclawApi.runPlanner( - { - profile, - requirements: reqs, - style_directives: style - }, - signal - ); + const payload = { + profile, + requirements: reqs, + style_directives: style + }; + + let res; + const streamingSupported = await openclawApi.supportsAssistStreaming(); + if (streamingSupported) { + res = await openclawApi.runPlannerStream(payload, { + signal, + onEvent: (evt) => { + if (requestId !== activeRequestId || !evt) return; + if (evt.event === "stage" && evt.data?.message) { + lifecycle.setStage(evt.data.message); + } else if (evt.event === "delta" && typeof evt.data?.text === "string" && previewEl) { + previewEl.value += evt.data.text; + previewEl.scrollTop = previewEl.scrollHeight; + } + } + }); + if (!res.ok && !["cancelled", "timeout"].includes(res.error || "")) { + // Fallback to classic path if streaming transport/path degrades. + lifecycle.setStage("Streaming unavailable, falling back..."); + res = await openclawApi.runPlanner(payload, signal); + } + } else { + res = await openclawApi.runPlanner(payload, signal); + } if (requestId !== activeRequestId) { return; diff --git a/web/tabs/refiner_tab.js b/web/tabs/refiner_tab.js index bc214c1..09db8aa 100644 --- a/web/tabs/refiner_tab.js +++ b/web/tabs/refiner_tab.js @@ -49,6 +49,10 @@ export const RefinerTab = {
Elapsed: 0s
+
+
Live Preview (best effort)
+ +
@@ -112,7 +116,9 @@ export const RefinerTab = { container.querySelector("#refiner-run-btn").onclick = async () => { clearError(container); const resDiv = container.querySelector("#refiner-results"); + const previewEl = container.querySelector("#refiner-stream-preview"); resDiv.style.display = "none"; + if (previewEl) previewEl.value = ""; const requestId = ++activeRequestId; const signal = lifecycle.begin("Preparing request..."); @@ -122,15 +128,35 @@ export const RefinerTab = { await new Promise((resolve) => requestAnimationFrame(resolve)); lifecycle.setStage("Waiting for provider response..."); - const res = await openclawApi.runRefiner( - { - image_b64: currentImgB64, - orig_positive: container.querySelector("#refiner-orig-pos").value, - orig_negative: container.querySelector("#refiner-orig-neg").value, - issue: container.querySelector("#refiner-issue").value - }, - signal - ); + const payload = { + image_b64: currentImgB64, + orig_positive: container.querySelector("#refiner-orig-pos").value, + orig_negative: container.querySelector("#refiner-orig-neg").value, + issue: container.querySelector("#refiner-issue").value + }; + + let res; + const streamingSupported = await openclawApi.supportsAssistStreaming(); + if (streamingSupported) { + res = await openclawApi.runRefinerStream(payload, { + signal, + onEvent: (evt) => { + if (requestId !== activeRequestId || !evt) return; + if (evt.event === "stage" && evt.data?.message) { + lifecycle.setStage(evt.data.message); + } else if (evt.event === "delta" && typeof evt.data?.text === "string" && previewEl) { + previewEl.value += evt.data.text; + previewEl.scrollTop = previewEl.scrollHeight; + } + } + }); + if (!res.ok && !["cancelled", "timeout"].includes(res.error || "")) { + lifecycle.setStage("Streaming unavailable, falling back..."); + res = await openclawApi.runRefiner(payload, signal); + } + } else { + res = await openclawApi.runRefiner(payload, signal); + } if (requestId !== activeRequestId) { return; diff --git a/web/tests/e2e-harness.html b/web/tests/e2e-harness.html index ccfba80..89e63fb 100644 --- a/web/tests/e2e-harness.html +++ b/web/tests/e2e-harness.html @@ -22,6 +22,13 @@ parseJsonOrThrow, parseJsonSafe, } from '../openclaw_utils.js'; + import { + composeFetchWrappersOnce, + getFetchWrapperMeta, + withAbortPassthrough, + withGetRetry, + withPreconnectHint, + } from '../openclaw_fetch_wrappers.js'; const logEl = document.getElementById('log'); const mount = document.getElementById('mount'); @@ -162,6 +169,29 @@ detach(); }); + await run('R96 fetch wrapper composition is idempotent', async () => { + let calls = 0; + const baseFetch = async () => { + calls += 1; + if (calls === 1) throw new Error('transient'); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + + const decorators = [withAbortPassthrough(), withPreconnectHint(), withGetRetry({ retries: 1 })]; + const wrapped1 = composeFetchWrappersOnce(baseFetch, decorators); + const wrapped2 = composeFetchWrappersOnce(wrapped1, decorators); + + assert(wrapped1 === wrapped2, 'expected idempotent wrapper reuse on repeated composition'); + + const res = await wrapped2('https://example.com/health', { method: 'GET' }); + assert(res.status === 200, 'wrapped fetch did not return response'); + assert(calls === 2, `expected single retry path (2 calls), got ${calls}`); + + const meta = getFetchWrapperMeta(wrapped2); + assert(meta && Array.isArray(meta.chainIds), 'missing wrapper metadata'); + assert(meta.chainIds.length === 3, 'unexpected decorator chain metadata'); + }); + setDone(); })();